Explorar el Código

feat(dashboard): Implement remaining bulk actions (#3627)

Michael Bromley hace 6 meses
padre
commit
13989b932b
Se han modificado 62 ficheros con 1563 adiciones y 487 borrados
  1. 147 0
      packages/dashboard/src/app/common/delete-bulk-action.tsx
  2. 1 1
      packages/dashboard/src/app/common/duplicate-bulk-action.tsx
  3. 9 0
      packages/dashboard/src/app/routes/_authenticated/_administrators/administrators.graphql.ts
  4. 7 0
      packages/dashboard/src/app/routes/_authenticated/_administrators/administrators.tsx
  5. 15 0
      packages/dashboard/src/app/routes/_authenticated/_administrators/components/administrator-bulk-actions.tsx
  6. 11 0
      packages/dashboard/src/app/routes/_authenticated/_assets/assets.graphql.ts
  7. 10 2
      packages/dashboard/src/app/routes/_authenticated/_assets/assets.tsx
  8. 45 0
      packages/dashboard/src/app/routes/_authenticated/_assets/components/asset-bulk-actions.tsx
  9. 9 0
      packages/dashboard/src/app/routes/_authenticated/_channels/channels.graphql.ts
  10. 7 0
      packages/dashboard/src/app/routes/_authenticated/_channels/channels.tsx
  11. 15 0
      packages/dashboard/src/app/routes/_authenticated/_channels/components/channel-bulk-actions.tsx
  12. 0 110
      packages/dashboard/src/app/routes/_authenticated/_collections/components/assign-collections-to-channel-dialog.tsx
  13. 39 110
      packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx
  14. 15 0
      packages/dashboard/src/app/routes/_authenticated/_countries/components/country-bulk-actions.tsx
  15. 9 0
      packages/dashboard/src/app/routes/_authenticated/_countries/countries.graphql.ts
  16. 7 0
      packages/dashboard/src/app/routes/_authenticated/_countries/countries.tsx
  17. 15 0
      packages/dashboard/src/app/routes/_authenticated/_customer-groups/components/customer-group-bulk-actions.tsx
  18. 9 0
      packages/dashboard/src/app/routes/_authenticated/_customer-groups/customer-groups.graphql.ts
  19. 7 0
      packages/dashboard/src/app/routes/_authenticated/_customer-groups/customer-groups.tsx
  20. 15 0
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-bulk-actions.tsx
  21. 9 1
      packages/dashboard/src/app/routes/_authenticated/_customers/customers.graphql.ts
  22. 7 0
      packages/dashboard/src/app/routes/_authenticated/_customers/customers.tsx
  23. 104 0
      packages/dashboard/src/app/routes/_authenticated/_facets/components/facet-bulk-actions.tsx
  24. 30 0
      packages/dashboard/src/app/routes/_authenticated/_facets/facets.graphql.ts
  25. 24 0
      packages/dashboard/src/app/routes/_authenticated/_facets/facets.tsx
  26. 58 0
      packages/dashboard/src/app/routes/_authenticated/_payment-methods/components/payment-method-bulk-actions.tsx
  27. 27 0
      packages/dashboard/src/app/routes/_authenticated/_payment-methods/payment-methods.graphql.ts
  28. 30 8
      packages/dashboard/src/app/routes/_authenticated/_payment-methods/payment-methods.tsx
  29. 4 1
      packages/dashboard/src/app/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx
  30. 36 110
      packages/dashboard/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx
  31. 36 105
      packages/dashboard/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx
  32. 82 0
      packages/dashboard/src/app/routes/_authenticated/_promotions/components/promotion-bulk-actions.tsx
  33. 25 0
      packages/dashboard/src/app/routes/_authenticated/_promotions/promotions.graphql.ts
  34. 24 0
      packages/dashboard/src/app/routes/_authenticated/_promotions/promotions.tsx
  35. 1 1
      packages/dashboard/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx
  36. 15 0
      packages/dashboard/src/app/routes/_authenticated/_roles/components/role-bulk-actions.tsx
  37. 9 0
      packages/dashboard/src/app/routes/_authenticated/_roles/roles.graphql.ts
  38. 7 0
      packages/dashboard/src/app/routes/_authenticated/_roles/roles.tsx
  39. 15 0
      packages/dashboard/src/app/routes/_authenticated/_sellers/components/seller-bulk-actions.tsx
  40. 9 0
      packages/dashboard/src/app/routes/_authenticated/_sellers/sellers.graphql.ts
  41. 7 0
      packages/dashboard/src/app/routes/_authenticated/_sellers/sellers.tsx
  42. 1 1
      packages/dashboard/src/app/routes/_authenticated/_sellers/sellers_.$id.tsx
  43. 61 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-bulk-actions.tsx
  44. 27 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts
  45. 19 0
      packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx
  46. 58 0
      packages/dashboard/src/app/routes/_authenticated/_stock-locations/components/stock-location-bulk-actions.tsx
  47. 25 0
      packages/dashboard/src/app/routes/_authenticated/_stock-locations/stock-locations.graphql.ts
  48. 19 0
      packages/dashboard/src/app/routes/_authenticated/_stock-locations/stock-locations.tsx
  49. 15 0
      packages/dashboard/src/app/routes/_authenticated/_tax-categories/components/tax-category-bulk-actions.tsx
  50. 9 0
      packages/dashboard/src/app/routes/_authenticated/_tax-categories/tax-categories.graphql.ts
  51. 7 0
      packages/dashboard/src/app/routes/_authenticated/_tax-categories/tax-categories.tsx
  52. 15 0
      packages/dashboard/src/app/routes/_authenticated/_tax-rates/components/tax-rate-bulk-actions.tsx
  53. 9 0
      packages/dashboard/src/app/routes/_authenticated/_tax-rates/tax-rates.graphql.ts
  54. 7 0
      packages/dashboard/src/app/routes/_authenticated/_tax-rates/tax-rates.tsx
  55. 15 0
      packages/dashboard/src/app/routes/_authenticated/_zones/components/zone-bulk-actions.tsx
  56. 9 0
      packages/dashboard/src/app/routes/_authenticated/_zones/zones.graphql.ts
  57. 7 0
      packages/dashboard/src/app/routes/_authenticated/_zones/zones.tsx
  58. 90 0
      packages/dashboard/src/lib/components/shared/asset/asset-bulk-actions.tsx
  59. 12 7
      packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx
  60. 70 0
      packages/dashboard/src/lib/components/shared/assign-to-channel-bulk-action.tsx
  61. 48 30
      packages/dashboard/src/lib/components/shared/assign-to-channel-dialog.tsx
  62. 89 0
      packages/dashboard/src/lib/components/shared/remove-from-channel-bulk-action.tsx

+ 147 - 0
packages/dashboard/src/app/common/delete-bulk-action.tsx

@@ -0,0 +1,147 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { TrashIcon } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { api } from '@/graphql/api.js';
+import { getMutationName, usePaginatedList } from '@/index.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+
+interface DeleteBulkActionProps {
+    /** The GraphQL mutation document to execute */
+    mutationDocument: any;
+    /** The entity name for localization (e.g., "products", "administrators") */
+    entityName: string;
+    /** The required permissions for this action */
+    requiredPermissions: string[];
+    /** Optional callback for additional cleanup after successful deletion */
+    onSuccess?: () => void;
+    /** Optional query keys to invalidate after successful deletion */
+    invalidateQueries?: string[];
+    /** The selected items to delete */
+    selection: any[];
+    /** The table instance */
+    table: any;
+}
+
+/**
+ * A reusable delete bulk action component that can be used across all bulk action files.
+ *
+ * This component handles the common pattern of deleting multiple entities:
+ * - Executes the provided GraphQL mutation
+ * - Shows success/error toasts with localized messages
+ * - Refetches the paginated list
+ * - Resets table selection
+ * - Optionally invalidates additional queries
+ * - Optionally calls additional success callbacks
+ *
+ * @example
+ * ```tsx
+ * // Basic usage
+ * export const DeleteProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+ *     return (
+ *         <DeleteBulkAction
+ *             mutationDocument={deleteProductsDocument}
+ *             entityName="products"
+ *             requiredPermissions={['DeleteCatalog', 'DeleteProduct']}
+ *             selection={selection}
+ *             table={table}
+ *         />
+ *     );
+ * };
+ *
+ * // With additional cleanup
+ * export const DeleteCollectionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+ *     return (
+ *         <DeleteBulkAction
+ *             mutationDocument={deleteCollectionsDocument}
+ *             entityName="collections"
+ *             requiredPermissions={['DeleteCatalog', 'DeleteCollection']}
+ *             invalidateQueries={['childCollections']}
+ *             onSuccess={() => {
+ *                 // Additional cleanup logic
+ *             }}
+ *             selection={selection}
+ *             table={table}
+ *         />
+ *     );
+ * };
+ * ```
+ */
+export function DeleteBulkAction({
+    mutationDocument,
+    entityName,
+    requiredPermissions,
+    onSuccess,
+    invalidateQueries = [],
+    selection,
+    table,
+}: Readonly<DeleteBulkActionProps>) {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { i18n } = useLingui();
+    const queryClient = useQueryClient();
+
+    const { mutate } = useMutation({
+        mutationFn: api.mutate(mutationDocument),
+        onSuccess: (result: any) => {
+            let deleted = 0;
+            let failed = 0;
+            const errors: string[] = [];
+
+            // Get the mutation result field name from the document
+            const mutationResultFieldName = getMutationName(mutationDocument);
+            const deleteResults = result[mutationResultFieldName];
+
+            for (const item of deleteResults) {
+                if (item.result === 'DELETED') {
+                    deleted++;
+                } else {
+                    failed++;
+                    if (item.message) {
+                        errors.push(item.message);
+                    }
+                }
+            }
+
+            if (0 < deleted) {
+                toast.success(i18n.t(`Deleted ${deleted} ${entityName}`));
+            }
+            if (0 < failed) {
+                const errorMessage =
+                    errors.length > 0
+                        ? i18n.t(`Failed to delete ${failed} ${entityName}: ${errors.join(', ')}`)
+                        : i18n.t(`Failed to delete ${failed} ${entityName}`);
+                toast.error(errorMessage);
+            }
+
+            refetchPaginatedList();
+            table.resetRowSelection();
+
+            // Invalidate additional queries if specified
+            invalidateQueries.forEach(queryKey => {
+                queryClient.invalidateQueries({ queryKey: [queryKey] });
+            });
+
+            // Call additional success callback if provided
+            onSuccess?.();
+        },
+        onError: () => {
+            toast.error(`Failed to delete ${selection.length} ${entityName}`);
+        },
+    });
+
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={requiredPermissions}
+            onClick={() => mutate({ ids: selection.map(s => s.id) })}
+            label={<Trans>Delete</Trans>}
+            confirmationText={
+                <Trans>
+                    Are you sure you want to delete {selection.length} {entityName}?
+                </Trans>
+            }
+            icon={TrashIcon}
+            className="text-destructive"
+        />
+    );
+}

+ 1 - 1
packages/dashboard/src/app/common/duplicate-bulk-action.tsx

@@ -10,7 +10,7 @@ import { usePaginatedList } from '@/index.js';
 import { Trans, useLingui } from '@/lib/trans.js';
 
 interface DuplicateBulkActionProps {
-    entityType: 'Product' | 'Collection';
+    entityType: 'Product' | 'Collection' | 'Facet' | 'Promotion';
     duplicatorCode: string;
     duplicatorArguments?: Array<{ name: string; value: string }>;
     requiredPermissions: string[];

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_administrators/administrators.graphql.ts

@@ -77,3 +77,12 @@ export const deleteAdministratorDocument = graphql(`
         }
     }
 `);
+
+export const deleteAdministratorsDocument = graphql(`
+    mutation DeleteAdministrators($ids: [ID!]!) {
+        deleteAdministrators(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_administrators/administrators.tsx

@@ -9,6 +9,7 @@ import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
 import { administratorListDocument, deleteAdministratorDocument } from './administrators.graphql.js';
+import { DeleteAdministratorsBulkAction } from './components/administrator-bulk-actions.js';
 
 export const Route = createFileRoute('/_authenticated/_administrators/administrators')({
     component: AdministratorListPage,
@@ -70,6 +71,12 @@ function AdministratorListPage() {
                 emailAddress: true,
             }}
             defaultColumnOrder={['name', 'emailAddress', 'roles']}
+            bulkActions={[
+                {
+                    component: DeleteAdministratorsBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateAdministrator']}>

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_administrators/components/administrator-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteAdministratorsDocument } from '../administrators.graphql.js';
+
+export const DeleteAdministratorsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteAdministratorsDocument}
+            entityName="administrators"
+            requiredPermissions={['DeleteAdministrator']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 11 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/assets.graphql.ts

@@ -24,3 +24,14 @@ export const assetUpdateDocument = graphql(`
         }
     }
 `);
+
+export const deleteAssetsDocument = graphql(`
+    mutation DeleteAssets($input: DeleteAssetsInput!) {
+        deleteAssets(input: $input) {
+            ... on DeletionResponse {
+                result
+                message
+            }
+        }
+    }
+`);

+ 10 - 2
packages/dashboard/src/app/routes/_authenticated/_assets/assets.tsx

@@ -1,7 +1,8 @@
 import { AssetGallery } from '@/components/shared/asset/asset-gallery.js';
-import { Page, PageTitle, PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { Page, PageBlock, PageTitle } from '@/framework/layout-engine/page-layout.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute } from '@tanstack/react-router';
+import { DeleteAssetsBulkAction } from './components/asset-bulk-actions.js';
 
 export const Route = createFileRoute('/_authenticated/_assets/assets')({
     component: RouteComponent,
@@ -13,7 +14,14 @@ function RouteComponent() {
             <PageTitle>
                 <Trans>Assets</Trans>
             </PageTitle>
-            <AssetGallery selectable={true} multiSelect='manual' />
+            <PageBlock blockId="asset-gallery" column="main">
+                <AssetGallery selectable={true} multiSelect="manual" bulkActions={[{
+                        order: 10,
+                        component: DeleteAssetsBulkAction,
+                    },
+                ]}
+                />
+            </PageBlock>
         </Page>
     );
 }

+ 45 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/components/asset-bulk-actions.tsx

@@ -0,0 +1,45 @@
+import { useMutation } from '@tanstack/react-query';
+import { TrashIcon } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { api } from '@/graphql/api.js';
+import { AssetFragment } from '@/graphql/fragments.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { deleteAssetsDocument } from '../assets.graphql.js';
+
+export const DeleteAssetsBulkAction = ({
+    selection,
+    refetch,
+}: {
+    selection: AssetFragment[];
+    refetch: () => void;
+}) => {
+    const { i18n } = useLingui();
+    const { mutate } = useMutation({
+        mutationFn: api.mutate(deleteAssetsDocument),
+        onSuccess: (result: ResultOf<typeof deleteAssetsDocument>) => {
+            if (result.deleteAssets.result === 'DELETED') {
+                toast.success(i18n.t(`Deleted ${selection.length} assets`));
+            } else {
+                toast.error(i18n.t(`Failed to delete assets: ${result.deleteAssets.message}`));
+            }
+            refetch();
+        },
+        onError: () => {
+            toast.error(`Failed to delete ${selection.length} assets`);
+        },
+    });
+
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={['DeleteCatalog', 'DeleteAsset']}
+            onClick={() => mutate({ input: { assetIds: selection.map(s => s.id) } })}
+            label={<Trans>Delete</Trans>}
+            confirmationText={<Trans>Are you sure you want to delete {selection.length} assets?</Trans>}
+            icon={TrashIcon}
+            className="text-destructive"
+        />
+    );
+};

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_channels/channels.graphql.ts

@@ -91,3 +91,12 @@ export const deleteChannelDocument = graphql(`
         }
     }
 `);
+
+export const deleteChannelsDocument = graphql(`
+    mutation DeleteChannels($ids: [ID!]!) {
+        deleteChannels(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_channels/channels.tsx

@@ -9,6 +9,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
 import { channelListQuery, deleteChannelDocument } from './channels.graphql.js';
 import { useLocalFormat } from '@/hooks/use-local-format.js';
+import { DeleteChannelsBulkAction } from './components/channel-bulk-actions.js';
 
 export const Route = createFileRoute('/_authenticated/_channels/channels')({
     component: ChannelListPage,
@@ -62,6 +63,12 @@ function ChannelListPage() {
                     }
                 },
             }}
+            bulkActions={[
+                {
+                    component: DeleteChannelsBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateChannel']}>

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_channels/components/channel-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteChannelsDocument } from '../channels.graphql.js';
+
+export const DeleteChannelsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteChannelsDocument}
+            entityName="channels"
+            requiredPermissions={['DeleteChannel']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 0 - 110
packages/dashboard/src/app/routes/_authenticated/_collections/components/assign-collections-to-channel-dialog.tsx

@@ -1,110 +0,0 @@
-import { useMutation } from '@tanstack/react-query';
-import { useState } from 'react';
-import { toast } from 'sonner';
-
-import { ChannelCodeLabel } from '@/components/shared/channel-code-label.js';
-import { Button } from '@/components/ui/button.js';
-import {
-    Dialog,
-    DialogContent,
-    DialogDescription,
-    DialogFooter,
-    DialogHeader,
-    DialogTitle,
-} from '@/components/ui/dialog.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
-import { ResultOf } from '@/graphql/graphql.js';
-import { Trans, useLingui } from '@/lib/trans.js';
-
-import { useChannel } from '@/hooks/use-channel.js';
-
-interface AssignCollectionsToChannelDialogProps {
-    open: boolean;
-    onOpenChange: (open: boolean) => void;
-    entityIds: string[];
-    mutationFn: (variables: any) => Promise<ResultOf<any>>;
-    onSuccess?: () => void;
-}
-
-export function AssignCollectionsToChannelDialog({
-    open,
-    onOpenChange,
-    entityIds,
-    mutationFn,
-    onSuccess,
-}: AssignCollectionsToChannelDialogProps) {
-    const { i18n } = useLingui();
-    const [selectedChannelId, setSelectedChannelId] = useState<string>('');
-    const { channels, selectedChannel } = useChannel();
-
-    // Filter out the currently selected channel from available options
-    const availableChannels = channels.filter(channel => channel.id !== selectedChannel?.id);
-
-    const { mutate, isPending } = useMutation({
-        mutationFn,
-        onSuccess: () => {
-            toast.success(i18n.t(`Successfully assigned ${entityIds.length} collections to channel`));
-            onSuccess?.();
-            onOpenChange(false);
-        },
-        onError: () => {
-            toast.error(`Failed to assign ${entityIds.length} collections to channel`);
-        },
-    });
-
-    const handleAssign = () => {
-        if (!selectedChannelId) {
-            toast.error('Please select a channel');
-            return;
-        }
-
-        const input = {
-            collectionIds: entityIds,
-            channelId: selectedChannelId,
-        };
-
-        mutate({ input });
-    };
-
-    return (
-        <Dialog open={open} onOpenChange={onOpenChange}>
-            <DialogContent className="sm:max-w-[425px]">
-                <DialogHeader>
-                    <DialogTitle>
-                        <Trans>Assign collections to channel</Trans>
-                    </DialogTitle>
-                    <DialogDescription>
-                        <Trans>Select a channel to assign {entityIds.length} collections to</Trans>
-                    </DialogDescription>
-                </DialogHeader>
-                <div className="grid gap-4 py-4">
-                    <div className="grid gap-2">
-                        <label className="text-sm font-medium">
-                            <Trans>Channel</Trans>
-                        </label>
-                        <Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
-                            <SelectTrigger>
-                                <SelectValue placeholder={i18n.t('Select a channel')} />
-                            </SelectTrigger>
-                            <SelectContent>
-                                {availableChannels.map(channel => (
-                                    <SelectItem key={channel.id} value={channel.id}>
-                                        <ChannelCodeLabel code={channel.code} />
-                                    </SelectItem>
-                                ))}
-                            </SelectContent>
-                        </Select>
-                    </div>
-                </div>
-                <DialogFooter>
-                    <Button variant="outline" onClick={() => onOpenChange(false)}>
-                        <Trans>Cancel</Trans>
-                    </Button>
-                    <Button onClick={handleAssign} disabled={!selectedChannelId || isPending}>
-                        <Trans>Assign</Trans>
-                    </Button>
-                </DialogFooter>
-            </DialogContent>
-        </Dialog>
-    );
-}

+ 39 - 110
packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx

@@ -1,99 +1,57 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import { LayersIcon, TrashIcon } from 'lucide-react';
-import { useState } from 'react';
-import { toast } from 'sonner';
+import { useQueryClient } from '@tanstack/react-query';
 
-import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { AssignToChannelBulkAction } from '@/components/shared/assign-to-channel-bulk-action.js';
+import { RemoveFromChannelBulkAction } from '@/components/shared/remove-from-channel-bulk-action.js';
 import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
 import { api } from '@/graphql/api.js';
-import { ResultOf, useChannel, usePaginatedList } from '@/index.js';
-import { Trans, useLingui } from '@/lib/trans.js';
+import { useChannel } from '@/index.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
 import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
 import {
     assignCollectionToChannelDocument,
     deleteCollectionsDocument,
     removeCollectionFromChannelDocument,
 } from '../collections.graphql.js';
-import { AssignCollectionsToChannelDialog } from './assign-collections-to-channel-dialog.js';
 
 export const AssignCollectionsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
-    const { refetchPaginatedList } = usePaginatedList();
-    const { channels } = useChannel();
-    const [dialogOpen, setDialogOpen] = useState(false);
     const queryClient = useQueryClient();
 
-    if (channels.length < 2) {
-        return null;
-    }
-
-    const handleSuccess = () => {
-        refetchPaginatedList();
-        table.resetRowSelection();
-        queryClient.invalidateQueries({ queryKey: ['childCollections'] });
-    };
-
     return (
-        <>
-            <DataTableBulkActionItem
-                requiresPermission={['UpdateCatalog', 'UpdateCollection']}
-                onClick={() => setDialogOpen(true)}
-                label={<Trans>Assign to channel</Trans>}
-                icon={LayersIcon}
-            />
-            <AssignCollectionsToChannelDialog
-                open={dialogOpen}
-                onOpenChange={setDialogOpen}
-                entityIds={selection.map(s => s.id)}
-                mutationFn={api.mutate(assignCollectionToChannelDocument)}
-                onSuccess={handleSuccess}
-            />
-        </>
+        <AssignToChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="collections"
+            mutationFn={api.mutate(assignCollectionToChannelDocument)}
+            requiredPermissions={['UpdateCatalog', 'UpdateCollection']}
+            buildInput={(channelId: string) => ({
+                collectionIds: selection.map(s => s.id),
+                channelId,
+            })}
+            onSuccess={() => {
+                queryClient.invalidateQueries({ queryKey: ['childCollections'] });
+            }}
+        />
     );
 };
 
 export const RemoveCollectionsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
-    const { refetchPaginatedList } = usePaginatedList();
     const { selectedChannel } = useChannel();
-    const { i18n } = useLingui();
     const queryClient = useQueryClient();
-    const { mutate } = useMutation({
-        mutationFn: api.mutate(removeCollectionFromChannelDocument),
-        onSuccess: () => {
-            toast.success(i18n.t(`Successfully removed ${selection.length} collections from channel`));
-            refetchPaginatedList();
-            table.resetRowSelection();
-            queryClient.invalidateQueries({ queryKey: ['childCollections'] });
-        },
-        onError: error => {
-            toast.error(`Failed to remove ${selection.length} collections from channel: ${error.message}`);
-        },
-    });
-
-    if (!selectedChannel) {
-        return null;
-    }
-
-    const handleRemove = () => {
-        mutate({
-            input: {
-                collectionIds: selection.map(s => s.id),
-                channelId: selectedChannel.id,
-            },
-        });
-    };
 
     return (
-        <DataTableBulkActionItem
-            requiresPermission={['UpdateCatalog', 'UpdateCollection']}
-            onClick={handleRemove}
-            label={<Trans>Remove from current channel</Trans>}
-            confirmationText={
-                <Trans>
-                    Are you sure you want to remove {selection.length} collections from the current channel?
-                </Trans>
-            }
-            icon={LayersIcon}
-            className="text-warning"
+        <RemoveFromChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="collections"
+            mutationFn={api.mutate(removeCollectionFromChannelDocument)}
+            requiredPermissions={['UpdateCatalog', 'UpdateCollection']}
+            buildInput={() => ({
+                collectionIds: selection.map(s => s.id),
+                channelId: selectedChannel?.id,
+            })}
+            onSuccess={() => {
+                queryClient.invalidateQueries({ queryKey: ['childCollections'] });
+            }}
         />
     );
 };
@@ -117,43 +75,14 @@ export const DuplicateCollectionsBulkAction: BulkActionComponent<any> = ({ selec
 };
 
 export const DeleteCollectionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
-    const { refetchPaginatedList } = usePaginatedList();
-    const { i18n } = useLingui();
-    const queryClient = useQueryClient();
-    const { mutate } = useMutation({
-        mutationFn: api.mutate(deleteCollectionsDocument),
-        onSuccess: (result: ResultOf<typeof deleteCollectionsDocument>) => {
-            let deleted = 0;
-            const errors: string[] = [];
-            for (const item of result.deleteCollections) {
-                if (item.result === 'DELETED') {
-                    deleted++;
-                } else if (item.message) {
-                    errors.push(item.message);
-                }
-            }
-            if (0 < deleted) {
-                toast.success(i18n.t(`Deleted ${deleted} collections`));
-            }
-            if (0 < errors.length) {
-                toast.error(i18n.t(`Failed to delete ${errors.length} collections`));
-            }
-            refetchPaginatedList();
-            table.resetRowSelection();
-            queryClient.invalidateQueries({ queryKey: ['childCollections'] });
-        },
-        onError: () => {
-            toast.error(`Failed to delete ${selection.length} collections`);
-        },
-    });
     return (
-        <DataTableBulkActionItem
-            requiresPermission={['DeleteCatalog', 'DeleteCollection']}
-            onClick={() => mutate({ ids: selection.map(s => s.id) })}
-            label={<Trans>Delete</Trans>}
-            confirmationText={<Trans>Are you sure you want to delete {selection.length} collections?</Trans>}
-            icon={TrashIcon}
-            className="text-destructive"
+        <DeleteBulkAction
+            mutationDocument={deleteCollectionsDocument}
+            entityName="collections"
+            requiredPermissions={['DeleteCatalog', 'DeleteCollection']}
+            invalidateQueries={['childCollections']}
+            selection={selection}
+            table={table}
         />
     );
 };

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_countries/components/country-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteCountriesDocument } from '../countries.graphql.js';
+
+export const DeleteCountriesBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteCountriesDocument}
+            entityName="countries"
+            requiredPermissions={['DeleteCountry']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_countries/countries.graphql.ts

@@ -67,3 +67,12 @@ export const deleteCountryDocument = graphql(`
         }
     }
 `);
+
+export const deleteCountriesDocument = graphql(`
+    mutation DeleteCountries($ids: [ID!]!) {
+        deleteCountries(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_countries/countries.tsx

@@ -6,6 +6,7 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import { DeleteCountriesBulkAction } from './components/country-bulk-actions.js';
 import { countriesListQuery, deleteCountryDocument } from './countries.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_countries/countries')({
@@ -51,6 +52,12 @@ function CountryListPage() {
                     cell: ({ row }) => <DetailPageButton id={row.original.id} label={row.original.name} />,
                 },
             }}
+            bulkActions={[
+                {
+                    component: DeleteCountriesBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateCountry']}>

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_customer-groups/components/customer-group-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteCustomerGroupsDocument } from '../customer-groups.graphql.js';
+
+export const DeleteCustomerGroupsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteCustomerGroupsDocument}
+            entityName="customer groups"
+            requiredPermissions={['DeleteCustomerGroup']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_customer-groups/customer-groups.graphql.ts

@@ -69,3 +69,12 @@ export const deleteCustomerGroupDocument = graphql(`
         }
     }
 `);
+
+export const deleteCustomerGroupsDocument = graphql(`
+    mutation DeleteCustomerGroups($ids: [ID!]!) {
+        deleteCustomerGroups(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_customer-groups/customer-groups.tsx

@@ -6,6 +6,7 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import { DeleteCustomerGroupsBulkAction } from './components/customer-group-bulk-actions.js';
 import { CustomerGroupMembersSheet } from './components/customer-group-members-sheet.js';
 import { customerGroupListDocument, deleteCustomerGroupDocument } from './customer-groups.graphql.js';
 
@@ -52,6 +53,12 @@ function CustomerGroupListPage() {
                     name: { contains: searchTerm },
                 };
             }}
+            bulkActions={[
+                {
+                    component: DeleteCustomerGroupsBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateCustomerGroup']}>

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteCustomersDocument } from '../customers.graphql.js';
+
+export const DeleteCustomersBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteCustomersDocument}
+            entityName="customers"
+            requiredPermissions={['DeleteCustomer']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 9 - 1
packages/dashboard/src/app/routes/_authenticated/_customers/customers.graphql.ts

@@ -1,5 +1,4 @@
 import { graphql } from '@/graphql/graphql.js';
-import { gql } from 'awesome-graphql-client';
 
 export const customerListDocument = graphql(`
     query GetCustomerList($options: CustomerListOptions) {
@@ -202,3 +201,12 @@ export const removeCustomerFromGroupDocument = graphql(`
         }
     }
 `);
+
+export const deleteCustomersDocument = graphql(`
+    mutation DeleteCustomers($ids: [ID!]!) {
+        deleteCustomers(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/customers.tsx

@@ -6,6 +6,7 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import { DeleteCustomersBulkAction } from './components/customer-bulk-actions.js';
 import { CustomerStatusBadge } from './components/customer-status-badge.js';
 import { customerListDocument, deleteCustomerDocument } from './customers.graphql.js';
 
@@ -66,6 +67,12 @@ function CustomerListPage() {
                 firstName: false,
                 lastName: false,
             }}
+            bulkActions={[
+                {
+                    component: DeleteCustomersBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateCustomer']}>

+ 104 - 0
packages/dashboard/src/app/routes/_authenticated/_facets/components/facet-bulk-actions.tsx

@@ -0,0 +1,104 @@
+import { toast } from 'sonner';
+
+import { AssignToChannelBulkAction } from '@/components/shared/assign-to-channel-bulk-action.js';
+import { RemoveFromChannelBulkAction } from '@/components/shared/remove-from-channel-bulk-action.js';
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { api } from '@/graphql/api.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { useChannel } from '@/index.js';
+import { useLingui } from '@/lib/trans.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
+
+import {
+    assignFacetsToChannelDocument,
+    deleteFacetsDocument,
+    removeFacetsFromChannelDocument,
+} from '../facets.graphql.js';
+
+export const DeleteFacetsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteFacetsDocument}
+            entityName="facets"
+            requiredPermissions={['DeleteCatalog', 'DeleteFacet']}
+            selection={selection}
+            table={table}
+        />
+    );
+};
+
+export const AssignFacetsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <AssignToChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="facets"
+            mutationFn={api.mutate(assignFacetsToChannelDocument)}
+            requiredPermissions={['UpdateCatalog', 'UpdateFacet']}
+            buildInput={(channelId: string) => ({
+                facetIds: selection.map(s => s.id),
+                channelId,
+            })}
+        />
+    );
+};
+
+export const RemoveFacetsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { selectedChannel } = useChannel();
+    const { i18n } = useLingui();
+
+    return (
+        <RemoveFromChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="facets"
+            mutationFn={api.mutate(removeFacetsFromChannelDocument)}
+            requiredPermissions={['UpdateCatalog', 'UpdateFacet']}
+            buildInput={() => ({
+                facetIds: selection.map(s => s.id),
+                channelId: selectedChannel?.id,
+            })}
+            onSuccess={result => {
+                const typedResult = result as ResultOf<typeof removeFacetsFromChannelDocument>;
+                if (typedResult?.removeFacetsFromChannel) {
+                    const errors: string[] = [];
+
+                    for (const item of typedResult.removeFacetsFromChannel) {
+                        if ('id' in item) {
+                            // Do nothing
+                        } else if ('message' in item) {
+                            errors.push(item.message);
+                            toast.error(i18n.t(`Failed to remove facet from channel: ${item.message}`));
+                        }
+                    }
+
+                    const successCount = selection.length - errors.length;
+
+                    if (successCount > 0) {
+                        toast.success(i18n.t(`Successfully removed ${successCount} facets from channel`));
+                    }
+                }
+            }}
+        />
+    );
+};
+
+export const DuplicateFacetsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DuplicateBulkAction
+            entityType="Facet"
+            duplicatorCode="facet-duplicator"
+            duplicatorArguments={[
+                {
+                    name: 'includeValues',
+                    value: 'true',
+                },
+            ]}
+            requiredPermissions={['UpdateCatalog', 'UpdateFacet']}
+            entityName="Facet"
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 30 - 0
packages/dashboard/src/app/routes/_authenticated/_facets/facets.graphql.ts

@@ -102,3 +102,33 @@ export const deleteFacetDocument = graphql(`
         }
     }
 `);
+
+export const assignFacetsToChannelDocument = graphql(`
+    mutation AssignFacetsToChannel($input: AssignFacetsToChannelInput!) {
+        assignFacetsToChannel(input: $input) {
+            id
+        }
+    }
+`);
+
+export const removeFacetsFromChannelDocument = graphql(`
+    mutation RemoveFacetsFromChannel($input: RemoveFacetsFromChannelInput!) {
+        removeFacetsFromChannel(input: $input) {
+            ... on Facet {
+                id
+            }
+            ... on ErrorResult {
+                message
+            }
+        }
+    }
+`);
+
+export const deleteFacetsDocument = graphql(`
+    mutation DeleteFacets($ids: [ID!]!) {
+        deleteFacets(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 24 - 0
packages/dashboard/src/app/routes/_authenticated/_facets/facets.tsx

@@ -8,6 +8,12 @@ import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { ResultOf } from 'gql.tada';
 import { PlusIcon } from 'lucide-react';
+import {
+    AssignFacetsToChannelBulkAction,
+    DeleteFacetsBulkAction,
+    DuplicateFacetsBulkAction,
+    RemoveFacetsFromChannelBulkAction,
+} from './components/facet-bulk-actions.js';
 import { FacetValuesSheet } from './components/facet-values-sheet.js';
 import { deleteFacetDocument, facetListDocument } from './facets.graphql.js';
 
@@ -80,6 +86,24 @@ function FacetListPage() {
                     },
                 };
             }}
+            bulkActions={[
+                {
+                    order: 100,
+                    component: AssignFacetsToChannelBulkAction,
+                },
+                {
+                    order: 200,
+                    component: RemoveFacetsFromChannelBulkAction,
+                },
+                {
+                    order: 300,
+                    component: DuplicateFacetsBulkAction,
+                },
+                {
+                    order: 400,
+                    component: DeleteFacetsBulkAction,
+                },
+            ]}
             route={Route}
         >
             <PageActionBarRight>

+ 58 - 0
packages/dashboard/src/app/routes/_authenticated/_payment-methods/components/payment-method-bulk-actions.tsx

@@ -0,0 +1,58 @@
+import { AssignToChannelBulkAction } from '@/components/shared/assign-to-channel-bulk-action.js';
+import { RemoveFromChannelBulkAction } from '@/components/shared/remove-from-channel-bulk-action.js';
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { api } from '@/graphql/api.js';
+import { useChannel } from '@/index.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+
+import {
+    assignPaymentMethodsToChannelDocument,
+    deletePaymentMethodsDocument,
+    removePaymentMethodsFromChannelDocument,
+} from '../payment-methods.graphql.js';
+
+export const DeletePaymentMethodsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deletePaymentMethodsDocument}
+            entityName="payment methods"
+            requiredPermissions={['DeletePaymentMethod']}
+            selection={selection}
+            table={table}
+        />
+    );
+};
+
+export const AssignPaymentMethodsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <AssignToChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="payment methods"
+            mutationFn={api.mutate(assignPaymentMethodsToChannelDocument)}
+            requiredPermissions={['UpdatePaymentMethod']}
+            buildInput={(channelId: string) => ({
+                paymentMethodIds: selection.map(s => s.id),
+                channelId,
+            })}
+        />
+    );
+};
+
+export const RemovePaymentMethodsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { selectedChannel } = useChannel();
+
+    return (
+        <RemoveFromChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="payment methods"
+            mutationFn={api.mutate(removePaymentMethodsFromChannelDocument)}
+            requiredPermissions={['UpdatePaymentMethod']}
+            buildInput={() => ({
+                paymentMethodIds: selection.map(s => s.id),
+                channelId: selectedChannel?.id,
+            })}
+        />
+    );
+};

+ 27 - 0
packages/dashboard/src/app/routes/_authenticated/_payment-methods/payment-methods.graphql.ts

@@ -81,3 +81,30 @@ export const deletePaymentMethodDocument = graphql(`
         }
     }
 `);
+
+export const deletePaymentMethodsDocument = graphql(`
+    mutation DeletePaymentMethods($ids: [ID!]!) {
+        deletePaymentMethods(ids: $ids) {
+            result
+            message
+        }
+    }
+`);
+
+export const assignPaymentMethodsToChannelDocument = graphql(`
+    mutation AssignPaymentMethodsToChannel($input: AssignPaymentMethodsToChannelInput!) {
+        assignPaymentMethodsToChannel(input: $input) {
+            id
+            name
+        }
+    }
+`);
+
+export const removePaymentMethodsFromChannelDocument = graphql(`
+    mutation RemovePaymentMethodsFromChannel($input: RemovePaymentMethodsFromChannelInput!) {
+        removePaymentMethodsFromChannel(input: $input) {
+            id
+            name
+        }
+    }
+`);

+ 30 - 8
packages/dashboard/src/app/routes/_authenticated/_payment-methods/payment-methods.tsx

@@ -2,10 +2,16 @@ import { BooleanDisplayBadge } from '@/components/data-display/boolean.js';
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
+import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import {
+    AssignPaymentMethodsToChannelBulkAction,
+    DeletePaymentMethodsBulkAction,
+    RemovePaymentMethodsFromChannelBulkAction,
+} from './components/payment-method-bulk-actions.js';
 import { deletePaymentMethodDocument, paymentMethodListQuery } from './payment-methods.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_payment-methods/payment-methods')({
@@ -50,15 +56,31 @@ function PaymentMethodListPage() {
                     cell: ({ row }) => <BooleanDisplayBadge value={row.original.enabled} />,
                 },
             }}
+            bulkActions={[
+                {
+                    component: AssignPaymentMethodsToChannelBulkAction,
+                    order: 100,
+                },
+                {
+                    component: RemovePaymentMethodsFromChannelBulkAction,
+                    order: 200,
+                },
+                {
+                    component: DeletePaymentMethodsBulkAction,
+                    order: 500,
+                },
+            ]}
         >
-            <PermissionGuard requires={['CreatePaymentMethod']}>
-                <Button asChild>
-                    <Link to="./new">
-                        <PlusIcon className="mr-2 h-4 w-4" />
-                        New Payment Method
-                    </Link>
-                </Button>
-            </PermissionGuard>
+            <PageActionBarRight>
+                <PermissionGuard requires={['CreatePaymentMethod']}>
+                    <Button asChild>
+                        <Link to="./new">
+                            <PlusIcon className="mr-2 h-4 w-4" />
+                            New Payment Method
+                        </Link>
+                    </Button>
+                </PermissionGuard>
+            </PageActionBarRight>
         </ListPage>
     );
 }

+ 4 - 1
packages/dashboard/src/app/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx

@@ -38,7 +38,10 @@ export const Route = createFileRoute('/_authenticated/_payment-methods/payment-m
         pageId,
         queryDocument: paymentMethodDetailDocument,
         breadcrumb(_isNew, entity) {
-            return [{ path: '/payment-methods', label: 'Payment methods' }, entity?.name];
+            return [
+                { path: '/payment-methods', label: 'Payment methods' },
+                _isNew ? <Trans>New payment method</Trans> : entity?.name,
+            ];
         },
     }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,

+ 36 - 110
packages/dashboard/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx

@@ -1,17 +1,17 @@
-import { useMutation } from '@tanstack/react-query';
-import { LayersIcon, TagIcon, TrashIcon } from 'lucide-react';
+import { TagIcon } from 'lucide-react';
 import { useState } from 'react';
-import { toast } from 'sonner';
 
 import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { AssignToChannelBulkAction } from '@/components/shared/assign-to-channel-bulk-action.js';
+import { usePriceFactor } from '@/components/shared/assign-to-channel-dialog.js';
+import { RemoveFromChannelBulkAction } from '@/components/shared/remove-from-channel-bulk-action.js';
 import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
 import { api } from '@/graphql/api.js';
-import { ResultOf } from '@/graphql/graphql.js';
 import { useChannel, usePaginatedList } from '@/index.js';
-import { Trans, useLingui } from '@/lib/trans.js';
+import { Trans } from '@/lib/trans.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
 
 import { AssignFacetValuesDialog } from '../../_products/components/assign-facet-values-dialog.js';
-import { AssignToChannelDialog } from '../../_products/components/assign-to-channel-dialog.js';
 import {
     assignProductVariantsToChannelDocument,
     deleteProductVariantsDocument,
@@ -22,78 +22,34 @@ import {
 } from '../product-variants.graphql.js';
 
 export const DeleteProductVariantsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
-    const { refetchPaginatedList } = usePaginatedList();
-    const { i18n } = useLingui();
-    const { mutate } = useMutation({
-        mutationFn: api.mutate(deleteProductVariantsDocument),
-        onSuccess: (result: ResultOf<typeof deleteProductVariantsDocument>) => {
-            let deleted = 0;
-            const errors: string[] = [];
-            for (const item of result.deleteProductVariants) {
-                if (item.result === 'DELETED') {
-                    deleted++;
-                } else if (item.message) {
-                    errors.push(item.message);
-                }
-            }
-            if (0 < deleted) {
-                toast.success(i18n.t(`Deleted ${deleted} product variants`));
-            }
-            if (0 < errors.length) {
-                toast.error(i18n.t(`Failed to delete ${errors.length} product variants`));
-            }
-            refetchPaginatedList();
-            table.resetRowSelection();
-        },
-        onError: () => {
-            toast.error(`Failed to delete ${selection.length} product variants`);
-        },
-    });
     return (
-        <DataTableBulkActionItem
-            requiresPermission={['DeleteCatalog', 'DeleteProduct']}
-            onClick={() => mutate({ ids: selection.map(s => s.id) })}
-            label={<Trans>Delete</Trans>}
-            confirmationText={
-                <Trans>Are you sure you want to delete {selection.length} product variants?</Trans>
-            }
-            icon={TrashIcon}
-            className="text-destructive"
+        <DeleteBulkAction
+            mutationDocument={deleteProductVariantsDocument}
+            entityName="product variants"
+            requiredPermissions={['DeleteCatalog', 'DeleteProduct']}
+            selection={selection}
+            table={table}
         />
     );
 };
 
 export const AssignProductVariantsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
-    const { refetchPaginatedList } = usePaginatedList();
-    const { channels } = useChannel();
-    const [dialogOpen, setDialogOpen] = useState(false);
-
-    if (channels.length < 2) {
-        return null;
-    }
-
-    const handleSuccess = () => {
-        refetchPaginatedList();
-        table.resetRowSelection();
-    };
+    const { priceFactor, priceFactorField } = usePriceFactor();
 
     return (
-        <>
-            <DataTableBulkActionItem
-                requiresPermission={['UpdateCatalog', 'UpdateProduct']}
-                onClick={() => setDialogOpen(true)}
-                label={<Trans>Assign to channel</Trans>}
-                icon={LayersIcon}
-            />
-            <AssignToChannelDialog
-                open={dialogOpen}
-                onOpenChange={setDialogOpen}
-                entityIds={selection.map(s => s.id)}
-                entityType="variants"
-                mutationFn={api.mutate(assignProductVariantsToChannelDocument)}
-                onSuccess={handleSuccess}
-            />
-        </>
+        <AssignToChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="variants"
+            mutationFn={api.mutate(assignProductVariantsToChannelDocument)}
+            requiredPermissions={['UpdateCatalog', 'UpdateProduct']}
+            buildInput={(channelId: string) => ({
+                productVariantIds: selection.map(s => s.id),
+                channelId,
+                priceFactor,
+            })}
+            additionalFields={priceFactorField}
+        />
     );
 };
 
@@ -101,49 +57,19 @@ export const RemoveProductVariantsFromChannelBulkAction: BulkActionComponent<any
     selection,
     table,
 }) => {
-    const { refetchPaginatedList } = usePaginatedList();
     const { selectedChannel } = useChannel();
-    const { i18n } = useLingui();
-    const { mutate } = useMutation({
-        mutationFn: api.mutate(removeProductVariantsFromChannelDocument),
-        onSuccess: () => {
-            toast.success(i18n.t(`Successfully removed ${selection.length} product variants from channel`));
-            refetchPaginatedList();
-            table.resetRowSelection();
-        },
-        onError: error => {
-            toast.error(
-                `Failed to remove ${selection.length} product variants from channel: ${error.message}`,
-            );
-        },
-    });
-
-    if (!selectedChannel) {
-        return null;
-    }
-
-    const handleRemove = () => {
-        mutate({
-            input: {
-                productVariantIds: selection.map(s => s.id),
-                channelId: selectedChannel.id,
-            },
-        });
-    };
 
     return (
-        <DataTableBulkActionItem
-            requiresPermission={['UpdateCatalog', 'UpdateProduct']}
-            onClick={handleRemove}
-            label={<Trans>Remove from current channel</Trans>}
-            confirmationText={
-                <Trans>
-                    Are you sure you want to remove {selection.length} product variants from the current
-                    channel?
-                </Trans>
-            }
-            icon={LayersIcon}
-            className="text-warning"
+        <RemoveFromChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="product variants"
+            mutationFn={api.mutate(removeProductVariantsFromChannelDocument)}
+            requiredPermissions={['UpdateCatalog', 'UpdateProduct']}
+            buildInput={() => ({
+                productVariantIds: selection.map(s => s.id),
+                channelId: selectedChannel?.id,
+            })}
         />
     );
 };

+ 36 - 105
packages/dashboard/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx

@@ -1,14 +1,15 @@
-import { useMutation } from '@tanstack/react-query';
-import { LayersIcon, TagIcon, TrashIcon } from 'lucide-react';
+import { TagIcon } from 'lucide-react';
 import { useState } from 'react';
-import { toast } from 'sonner';
 
 import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { AssignToChannelBulkAction } from '@/components/shared/assign-to-channel-bulk-action.js';
+import { usePriceFactor } from '@/components/shared/assign-to-channel-dialog.js';
+import { RemoveFromChannelBulkAction } from '@/components/shared/remove-from-channel-bulk-action.js';
 import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
 import { api } from '@/graphql/api.js';
-import { ResultOf } from '@/graphql/graphql.js';
 import { useChannel, usePaginatedList } from '@/index.js';
-import { Trans, useLingui } from '@/lib/trans.js';
+import { Trans } from '@/lib/trans.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
 import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
 import {
     assignProductsToChannelDocument,
@@ -19,123 +20,53 @@ import {
     updateProductsDocument,
 } from '../products.graphql.js';
 import { AssignFacetValuesDialog } from './assign-facet-values-dialog.js';
-import { AssignToChannelDialog } from './assign-to-channel-dialog.js';
 
 export const DeleteProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
-    const { refetchPaginatedList } = usePaginatedList();
-    const { i18n } = useLingui();
-    const { mutate } = useMutation({
-        mutationFn: api.mutate(deleteProductsDocument),
-        onSuccess: (result: ResultOf<typeof deleteProductsDocument>) => {
-            let deleted = 0;
-            const errors: string[] = [];
-            for (const item of result.deleteProducts) {
-                if (item.result === 'DELETED') {
-                    deleted++;
-                } else if (item.message) {
-                    errors.push(item.message);
-                }
-            }
-            if (0 < deleted) {
-                toast.success(i18n.t(`Deleted ${deleted} products`));
-            }
-            if (0 < errors.length) {
-                toast.error(i18n.t(`Failed to delete ${errors.length} products`));
-            }
-            refetchPaginatedList();
-            table.resetRowSelection();
-        },
-        onError: () => {
-            toast.error(`Failed to delete ${selection.length} products`);
-        },
-    });
     return (
-        <DataTableBulkActionItem
-            requiresPermission={['DeleteCatalog', 'DeleteProduct']}
-            onClick={() => mutate({ ids: selection.map(s => s.id) })}
-            label={<Trans>Delete</Trans>}
-            confirmationText={<Trans>Are you sure you want to delete {selection.length} products?</Trans>}
-            icon={TrashIcon}
-            className="text-destructive"
+        <DeleteBulkAction
+            mutationDocument={deleteProductsDocument}
+            entityName="products"
+            requiredPermissions={['DeleteCatalog', 'DeleteProduct']}
+            selection={selection}
+            table={table}
         />
     );
 };
 
 export const AssignProductsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
-    const { refetchPaginatedList } = usePaginatedList();
-    const { channels } = useChannel();
-    const [dialogOpen, setDialogOpen] = useState(false);
-
-    if (channels.length < 2) {
-        return null;
-    }
-
-    const handleSuccess = () => {
-        refetchPaginatedList();
-        table.resetRowSelection();
-    };
+    const { priceFactor, priceFactorField } = usePriceFactor();
 
     return (
-        <>
-            <DataTableBulkActionItem
-                requiresPermission={['UpdateCatalog', 'UpdateProduct']}
-                onClick={() => setDialogOpen(true)}
-                label={<Trans>Assign to channel</Trans>}
-                icon={LayersIcon}
-            />
-            <AssignToChannelDialog
-                open={dialogOpen}
-                onOpenChange={setDialogOpen}
-                entityIds={selection.map(s => s.id)}
-                entityType="products"
-                mutationFn={api.mutate(assignProductsToChannelDocument)}
-                onSuccess={handleSuccess}
-            />
-        </>
+        <AssignToChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="products"
+            mutationFn={api.mutate(assignProductsToChannelDocument)}
+            requiredPermissions={['UpdateCatalog', 'UpdateProduct']}
+            buildInput={(channelId: string) => ({
+                productIds: selection.map(s => s.id),
+                channelId,
+                priceFactor,
+            })}
+            additionalFields={priceFactorField}
+        />
     );
 };
 
 export const RemoveProductsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
-    const { refetchPaginatedList } = usePaginatedList();
     const { selectedChannel } = useChannel();
-    const { i18n } = useLingui();
-    const { mutate } = useMutation({
-        mutationFn: api.mutate(removeProductsFromChannelDocument),
-        onSuccess: () => {
-            toast.success(i18n.t(`Successfully removed ${selection.length} products from channel`));
-            refetchPaginatedList();
-            table.resetRowSelection();
-        },
-        onError: error => {
-            toast.error(`Failed to remove ${selection.length} products from channel: ${error.message}`);
-        },
-    });
-
-    if (!selectedChannel) {
-        return null;
-    }
-
-    const handleRemove = () => {
-        mutate({
-            input: {
-                productIds: selection.map(s => s.id),
-                channelId: selectedChannel.id,
-            },
-        });
-    };
 
     return (
-        <DataTableBulkActionItem
-            requiresPermission={['UpdateCatalog', 'UpdateProduct']}
-            onClick={handleRemove}
-            label={<Trans>Remove from current channel</Trans>}
-            confirmationText={
-                <Trans>
-                    Are you sure you want to remove {selection.length} products from the current channel?
-                </Trans>
-            }
-            icon={LayersIcon}
-            className="text-warning"
+        <RemoveFromChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="products"
+            mutationFn={api.mutate(removeProductsFromChannelDocument)}
+            requiredPermissions={['UpdateCatalog', 'UpdateProduct']}
+            buildInput={() => ({
+                productIds: selection.map(s => s.id),
+                channelId: selectedChannel?.id,
+            })}
         />
     );
 };

+ 82 - 0
packages/dashboard/src/app/routes/_authenticated/_promotions/components/promotion-bulk-actions.tsx

@@ -0,0 +1,82 @@
+import { AssignToChannelBulkAction } from '@/components/shared/assign-to-channel-bulk-action.js';
+import { RemoveFromChannelBulkAction } from '@/components/shared/remove-from-channel-bulk-action.js';
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { api } from '@/graphql/api.js';
+import { useChannel } from '@/index.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
+
+import {
+    assignPromotionsToChannelDocument,
+    deletePromotionsDocument,
+    removePromotionsFromChannelDocument,
+} from '../promotions.graphql.js';
+
+export const DeletePromotionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deletePromotionsDocument}
+            entityName="promotions"
+            requiredPermissions={['DeletePromotion']}
+            selection={selection}
+            table={table}
+        />
+    );
+};
+
+export const AssignPromotionsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <AssignToChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="promotions"
+            mutationFn={api.mutate(assignPromotionsToChannelDocument)}
+            requiredPermissions={['UpdatePromotion']}
+            buildInput={(channelId: string) => ({
+                promotionIds: selection.map(s => s.id),
+                channelId,
+            })}
+        />
+    );
+};
+
+export const RemovePromotionsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { selectedChannel } = useChannel();
+
+    return (
+        <RemoveFromChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="promotions"
+            mutationFn={api.mutate(removePromotionsFromChannelDocument)}
+            requiredPermissions={['UpdatePromotion']}
+            buildInput={() => ({
+                promotionIds: selection.map(s => s.id),
+                channelId: selectedChannel?.id,
+            })}
+        />
+    );
+};
+
+export const DuplicatePromotionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DuplicateBulkAction
+            entityType="Promotion"
+            duplicatorCode="promotion-duplicator"
+            duplicatorArguments={[
+                {
+                    name: 'includeConditions',
+                    value: 'true',
+                },
+                {
+                    name: 'includeActions',
+                    value: 'true',
+                },
+            ]}
+            requiredPermissions={['CreatePromotion']}
+            entityName="Promotion"
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 25 - 0
packages/dashboard/src/app/routes/_authenticated/_promotions/promotions.graphql.ts

@@ -94,3 +94,28 @@ export const deletePromotionDocument = graphql(`
         }
     }
 `);
+
+export const assignPromotionsToChannelDocument = graphql(`
+    mutation AssignPromotionsToChannel($input: AssignPromotionsToChannelInput!) {
+        assignPromotionsToChannel(input: $input) {
+            id
+        }
+    }
+`);
+
+export const removePromotionsFromChannelDocument = graphql(`
+    mutation RemovePromotionsFromChannel($input: RemovePromotionsFromChannelInput!) {
+        removePromotionsFromChannel(input: $input) {
+            id
+        }
+    }
+`);
+
+export const deletePromotionsDocument = graphql(`
+    mutation DeletePromotions($ids: [ID!]!) {
+        deletePromotions(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 24 - 0
packages/dashboard/src/app/routes/_authenticated/_promotions/promotions.tsx

@@ -7,6 +7,12 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import {
+    AssignPromotionsToChannelBulkAction,
+    DeletePromotionsBulkAction,
+    DuplicatePromotionsBulkAction,
+    RemovePromotionsFromChannelBulkAction,
+} from './components/promotion-bulk-actions.js';
 import { deletePromotionDocument, promotionListDocument } from './promotions.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_promotions/promotions')({
@@ -45,6 +51,24 @@ function PromotionListPage() {
                     cell: ({ row }) => <BooleanDisplayBadge value={row.original.enabled} />,
                 },
             }}
+            bulkActions={[
+                {
+                    order: 100,
+                    component: AssignPromotionsToChannelBulkAction,
+                },
+                {
+                    order: 200,
+                    component: RemovePromotionsFromChannelBulkAction,
+                },
+                {
+                    order: 300,
+                    component: DuplicatePromotionsBulkAction,
+                },
+                {
+                    order: 400,
+                    component: DeletePromotionsBulkAction,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreatePromotion']}>

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx

@@ -102,7 +102,7 @@ function PromotionDetailPage() {
                 toast.success(i18n.t('Successfully updated promotion'));
                 resetForm();
                 if (creatingNewEntity) {
-                    await navigate({ to: `../${data.id}`, from: Route.id });
+                    await navigate({ to: `../$id`, params: { id: data.id } });
                 }
             } else {
                 toast.error(i18n.t('Failed to update promotion'), {

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_roles/components/role-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteRolesDocument } from '../roles.graphql.js';
+
+export const DeleteRolesBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteRolesDocument}
+            entityName="roles"
+            requiredPermissions={['DeleteAdministrator']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_roles/roles.graphql.ts

@@ -65,3 +65,12 @@ export const deleteRoleDocument = graphql(`
         }
     }
 `);
+
+export const deleteRolesDocument = graphql(`
+    mutation DeleteRoles($ids: [ID!]!) {
+        deleteRoles(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_roles/roles.tsx

@@ -11,6 +11,7 @@ import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { LayersIcon, PlusIcon } from 'lucide-react';
 import { ExpandablePermissions } from './components/expandable-permissions.js';
+import { DeleteRolesBulkAction } from './components/role-bulk-actions.js';
 import { deleteRoleDocument, roleListQuery } from './roles.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_roles/roles')({
@@ -80,6 +81,12 @@ function RoleListPage() {
                     },
                 },
             }}
+            bulkActions={[
+                {
+                    component: DeleteRolesBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateAdministrator']}>

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_sellers/components/seller-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteSellersDocument } from '../sellers.graphql.js';
+
+export const DeleteSellersBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteSellersDocument}
+            entityName="sellers"
+            requiredPermissions={['DeleteSeller']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_sellers/sellers.graphql.ts

@@ -59,3 +59,12 @@ export const deleteSellerDocument = graphql(`
         }
     }
 `);
+
+export const deleteSellersDocument = graphql(`
+    mutation DeleteSellers($ids: [ID!]!) {
+        deleteSellers(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_sellers/sellers.tsx

@@ -7,6 +7,7 @@ import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
 import { deleteSellerDocument, sellerListQuery } from './sellers.graphql.js';
+import { DeleteSellersBulkAction } from './components/seller-bulk-actions.js';
 
 export const Route = createFileRoute('/_authenticated/_sellers/sellers')({
     component: SellerListPage,
@@ -35,6 +36,12 @@ function SellerListPage() {
                     cell: ({ row }) => <DetailPageButton id={row.original.id} label={row.original.name} />,
                 },
             }}
+            bulkActions={[
+                {
+                    component: DeleteSellersBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateSeller']}>

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_sellers/sellers_.$id.tsx

@@ -58,7 +58,7 @@ function SellerDetailPage() {
             toast(i18n.t('Successfully updated seller'));
             form.reset(form.getValues());
             if (creatingNewEntity) {
-                await navigate({ to: `../${data?.id}`, from: Route.id });
+                await navigate({ to: `../$id`, params: { id: data.id } });
             }
         },
         onError: err => {

+ 61 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-bulk-actions.tsx

@@ -0,0 +1,61 @@
+import { AssignToChannelBulkAction } from '@/components/shared/assign-to-channel-bulk-action.js';
+import { RemoveFromChannelBulkAction } from '@/components/shared/remove-from-channel-bulk-action.js';
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { api } from '@/graphql/api.js';
+import { useChannel } from '@/index.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+
+import {
+    assignShippingMethodsToChannelDocument,
+    deleteShippingMethodsDocument,
+    removeShippingMethodsFromChannelDocument,
+} from '../shipping-methods.graphql.js';
+
+export const DeleteShippingMethodsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteShippingMethodsDocument}
+            entityName="shipping methods"
+            requiredPermissions={['DeleteShippingMethod']}
+            selection={selection}
+            table={table}
+        />
+    );
+};
+
+export const AssignShippingMethodsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <AssignToChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="shipping methods"
+            mutationFn={api.mutate(assignShippingMethodsToChannelDocument)}
+            requiredPermissions={['UpdateShippingMethod']}
+            buildInput={(channelId: string) => ({
+                shippingMethodIds: selection.map(s => s.id),
+                channelId,
+            })}
+        />
+    );
+};
+
+export const RemoveShippingMethodsFromChannelBulkAction: BulkActionComponent<any> = ({
+    selection,
+    table,
+}) => {
+    const { selectedChannel } = useChannel();
+
+    return (
+        <RemoveFromChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="shipping methods"
+            mutationFn={api.mutate(removeShippingMethodsFromChannelDocument)}
+            requiredPermissions={['UpdateShippingMethod']}
+            buildInput={() => ({
+                shippingMethodIds: selection.map(s => s.id),
+                channelId: selectedChannel?.id,
+            })}
+        />
+    );
+};

+ 27 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts

@@ -81,3 +81,30 @@ export const deleteShippingMethodDocument = graphql(`
         }
     }
 `);
+
+export const deleteShippingMethodsDocument = graphql(`
+    mutation DeleteShippingMethods($ids: [ID!]!) {
+        deleteShippingMethods(ids: $ids) {
+            result
+            message
+        }
+    }
+`);
+
+export const assignShippingMethodsToChannelDocument = graphql(`
+    mutation AssignShippingMethodsToChannel($input: AssignShippingMethodsToChannelInput!) {
+        assignShippingMethodsToChannel(input: $input) {
+            id
+            name
+        }
+    }
+`);
+
+export const removeShippingMethodsFromChannelDocument = graphql(`
+    mutation RemoveShippingMethodsFromChannel($input: RemoveShippingMethodsFromChannelInput!) {
+        removeShippingMethodsFromChannel(input: $input) {
+            id
+            name
+        }
+    }
+`);

+ 19 - 0
packages/dashboard/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx

@@ -6,6 +6,11 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import {
+    AssignShippingMethodsToChannelBulkAction,
+    DeleteShippingMethodsBulkAction,
+    RemoveShippingMethodsFromChannelBulkAction,
+} from './components/shipping-method-bulk-actions.js';
 import { TestShippingMethodDialog } from './components/test-shipping-method-dialog.js';
 import { deleteShippingMethodDocument, shippingMethodListQuery } from './shipping-methods.graphql.js';
 
@@ -38,6 +43,20 @@ function ShippingMethodListPage() {
                     name: { contains: searchTerm },
                 };
             }}
+            bulkActions={[
+                {
+                    component: AssignShippingMethodsToChannelBulkAction,
+                    order: 100,
+                },
+                {
+                    component: RemoveShippingMethodsFromChannelBulkAction,
+                    order: 200,
+                },
+                {
+                    component: DeleteShippingMethodsBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateShippingMethod']}>

+ 58 - 0
packages/dashboard/src/app/routes/_authenticated/_stock-locations/components/stock-location-bulk-actions.tsx

@@ -0,0 +1,58 @@
+import { AssignToChannelBulkAction } from '@/components/shared/assign-to-channel-bulk-action.js';
+import { RemoveFromChannelBulkAction } from '@/components/shared/remove-from-channel-bulk-action.js';
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { api } from '@/graphql/api.js';
+import { useChannel } from '@/index.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+
+import {
+    assignStockLocationsToChannelDocument,
+    deleteStockLocationsDocument,
+    removeStockLocationsFromChannelDocument,
+} from '../stock-locations.graphql.js';
+
+export const DeleteStockLocationsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteStockLocationsDocument}
+            entityName="stock locations"
+            requiredPermissions={['DeleteStockLocation']}
+            selection={selection}
+            table={table}
+        />
+    );
+};
+
+export const AssignStockLocationsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <AssignToChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="stock locations"
+            mutationFn={api.mutate(assignStockLocationsToChannelDocument)}
+            requiredPermissions={['UpdateStockLocation']}
+            buildInput={(channelId: string) => ({
+                stockLocationIds: selection.map(s => s.id),
+                channelId,
+            })}
+        />
+    );
+};
+
+export const RemoveStockLocationsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { selectedChannel } = useChannel();
+
+    return (
+        <RemoveFromChannelBulkAction
+            selection={selection}
+            table={table}
+            entityType="stock locations"
+            mutationFn={api.mutate(removeStockLocationsFromChannelDocument)}
+            requiredPermissions={['UpdateStockLocation']}
+            buildInput={() => ({
+                stockLocationIds: selection.map(s => s.id),
+                channelId: selectedChannel?.id,
+            })}
+        />
+    );
+};

+ 25 - 0
packages/dashboard/src/app/routes/_authenticated/_stock-locations/stock-locations.graphql.ts

@@ -60,3 +60,28 @@ export const deleteStockLocationDocument = graphql(`
         }
     }
 `);
+
+export const deleteStockLocationsDocument = graphql(`
+    mutation DeleteStockLocations($input: [DeleteStockLocationInput!]!) {
+        deleteStockLocations(input: $input) {
+            result
+            message
+        }
+    }
+`);
+
+export const assignStockLocationsToChannelDocument = graphql(`
+    mutation AssignStockLocationsToChannel($input: AssignStockLocationsToChannelInput!) {
+        assignStockLocationsToChannel(input: $input) {
+            id
+        }
+    }
+`);
+
+export const removeStockLocationsFromChannelDocument = graphql(`
+    mutation RemoveStockLocationsFromChannel($input: RemoveStockLocationsFromChannelInput!) {
+        removeStockLocationsFromChannel(input: $input) {
+            id
+        }
+    }
+`);

+ 19 - 0
packages/dashboard/src/app/routes/_authenticated/_stock-locations/stock-locations.tsx

@@ -6,6 +6,11 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import {
+    AssignStockLocationsToChannelBulkAction,
+    DeleteStockLocationsBulkAction,
+    RemoveStockLocationsFromChannelBulkAction,
+} from './components/stock-location-bulk-actions.js';
 import { deleteStockLocationDocument, stockLocationListQuery } from './stock-locations.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_stock-locations/stock-locations')({
@@ -32,6 +37,20 @@ function StockLocationListPage() {
                     name: { contains: searchTerm },
                 };
             }}
+            bulkActions={[
+                {
+                    component: AssignStockLocationsToChannelBulkAction,
+                    order: 100,
+                },
+                {
+                    component: RemoveStockLocationsFromChannelBulkAction,
+                    order: 200,
+                },
+                {
+                    component: DeleteStockLocationsBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateStockLocation']}>

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_tax-categories/components/tax-category-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteTaxCategoriesDocument } from '../tax-categories.graphql.js';
+
+export const DeleteTaxCategoriesBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteTaxCategoriesDocument}
+            entityName="tax categories"
+            requiredPermissions={['DeleteTaxCategory']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_tax-categories/tax-categories.graphql.ts

@@ -61,3 +61,12 @@ export const deleteTaxCategoryDocument = graphql(`
         }
     }
 `);
+
+export const deleteTaxCategoriesDocument = graphql(`
+    mutation DeleteTaxCategories($ids: [ID!]!) {
+        deleteTaxCategories(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_tax-categories/tax-categories.tsx

@@ -7,6 +7,7 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import { DeleteTaxCategoriesBulkAction } from './components/tax-category-bulk-actions.js';
 import { deleteTaxCategoryDocument, taxCategoryListQuery } from './tax-categories.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_tax-categories/tax-categories')({
@@ -49,6 +50,12 @@ function TaxCategoryListPage() {
                     ),
                 },
             }}
+            bulkActions={[
+                {
+                    component: DeleteTaxCategoriesBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateTaxCategory']}>

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_tax-rates/components/tax-rate-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteTaxRatesDocument } from '../tax-rates.graphql.js';
+
+export const DeleteTaxRatesBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteTaxRatesDocument}
+            entityName="tax rates"
+            requiredPermissions={['DeleteTaxRate']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_tax-rates/tax-rates.graphql.ts

@@ -73,3 +73,12 @@ export const deleteTaxRateDocument = graphql(`
         }
     }
 `);
+
+export const deleteTaxRatesDocument = graphql(`
+    mutation DeleteTaxRates($ids: [ID!]!) {
+        deleteTaxRates(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_tax-rates/tax-rates.tsx

@@ -10,6 +10,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
 import { taxCategoryListQuery } from '../_tax-categories/tax-categories.graphql.js';
 import { zoneListQuery } from '../_zones/zones.graphql.js';
+import { DeleteTaxRatesBulkAction } from './components/tax-rate-bulk-actions.js';
 import { deleteTaxRateDocument, taxRateListQuery } from './tax-rates.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_tax-rates/tax-rates')({
@@ -92,6 +93,12 @@ function TaxRateListPage() {
                     cell: ({ row }) => `${row.original.value}%`,
                 },
             }}
+            bulkActions={[
+                {
+                    component: DeleteTaxRatesBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateTaxRate']}>

+ 15 - 0
packages/dashboard/src/app/routes/_authenticated/_zones/components/zone-bulk-actions.tsx

@@ -0,0 +1,15 @@
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
+import { deleteZonesDocument } from '../zones.graphql.js';
+
+export const DeleteZonesBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    return (
+        <DeleteBulkAction
+            mutationDocument={deleteZonesDocument}
+            entityName="zones"
+            requiredPermissions={['DeleteZone']}
+            selection={selection}
+            table={table}
+        />
+    );
+};

+ 9 - 0
packages/dashboard/src/app/routes/_authenticated/_zones/zones.graphql.ts

@@ -94,3 +94,12 @@ export const deleteZoneDocument = graphql(`
         }
     }
 `);
+
+export const deleteZonesDocument = graphql(`
+    mutation DeleteZones($ids: [ID!]!) {
+        deleteZones(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 7 - 0
packages/dashboard/src/app/routes/_authenticated/_zones/zones.tsx

@@ -6,6 +6,7 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { PlusIcon } from 'lucide-react';
+import { DeleteZonesBulkAction } from './components/zone-bulk-actions.js';
 import { ZoneCountriesSheet } from './components/zone-countries-sheet.js';
 import { deleteZoneDocument, zoneListQuery } from './zones.graphql.js';
 
@@ -41,6 +42,12 @@ function ZoneListPage() {
                     ),
                 },
             }}
+            bulkActions={[
+                {
+                    component: DeleteZonesBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateZone']}>

+ 90 - 0
packages/dashboard/src/lib/components/shared/asset/asset-bulk-actions.tsx

@@ -0,0 +1,90 @@
+'use client';
+
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { getBulkActions } from '@/framework/data-table/data-table-extensions.js';
+import { usePageBlock } from '@/hooks/use-page-block.js';
+import { usePage } from '@/hooks/use-page.js';
+import { Trans } from '@/lib/trans.js';
+import { ChevronDown } from 'lucide-react';
+import { Asset } from './asset-gallery.js';
+
+export type AssetBulkActionContext = {
+    selection: Asset[];
+    refetch: () => void;
+};
+
+export type AssetBulkActionComponent = React.FunctionComponent<AssetBulkActionContext>;
+
+export type AssetBulkAction = {
+    order?: number;
+    component: AssetBulkActionComponent;
+};
+
+interface AssetBulkActionsProps {
+    selection: Asset[];
+    bulkActions?: AssetBulkAction[];
+    refetch: () => void;
+}
+
+export function AssetBulkActions({ selection, bulkActions, refetch }: AssetBulkActionsProps) {
+    const { pageId } = usePage();
+    const { blockId } = usePageBlock();
+
+    if (selection.length === 0) {
+        return null;
+    }
+
+    // Get extended bulk actions from the registry
+    const extendedBulkActions = pageId ? getBulkActions(pageId, blockId) : [];
+
+    // Convert DataTable bulk actions to Asset bulk actions
+    const convertedBulkActions: AssetBulkAction[] = extendedBulkActions.map(action => ({
+        order: action.order,
+        component: ({ selection }) => {
+            // Create a mock table context for compatibility
+            const mockTable = {
+                getState: () => ({ rowSelection: {} }),
+                getRow: () => null,
+            } as any;
+
+            const ActionComponent = action.component;
+            return <ActionComponent selection={selection} table={mockTable} />;
+        },
+    }));
+
+    const allBulkActions = [...convertedBulkActions, ...(bulkActions ?? [])];
+    allBulkActions.sort((a, b) => (a.order ?? 10_000) - (b.order ?? 10_000));
+
+    return (
+        <div className="flex items-center gap-2 px-2 py-1 mb-2 bg-muted/50 rounded-md border">
+            <span className="text-sm text-muted-foreground">
+                <Trans>{selection.length} selected</Trans>
+            </span>
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline" size="sm" className="h-8">
+                        <Trans>With selected...</Trans>
+                        <ChevronDown className="ml-2 h-4 w-4" />
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="start">
+                    {allBulkActions.length > 0 ? (
+                        allBulkActions.map((action, index) => (
+                            <action.component key={`asset-bulk-action-${index}`} selection={selection} refetch={refetch} />
+                        ))
+                    ) : (
+                        <DropdownMenuItem className="text-muted-foreground" disabled>
+                            <Trans>No actions available</Trans>
+                        </DropdownMenuItem>
+                    )}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

+ 12 - 7
packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx

@@ -16,14 +16,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
 import { api } from '@/graphql/api.js';
 import { assetFragment, AssetFragment } from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
-import { formatFileSize } from '@/lib/utils.js';
 import { Trans } from '@/lib/trans.js';
+import { formatFileSize } from '@/lib/utils.js';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
 import { Loader2, Search, Upload, X } from 'lucide-react';
 import { useCallback, useState } from 'react';
 import { useDropzone } from 'react-dropzone';
-import { useDebounce } from '@uidotdev/usehooks';
 import { DetailPageButton } from '../detail-page-button.js';
+import { AssetBulkAction, AssetBulkActions } from './asset-bulk-actions.js';
 
 const getAssetListDocument = graphql(
     `
@@ -76,7 +77,7 @@ export interface AssetGalleryProps {
     /**
      * @description
      * Defines whether multiple assets can be selected.
-     * 
+     *
      * If set to 'auto', the asset selection will be toggled when the user clicks on an asset.
      * If set to 'manual', multiple selection will occur only if the user holds down the control/cmd key.
      */
@@ -87,6 +88,7 @@ export interface AssetGalleryProps {
     showHeader?: boolean;
     className?: string;
     onFilesDropped?: (files: File[]) => void;
+    bulkActions?: AssetBulkAction[];
 }
 
 export function AssetGallery({
@@ -99,6 +101,7 @@ export function AssetGallery({
     showHeader = true,
     className = '',
     onFilesDropped,
+    bulkActions,
 }: AssetGalleryProps) {
     // State
     const [page, setPage] = useState(1);
@@ -111,7 +114,7 @@ export function AssetGallery({
     const queryKey = ['AssetGallery', page, pageSize, debouncedSearch, assetType];
 
     // Query for assets
-    const { data, isLoading } = useQuery({
+    const { data, isLoading, refetch } = useQuery({
         queryKey,
         queryFn: () => {
             const filter: Record<string, any> = {};
@@ -173,7 +176,6 @@ export function AssetGallery({
             return;
         }
 
-
         // Manual mode - check for modifier key
         const isModifierKeyPressed = event.metaKey || event.ctrlKey;
 
@@ -269,6 +271,9 @@ export function AssetGallery({
                 </div>
             )}
 
+            {/* Bulk actions bar */}
+            <AssetBulkActions selection={selected} bulkActions={bulkActions} refetch={refetch} />
+
             <div
                 {...getRootProps()}
                 className={`
@@ -300,7 +305,7 @@ export function AssetGallery({
                                     ${isSelected(asset as Asset) ? 'ring-2 ring-primary' : ''}
                                     flex flex-col min-w-[120px]
                                 `}
-                                onClick={(e) => handleSelect(asset as Asset, e)}
+                                onClick={e => handleSelect(asset as Asset, e)}
                             >
                                 <div
                                     className="relative w-full bg-muted/30"
@@ -324,7 +329,7 @@ export function AssetGallery({
                                     <p className="text-xs line-clamp-2 min-h-[2.5rem]" title={asset.name}>
                                         {asset.name}
                                     </p>
-                                    <div className='flex justify-between items-center'>
+                                    <div className="flex justify-between items-center">
                                         {asset.fileSize && (
                                             <p className="text-xs text-muted-foreground mt-1">
                                                 {formatFileSize(asset.fileSize)}

+ 70 - 0
packages/dashboard/src/lib/components/shared/assign-to-channel-bulk-action.tsx

@@ -0,0 +1,70 @@
+import { LayersIcon } from 'lucide-react';
+import { useState } from 'react';
+
+import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { AssignToChannelDialog } from '@/components/shared/assign-to-channel-dialog.js';
+import { useChannel, usePaginatedList } from '@/index.js';
+import { Trans } from '@/lib/trans.js';
+
+interface AssignToChannelBulkActionProps {
+    selection: any[];
+    table: any;
+    entityType: string;
+    mutationFn: (variables: any) => Promise<any>;
+    requiredPermissions: string[];
+    buildInput: (channelId: string, additionalData?: Record<string, any>) => Record<string, any>;
+    additionalFields?: React.ReactNode;
+    additionalData?: Record<string, any>;
+    /**
+     * Additional callback to run on success, after the standard refetch and reset
+     */
+    onSuccess?: () => void;
+}
+
+export function AssignToChannelBulkAction({
+    selection,
+    table,
+    entityType,
+    mutationFn,
+    requiredPermissions,
+    buildInput,
+    additionalFields,
+    additionalData = {},
+    onSuccess,
+}: Readonly<AssignToChannelBulkActionProps>) {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { channels } = useChannel();
+    const [dialogOpen, setDialogOpen] = useState(false);
+
+    if (channels.length < 2) {
+        return null;
+    }
+
+    const handleSuccess = () => {
+        refetchPaginatedList();
+        table.resetRowSelection();
+        onSuccess?.();
+    };
+
+    return (
+        <>
+            <DataTableBulkActionItem
+                requiresPermission={requiredPermissions}
+                onClick={() => setDialogOpen(true)}
+                label={<Trans>Assign to channel</Trans>}
+                icon={LayersIcon}
+            />
+            <AssignToChannelDialog
+                open={dialogOpen}
+                onOpenChange={setDialogOpen}
+                entityIds={selection.map(s => s.id)}
+                entityType={entityType}
+                mutationFn={mutationFn}
+                onSuccess={handleSuccess}
+                buildInput={buildInput}
+                additionalFields={additionalFields}
+                additionalData={additionalData}
+            />
+        </>
+    );
+}

+ 48 - 30
packages/dashboard/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx → packages/dashboard/src/lib/components/shared/assign-to-channel-dialog.tsx

@@ -1,5 +1,5 @@
 import { useMutation } from '@tanstack/react-query';
-import { useState } from 'react';
+import { ReactNode, useState } from 'react';
 import { toast } from 'sonner';
 
 import { ChannelCodeLabel } from '@/components/shared/channel-code-label.js';
@@ -23,9 +23,24 @@ interface AssignToChannelDialogProps {
     open: boolean;
     onOpenChange: (open: boolean) => void;
     entityIds: string[];
-    entityType: 'products' | 'variants';
+    entityType: string;
     mutationFn: (variables: any) => Promise<ResultOf<any>>;
     onSuccess?: () => void;
+    /**
+     * Function to build the input object for the mutation
+     * @param channelId - The selected channel ID
+     * @param additionalData - Any additional data (like priceFactor for products)
+     * @returns The input object for the mutation
+     */
+    buildInput: (channelId: string, additionalData?: Record<string, any>) => Record<string, any>;
+    /**
+     * Optional additional form fields to render
+     */
+    additionalFields?: ReactNode;
+    /**
+     * Optional additional data to pass to buildInput
+     */
+    additionalData?: Record<string, any>;
 }
 
 export function AssignToChannelDialog({
@@ -35,10 +50,12 @@ export function AssignToChannelDialog({
     entityType,
     mutationFn,
     onSuccess,
-}: AssignToChannelDialogProps) {
+    buildInput,
+    additionalFields,
+    additionalData = {},
+}: Readonly<AssignToChannelDialogProps>) {
     const { i18n } = useLingui();
     const [selectedChannelId, setSelectedChannelId] = useState<string>('');
-    const [priceFactor, setPriceFactor] = useState<number>(1);
     const { channels, selectedChannel } = useChannel();
 
     // Filter out the currently selected channel from available options
@@ -62,19 +79,7 @@ export function AssignToChannelDialog({
             return;
         }
 
-        const input =
-            entityType === 'products'
-                ? {
-                      productIds: entityIds,
-                      channelId: selectedChannelId,
-                      priceFactor,
-                  }
-                : {
-                      productVariantIds: entityIds,
-                      channelId: selectedChannelId,
-                      priceFactor,
-                  };
-
+        const input = buildInput(selectedChannelId, additionalData);
         mutate({ input });
     };
 
@@ -109,19 +114,7 @@ export function AssignToChannelDialog({
                             </SelectContent>
                         </Select>
                     </div>
-                    <div className="grid gap-2">
-                        <label className="text-sm font-medium">
-                            <Trans>Price conversion factor</Trans>
-                        </label>
-                        <Input
-                            type="number"
-                            min="0"
-                            max="99999"
-                            step="0.01"
-                            value={priceFactor}
-                            onChange={e => setPriceFactor(parseFloat(e.target.value) || 1)}
-                        />
-                    </div>
+                    {additionalFields}
                 </div>
                 <DialogFooter>
                     <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -135,3 +128,28 @@ export function AssignToChannelDialog({
         </Dialog>
     );
 }
+
+/**
+ * Hook for managing price factor state in assign-to-channel dialogs
+ */
+export function usePriceFactor() {
+    const [priceFactor, setPriceFactor] = useState<number>(1);
+
+    const priceFactorField = (
+        <div className="grid gap-2">
+            <label className="text-sm font-medium">
+                <Trans>Price conversion factor</Trans>
+            </label>
+            <Input
+                type="number"
+                min="0"
+                max="99999"
+                step="0.01"
+                value={priceFactor}
+                onChange={e => setPriceFactor(parseFloat(e.target.value) || 1)}
+            />
+        </div>
+    );
+
+    return { priceFactor, priceFactorField };
+}

+ 89 - 0
packages/dashboard/src/lib/components/shared/remove-from-channel-bulk-action.tsx

@@ -0,0 +1,89 @@
+import { useMutation } from '@tanstack/react-query';
+import { LayersIcon } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { useChannel, usePaginatedList } from '@/index.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+
+interface RemoveFromChannelBulkActionProps {
+    selection: any[];
+    table: any;
+    entityType: string;
+    mutationFn: (variables: any) => Promise<ResultOf<any>>;
+    requiredPermissions: string[];
+    buildInput: () => Record<string, any>;
+    /**
+     * Additional callback to run on success, after the standard refetch and reset
+     * @param result - The result from the mutation
+     */
+    onSuccess?: (result?: ResultOf<any>) => void;
+    /**
+     * Custom success message. If not provided, a default message will be used.
+     */
+    successMessage?: string;
+    /**
+     * Custom error message. If not provided, a default message will be used.
+     */
+    errorMessage?: string;
+}
+
+export function RemoveFromChannelBulkAction({
+    selection,
+    table,
+    entityType,
+    mutationFn,
+    requiredPermissions,
+    buildInput,
+    onSuccess,
+    successMessage,
+    errorMessage,
+}: Readonly<RemoveFromChannelBulkActionProps>) {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { selectedChannel } = useChannel();
+    const { i18n } = useLingui();
+    const { mutate } = useMutation({
+        mutationFn,
+        onSuccess: result => {
+            const message =
+                successMessage ||
+                i18n.t(`Successfully removed ${selection.length} ${entityType} from channel`);
+            toast.success(message);
+            refetchPaginatedList();
+            table.resetRowSelection();
+            onSuccess?.(result);
+        },
+        onError: error => {
+            const message =
+                errorMessage ||
+                `Failed to remove ${selection.length} ${entityType} from channel: ${error.message}`;
+            toast.error(message);
+        },
+    });
+
+    if (!selectedChannel) {
+        return null;
+    }
+
+    const handleRemove = () => {
+        mutate({
+            input: buildInput(),
+        });
+    };
+
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={requiredPermissions}
+            onClick={handleRemove}
+            label={<Trans>Remove from current channel</Trans>}
+            confirmationText={
+                <Trans>
+                    Are you sure you want to remove {selection.length} {entityType} from the current channel?
+                </Trans>
+            }
+            icon={LayersIcon}
+            className="text-warning"
+        />
+    );
+}