Repository: msupply-foundation/open-msupply
Pull request #7829: #7805 Campaign Editor
Author: login: CarlosNZ
Labels: name: feature: vaccine
Created: 2025-05-21T05:00:59Z | Merged: 2025-05-23T05:24:35Z

<!-- IMPORTANT!
  - Every PR must reference an issue; this helps to explain the intent of the PR
 -->

Fixes #7805 

# 👩🏻‍💻 What does this PR do?

Implements a (very) basic list viewer and editor modal for "Campaigns". 

Go to "Manage" -> "Campaigns" in Menu (must be central server)

You can:
- see a list of campaigns
- click a campaign to edit it in a modal
- add a new campaign with a button
- delete campaigns by selecting rows

![Screenshot 2025-05-21 at 4 50 22 PM](https://github.com/user-attachments/assets/773842d8-fcfe-44b9-a30c-5289712803a5)

![Screenshot 2025-05-21 at 4 51 35 PM](https://github.com/user-attachments/assets/ee9fed87-dc9a-4018-92cf-42740feec21a)

![Screenshot 2025-05-21 at 4 50 55 PM](https://github.com/user-attachments/assets/37ae8a83-cdc2-45cd-ac73-f73e0b930be9)


## 💌 Any notes for the reviewer?

Note that this makes a few changes/improvements to the front-end hooks done in https://github.com/msupply-foundation/open-msupply/pull/7826

See inline comments

# 🧪 Testing

<!-- Explain the steps you'd take to test the changes of this PR manually -->

Need to be Central Server, then go to Manage -> Campaigns

- [ ] Can add new campaigns
- [ ] Can see added campaigns in the list
- [ ] Can edit existing campaigns
- [ ] Can delete campaigns
- [ ] Gives an error when you try and create a new campaign with the same name as an existing one
- [ ] Check date functionality:
  - [ ] Can delete a date (i.e. save with an empty date field)
  - [ ] Can change a date
  - [ ] Gives an error when trying to save with invalid dates (i.e. Start is later than End)

# 📃 Reviewer Checklist

The PR Reviewer(s) should fill out this section before approving the PR

**Breaking Changes**
- [ ] No Breaking Changes in the Graphql API
- [ ] Technically some Breaking Changes but not expected to impact any integrations

**Issue Review**
- [ ] All requirements in original issue have been covered
- [ ] A follow up issue(s) have been created to cover additional requirements

**Tests Pass**
- [ ] Postgres
- [ ] SQLite
- [ ] Frontend



Linked issue #7805: UI for Campaign editor (Admin)
Linked issue comments: user: login: alainsussol
created_at: 2025-06-02T14:01:08Z
body: Tested in Vaccine Support Feature Release testing: see #7783 for results
author_association: NONE

Diff:
diff --git a/client/packages/common/src/intl/locales/en/common.json b/client/packages/common/src/intl/locales/en/common.json
index c33be799358..e280d3b2f5e 100644
--- a/client/packages/common/src/intl/locales/en/common.json
+++ b/client/packages/common/src/intl/locales/en/common.json
@@ -26,6 +26,7 @@
   "button.add-from-master-list": "Add from master list",
   "button.add-insurance": "Add Insurance",
   "button.add-item": "Add Item",
+  "button.add-new-campaign": "Add New Campaign",
   "button.add-new-indicator": "Add New Indicator",
   "button.add-new-program": "New Program",
   "button.add-new-stock": "Add New Stock",
@@ -128,6 +129,7 @@
   "button.view-patient": "View patient",
   "button.view-prescription": "View prescription",
   "button.zero-line-quantity": "Set quantities to 0",
+  "campaigns": "Campaigns",
   "catalogue": "Catalogue",
   "cmdk.drawer-toggle": "Navigation show/hide",
   "cmdk.goto-cold-chain-equipment": "Go to: Cold chain equipment",
@@ -316,6 +318,7 @@
   "error.no-asset-log-reasons": "There are no asset log reasons to display.",
   "error.no-asset-view-permission": "You do not have permission to view assets.",
   "error.no-assets": "There are no assets to display.",
+  "error.no-campaigns": "There are no campaigns to display",
   "error.no-create-outbound-shipment-permission": "You do not have permission to create an Outbound Shipment from a Requisition",
   "error.no-customer-return-items": "No items have been added to this return.",
   "error.no-customer-returns": "There are no Customer Returns to display.",
@@ -1173,6 +1176,8 @@
   "label.snapshot-num-of-packs": "Packs Snapshot",
   "label.soh": "SOH",
   "label.start-datetime": "Start date / time",
+  "label.start-date": "Start date",
+  "label.end-date": "End date",
   "label.status": "Status",
   "label.status-given": "Given",
   "label.status-late": "Overdue",
@@ -1449,6 +1454,10 @@
   "messages.confirm-delete-assets_one": "This will permanently remove this asset.",
   "messages.confirm-delete-assets_other": "This will permanently remove the {{count}} selected assets.",
   "messages.confirm-delete-bundled-item": "This will remove this item from the bundle",
+  "messages.confirm-delete-campaigns_few": "This will permanently remove {{count}} campaign",
+  "messages.confirm-delete-campaigns_many": "This will permanently remove {{count}} campaigns",
+  "messages.confirm-delete-campaigns_one": "This will permanently remove 1 campaign",
+  "messages.confirm-delete-campaigns_other": "This will permanently remove {{count}} campaigns",
   "messages.confirm-delete-customer-return": "This will permanently delete Customer Return #{{number}}",
   "messages.confirm-delete-document": "This will remove this document.",
   "messages.confirm-delete-generic": "This will permanently remove data",
@@ -1544,6 +1553,10 @@
   "messages.deleted-assets_one": "Deleted {{count}} asset",
   "messages.deleted-assets_other": "Deleted {{count}} assets",
   "messages.deleted-bundled-item": "Bundled item successfully deleted",
+  "messages.deleted-campaigns_few": "Deleted {{count}} campaigns",
+  "messages.deleted-campaigns_many": "Deleted {{count}} campaigns",
+  "messages.deleted-campaigns_one": "Deleted {{count}} campaign",
+  "messages.deleted-campaigns_other": "Deleted {{count}} campaigns",
   "messages.deleted-generic_few": "Deleted {{count}} records",
   "messages.deleted-generic_many": "Deleted {{count}} records",
   "messages.deleted-generic_one": "Deleted {{count}} record",
@@ -1608,6 +1621,7 @@
   "messages.error-no-file-selected": "No file selected",
   "messages.error-not-valid-email": "Please enter a valid email address",
   "messages.error-printing-report": "Unable to print report",
+  "messages.error-saving-campaign": "Error saving campaign 🥺",
   "messages.error-saving-insurances": "Error saving insurances 🥺",
   "messages.error-saving-prescription": "Error saving prescription 🥺",
   "messages.error-saving-return": "Error saving return 🥺",
@@ -1633,6 +1647,7 @@
   "messages.inbound-shipment-created-on": "Inbound shipment created on {{date}}",
   "messages.insurance-created": "Insurance created successfully",
   "messages.insurance-description": "Information about the medical insurance associated with this prescription",
+  "messages.campaign-saved": "Campaign saved successfully",
   "messages.insurance-saved": "Insurance saved successfully",
   "messages.internal-order-created-on": "Internal order created on {{date}}",
   "messages.invalid-file": "Invalid file",
@@ -1972,6 +1987,7 @@
   "select-store": "Select store",
   "sensors": "Sensors",
   "server-error.DemographicIndicatorHasNoName": "Demographic indicator has no name",
+  "server-error.InvalidDates": "Invalid dates",
   "settings": "Settings",
   "settings.label-printing": "Label Printing",
   "settings.printer-address": "IP address",
@@ -2032,8 +2048,10 @@
   "title.bundled-on": "Bundled on",
   "title.categories": "Categories",
   "title.confirm-delete-encounter": "Delete encounter",
+  "title.create-campaign": "Create Campaign",
   "title.details": "Details",
   "title.edit-insurance": "Edit insurance policy",
+  "title.edit-campaign": "Edit Campaign",
   "title.months-of-stock": "Months of Stock",
   "title.new-insurance": "New insurance policy",
   "title.new-sensor": "New sensor added",
diff --git a/client/packages/common/src/utils/environment/EnvUtils.ts b/client/packages/common/src/utils/environment/EnvUtils.ts
index 85984131969..29906dd05b8 100644
--- a/client/packages/common/src/utils/environment/EnvUtils.ts
+++ b/client/packages/common/src/utils/environment/EnvUtils.ts
@@ -94,6 +94,8 @@ const mapRoute = (route: string): RouteMapping => {
         title: 'indicators-demographics',
         docs: '/manage/demographics/',
       };
+    case inRoute(AppRoute.Campaigns):
+      return { title: 'campaigns', docs: '/manage/campaigns/' };
     case inRoute(AppRoute.ImmunisationPrograms):
       return { title: 'immunisations', docs: '/programs/immunizations/' };
     case inRoute(AppRoute.RnRForms):
diff --git a/client/packages/config/src/routes.ts b/client/packages/config/src/routes.ts
index 8987ee5854f..aa52a0634e1 100644
--- a/client/packages/config/src/routes.ts
+++ b/client/packages/config/src/routes.ts
@@ -44,6 +44,7 @@ export enum AppRoute {
   Programs = 'programs',
   Stores = 'stores',
   GlobalPreferences = 'global-preferences',
+  Campaigns = 'campaigns',
 
   Tools = 'tools',
 
diff --git a/client/packages/host/src/components/Navigation/ManageNav.tsx b/client/packages/host/src/components/Navigation/ManageNav.tsx
index 342a11137f6..cf1bd6ca2ec 100644
--- a/client/packages/host/src/components/Navigation/ManageNav.tsx
+++ b/client/packages/host/src/components/Navigation/ManageNav.tsx
@@ -64,6 +64,14 @@ export const ManageNav = ({ store }: { store?: UserStoreNodeFragment }) => {
               .build()}
             text={t('equipment')}
           />
+          <AppNavLink
+            visible={isCentralServer}
+            end
+            to={RouteBuilder.create(AppRoute.Manage)
+              .addPart(AppRoute.Campaigns)
+              .build()}
+            text={t('campaigns')}
+          />
         </List>
       </Collapse>
     </AppNavSection>
diff --git a/client/packages/system/src/Manage/Campaigns/ListView/AppBarButtons.tsx b/client/packages/system/src/Manage/Campaigns/ListView/AppBarButtons.tsx
new file mode 100644
index 00000000000..78a6876910f
--- /dev/null
+++ b/client/packages/system/src/Manage/Campaigns/ListView/AppBarButtons.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import {
+  AppBarButtonsPortal,
+  Grid,
+  ButtonWithIcon,
+  useTranslation,
+  PlusCircleIcon,
+} from '@openmsupply-client/common';
+
+export const AppBarButtonsComponent = ({ onOpen }: { onOpen: () => void }) => {
+  const t = useTranslation();
+
+  return (
+    <AppBarButtonsPortal>
+      <Grid container gap={1}>
+        <ButtonWithIcon
+          Icon={<PlusCircleIcon />}
+          label={t('button.add-new-campaign')}
+          onClick={onOpen}
+        />
+      </Grid>
+    </AppBarButtonsPortal>
+  );
+};
+
+export const AppBarButtons = React.memo(AppBarButtonsComponent);
diff --git a/client/packages/system/src/Manage/Campaigns/ListView/CampaignEditModal.tsx b/client/packages/system/src/Manage/Campaigns/ListView/CampaignEditModal.tsx
new file mode 100644
index 00000000000..8e59d337226
--- /dev/null
+++ b/client/packages/system/src/Manage/Campaigns/ListView/CampaignEditModal.tsx
@@ -0,0 +1,98 @@
+import React, { FC } from 'react';
+import {
+  useTranslation,
+  DetailContainer,
+  Box,
+  useDialog,
+  DialogButton,
+  InputWithLabelRow,
+  BasicTextInput,
+  DateUtils,
+  DateTimePickerInput,
+} from '@openmsupply-client/common';
+import { DraftCampaign, defaultDraftCampaign } from '../api';
+
+interface CampaignEditModalProps {
+  campaign: DraftCampaign;
+  isOpen: boolean;
+  onClose: () => void;
+  updateDraft: (campaign: Partial<DraftCampaign>) => void;
+  upsert: () => Promise<void>;
+}
+
+export const CampaignEditModal: FC<CampaignEditModalProps> = ({
+  campaign,
+  isOpen,
+  onClose,
+  updateDraft,
+  upsert,
+}) => {
+  const t = useTranslation();
+
+  const { Modal } = useDialog({ isOpen, onClose, disableBackdrop: true });
+
+  const { name, startDate, endDate, id } = campaign;
+
+  return (
+    <Modal
+      title={id ? 'Edit Campaign' : 'Create Campaign'}
+      cancelButton={
+        <DialogButton
+          variant="cancel"
+          onClick={() => {
+            onClose();
+            updateDraft(defaultDraftCampaign);
+          }}
+        />
+      }
+      okButton={
+        <DialogButton
+          variant="ok"
+          onClick={async () => {
+            await upsert();
+            onClose();
+            updateDraft(defaultDraftCampaign);
+          }}
+        />
+      }
+    >
+      <DetailContainer>
+        <Box display="flex" flexDirection="column" gap={2}>
+          <InputWithLabelRow
+            key="name"
+            label={t('label.name')}
+            Input={
+              <BasicTextInput
+                sx={{ width: 250 }}
+                value={name}
+                onChange={e => updateDraft({ name: e.target.value })}
+              />
+            }
+          />
+          <InputWithLabelRow
+            key="start-date"
+            label={t('label.start-date')}
+            Input={
+              <DateTimePickerInput
+                sx={{ width: 250 }}
+                value={DateUtils.getDateOrNull(startDate)}
+                onChange={startDate => updateDraft({ startDate })}
+              />
+            }
+          />
+          <InputWithLabelRow
+            key="end-date"
+            label={t('label.end-date')}
+            Input={
+              <DateTimePickerInput
+                sx={{ width: 250 }}
+                value={DateUtils.getDateOrNull(endDate)}
+                onChange={endDate => updateDraft({ endDate })}
+              />
+            }
+          />
+        </Box>
+      </DetailContainer>
+    </Modal>
+  );
+};
diff --git a/client/packages/system/src/Manage/Campaigns/ListView/Footer.tsx b/client/packages/system/src/Manage/Campaigns/ListView/Footer.tsx
new file mode 100644
index 00000000000..946bc75e591
--- /dev/null
+++ b/client/packages/system/src/Manage/Campaigns/ListView/Footer.tsx
@@ -0,0 +1,45 @@
+import React, { FC, memo } from 'react';
+import {
+  Action,
+  ActionsFooter,
+  DeleteIcon,
+  useTranslation,
+  AppFooterPortal,
+} from '@openmsupply-client/common';
+
+interface FooterProps {
+  selectedRowCount: number;
+  deleteRows: () => void;
+}
+
+export const FooterComponent: FC<FooterProps> = ({
+  selectedRowCount,
+  deleteRows,
+}) => {
+  const t = useTranslation();
+
+  const actions: Action[] = [
+    {
+      label: t('button.delete-lines'),
+      icon: <DeleteIcon />,
+      onClick: deleteRows,
+    },
+  ];
+
+  return (
+    <AppFooterPortal
+      Content={
+        <>
+          {selectedRowCount > 0 && (
+            <ActionsFooter
+              actions={actions}
+              selectedRowCount={selectedRowCount}
+            />
+          )}
+        </>
+      }
+    />
+  );
+};
+
+export const Footer = memo(FooterComponent);
diff --git a/client/packages/system/src/Manage/Campaigns/ListView/ListView.tsx b/client/packages/system/src/Manage/Campaigns/ListView/ListView.tsx
new file mode 100644
index 00000000000..23a3012de21
--- /dev/null
+++ b/client/packages/system/src/Manage/Campaigns/ListView/ListView.tsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import {
+  TableProvider,
+  DataTable,
+  useColumns,
+  createTableStore,
+  NothingHere,
+  useUrlQueryParams,
+  useEditModal,
+  useTranslation,
+  ColumnFormat,
+  CampaignNode,
+  GenericColumnKey,
+  useTableStore,
+  useDeleteConfirmation,
+  useNotification,
+} from '@openmsupply-client/common';
+import { Footer } from './Footer';
+import { CampaignEditModal } from './CampaignEditModal';
+import { AppBarButtons } from './AppBarButtons';
+import { CampaignRowFragment, DraftCampaign, useCampaigns } from '../api';
+
+const CampaignsComponent = () => {
+  const t = useTranslation();
+  const {
+    // filter,
+    updateSortQuery,
+    updatePaginationQuery,
+    queryParams: { sortBy, page, first, offset, filterBy },
+  } = useUrlQueryParams({ initialSort: { key: 'name', dir: 'asc' } });
+
+  const queryParams = { sortBy, first, offset, filterBy };
+  const {
+    query: { data, isError, isLoading },
+    upsert: { upsert },
+    delete: { deleteCampaign },
+    draft,
+    updateDraft,
+  } = useCampaigns(queryParams);
+
+  const pagination = { page, first, offset };
+
+  const { isOpen, onClose, onOpen } = useEditModal();
+
+  const { error, success } = useNotification();
+
+  const { selectedRows } = useTableStore(state => ({
+    selectedRows: Object.keys(state.rowState)
+      .filter(id => state.rowState[id]?.isSelected)
+      .map(selectedId => data?.nodes?.find(({ id }) => selectedId === id))
+      .filter(Boolean),
+  }));
+
+  const save = async () => {
+    const result = await upsert();
+    if (result?.__typename === 'CampaignNode')
+      success(t('messages.campaign-saved'))();
+    else if ('error' in result)
+      error(
+        `${t('messages.error-saving-campaign')} — ${result?.error?.description ?? ''}`
+      )();
+  };
+
+  const confirmAndDelete = useDeleteConfirmation({
+    selectedRows,
+    deleteAction: async () => {
+      const result = await Promise.all(
+        selectedRows.map(row => {
+          if (row) {
+            return deleteCampaign(row.id);
+          }
+        })
+      );
+      if (result.some(row => row?.__typename !== 'DeleteCampaignSuccess')) {
+        // Will currently show the "Can't delete" toast if *any* of the rows
+        // fail to delete
+        throw new Error('Delete failed');
+      }
+    },
+    messages: {
+      confirmMessage: t('messages.confirm-delete-campaigns', {
+        count: selectedRows.length,
+      }),
+      deleteSuccess: t('messages.deleted-campaigns', {
+        count: selectedRows.length,
+      }),
+    },
+  });
+
+  const onRowClick = (row: CampaignNode) => {
+    const selected = data?.nodes.find(campaign => campaign.id === row.id);
+    updateDraft(selected as DraftCampaign);
+    onOpen();
+  };
+
+  const columns = useColumns<CampaignRowFragment>(
+    [
+      GenericColumnKey.Selection,
+      'name',
+      {
+        key: 'startDate',
+        label: 'label.start-date',
+        width: 150,
+        format: ColumnFormat.Date,
+        sortable: false,
+      },
+      {
+        key: 'endDate',
+        label: 'label.end-date',
+        width: 150,
+        format: ColumnFormat.Date,
+        sortable: false,
+      },
+    ],
+    {
+      sortBy,
+      onChangeSortBy: updateSortQuery,
+    },
+    [sortBy]
+  );
+
+  return (
+    <>
+      <AppBarButtons onOpen={onOpen} />
+      {isOpen && (
+        <CampaignEditModal
+          isOpen={isOpen}
+          campaign={draft}
+          onClose={onClose}
+          upsert={save}
+          updateDraft={updateDraft}
+        />
+      )}
+      <DataTable
+        id="campaign-list"
+        pagination={{ ...pagination, total: data?.totalCount ?? 0 }}
+        onChangePage={updatePaginationQuery}
+        columns={columns}
+        data={data?.nodes}
+        isLoading={isLoading}
+        isError={isError}
+        noDataElement={<NothingHere body={t('error.no-campaigns')} />}
+        onRowClick={onRowClick}
+      />
+      <Footer
+        selectedRowCount={selectedRows.length}
+        deleteRows={confirmAndDelete}
+      />
+    </>
+  );
+};
+
+export const CampaignsList = () => (
+  <TableProvider createStore={createTableStore}>
+    <CampaignsComponent />
+  </TableProvider>
+);
diff --git a/client/packages/system/src/Manage/Campaigns/ListView/index.ts b/client/packages/system/src/Manage/Campaigns/ListView/index.ts
new file mode 100644
index 00000000000..e1e4de2f280
--- /dev/null
+++ b/client/packages/system/src/Manage/Campaigns/ListView/index.ts
@@ -0,0 +1 @@
+export * from './ListView';
diff --git a/client/packages/system/src/Manage/Campaigns/api/hooks/useCampaigns.ts b/client/packages/system/src/Manage/Campaigns/api/hooks/useCampaigns.ts
index 338075bcea5..4d703f872a8 100644
--- a/client/packages/system/src/Manage/Campaigns/api/hooks/useCampaigns.ts
+++ b/client/packages/system/src/Manage/Campaigns/api/hooks/useCampaigns.ts
@@ -1,3 +1,4 @@
+import { useState } from 'react';
 import { CampaignRowFragment } from '../operations.generated';
 import { useCampaignGraphQL } from '../useCampaignGraphQL';
 import { CAMPAIGN } from './keys';
@@ -8,7 +9,9 @@ import {
   LIST_KEY,
   CampaignFilterInput,
   CampaignSortFieldInput,
-  UpsertCampaignInput,
+  FnUtils,
+  Formatter,
+  useIntlUtils,
 } from '@openmsupply-client/common';
 
 type ListParams = {
@@ -18,20 +21,49 @@ type ListParams = {
   filterBy?: CampaignFilterInput | null;
 };
 
+export type DraftCampaign = {
+  id: string;
+  name: string;
+  startDate: Date | null;
+  endDate: Date | null;
+};
+
+export const defaultDraftCampaign: DraftCampaign = {
+  id: '',
+  name: '',
+  startDate: null,
+  endDate: null,
+};
+
 export const useCampaigns = (queryParams?: ListParams) => {
+  const [draft, setDraft] = useState<DraftCampaign>(defaultDraftCampaign);
+
   // QUERY
   const { data, isLoading, isError } = useGetList(queryParams);
 
+  // UPDATE DRAFT
+  const updateDraft = (patch: Partial<DraftCampaign>) => {
+    setDraft({ ...draft, ...patch });
+  };
+
   // UPSERT
   const {
-    mutateAsync: upsert,
+    mutateAsync: upsertMutation,
     isLoading: isUpserting,
     error: upsertError,
   } = useUpsertCampaign();
 
+  const upsert = async () => {
+    return await upsertMutation(draft);
+  };
+
+  const deleteCampaign = async (id: string) => {
+    return await deleteMutation(id);
+  };
+
   // DELETE
   const {
-    mutateAsync: deleteCampaign,
+    mutateAsync: deleteMutation,
     isLoading: isDeleting,
     error: deleteError,
   } = useDeleteCampaign();
@@ -40,6 +72,8 @@ export const useCampaigns = (queryParams?: ListParams) => {
     query: { data, isLoading, isError },
     upsert: { upsert, isUpserting, upsertError },
     delete: { deleteCampaign, isDeleting, deleteError },
+    draft,
+    updateDraft,
   };
 };
 
@@ -74,9 +108,37 @@ const toSortInput = (sortBy?: SortBy<CampaignRowFragment>) => ({
 
 const useUpsertCampaign = () => {
   const { campaignApi, queryClient } = useCampaignGraphQL();
+  const { translateServerError } = useIntlUtils();
+
+  const mutationFn = async (draft: DraftCampaign) => {
+    // If the dates have been modified, they will be Date objects, but initial
+    // value from database is string/null
+    const startDate =
+      draft.startDate instanceof Date
+        ? Formatter.naiveDate(draft.startDate)
+        : draft.startDate;
+
+    const endDate =
+      draft.endDate instanceof Date
+        ? Formatter.naiveDate(draft.endDate)
+        : draft.endDate;
 
-  const mutationFn = async (input: UpsertCampaignInput) => {
-    await campaignApi.upsertCampaign({ input });
+    const input = {
+      id: draft.id || FnUtils.generateUUID(),
+      name: draft.name,
+      startDate,
+      endDate,
+    };
+    try {
+      const result = await campaignApi.upsertCampaign({ input });
+      return result?.centralServer?.campaign?.upsertCampaign;
+    } catch (error) {
+      // For invalid dates, server returns a GraphQL standard error
+      return {
+        __typename: 'UpsertCampaignError',
+        error: { description: translateServerError((error as Error)?.message) },
+      };
+    }
   };
 
   return useMutation({
@@ -92,9 +154,18 @@ const useUpsertCampaign = () => {
 
 const useDeleteCampaign = () => {
   const { campaignApi, queryClient } = useCampaignGraphQL();
+  const { translateServerError } = useIntlUtils();
 
   const mutationFn = async (id: string) => {
-    await campaignApi.deleteCampaign({ id });
+    try {
+      const result = await campaignApi.deleteCampaign({ id });
+      return result?.centralServer?.campaign?.deleteCampaign;
+    } catch (error) {
+      return {
+        __typename: 'DeleteCampaignError',
+        error: { description: translateServerError((error as Error)?.message) },
+      };
+    }
   };
 
   return useMutation({
diff --git a/client/packages/system/src/Manage/Campaigns/api/operations.generated.ts b/client/packages/system/src/Manage/Campaigns/api/operations.generated.ts
index 8c9f632550f..7d0d4c1c253 100644
--- a/client/packages/system/src/Manage/Campaigns/api/operations.generated.ts
+++ b/client/packages/system/src/Manage/Campaigns/api/operations.generated.ts
@@ -201,6 +201,7 @@ export const DeleteCampaignDocument = gql`
             }
           }
           ... on DeleteCampaignSuccess {
+            __typename
             id
           }
         }
diff --git a/client/packages/system/src/Manage/Campaigns/api/operations.graphql b/client/packages/system/src/Manage/Campaigns/api/operations.graphql
index 1c5d279f0e7..2d59f29bec5 100644
--- a/client/packages/system/src/Manage/Campaigns/api/operations.graphql
+++ b/client/packages/system/src/Manage/Campaigns/api/operations.graphql
@@ -83,6 +83,7 @@ mutation deleteCampaign($id: String!) {
           }
         }
         ... on DeleteCampaignSuccess {
+          __typename
           id
         }
       }
diff --git a/client/packages/system/src/Manage/Campaigns/index.ts b/client/packages/system/src/Manage/Campaigns/index.ts
new file mode 100644
index 00000000000..e1e4de2f280
--- /dev/null
+++ b/client/packages/system/src/Manage/Campaigns/index.ts
@@ -0,0 +1 @@
+export * from './ListView';
diff --git a/client/packages/system/src/Manage/Service/Service.tsx b/client/packages/system/src/Manage/Service/Service.tsx
index 1160b07db7c..c9d8bc51031 100644
--- a/client/packages/system/src/Manage/Service/Service.tsx
+++ b/client/packages/system/src/Manage/Service/Service.tsx
@@ -3,6 +3,7 @@ import { RouteBuilder, Routes, Route } from '@openmsupply-client/common';
 import { AppRoute } from '@openmsupply-client/config';
 import { IndicatorsDemographics } from '../IndicatorsDemographics/DetailView/IndicatorsDemographics';
 import { EditPreferencesPage } from '../Preferences/EditPage';
+import { CampaignsList } from '../Campaigns';
 
 export const ManageService: FC = () => {
   const indicatorsDemographicsRoute = RouteBuilder.create(
@@ -13,6 +14,8 @@ export const ManageService: FC = () => {
     .addPart(':key?')
     .build();
 
+  const campaignsRoute = RouteBuilder.create(AppRoute.Campaigns).build();
+
   return (
     <Routes>
       <Route
@@ -20,6 +23,7 @@ export const ManageService: FC = () => {
         element={<IndicatorsDemographics />}
       />
       <Route path={preferencesRoute} element={<EditPreferencesPage />} />
+      <Route path={campaignsRoute} element={<CampaignsList />} />
     </Routes>
   );
 };
diff --git a/server/service/src/campaign/upsert.rs b/server/service/src/campaign/upsert.rs
index 96080a70b4c..423798d1679 100644
--- a/server/service/src/campaign/upsert.rs
+++ b/server/service/src/campaign/upsert.rs
@@ -74,8 +74,11 @@ fn validate(
     input: &UpsertCampaign,
 ) -> Result<(), UpsertCampaignError> {
     // Check for duplicate name
-    let campaigns_with_duplicate_name = CampaignRepository::new(connection)
-        .query_by_filter(CampaignFilter::new().name(StringFilter::equal_to(input.name.trim())))?;
+    let campaigns_with_duplicate_name = CampaignRepository::new(connection).query_by_filter(
+        CampaignFilter::new()
+            .name(StringFilter::equal_to(input.name.trim()))
+            .id(EqualFilter::not_equal_to(&input.id)),
+    )?;
 
     if !campaigns_with_duplicate_name.is_empty() {
         return Err(UpsertCampaignError::DuplicateName);


Reviews:
user: login: CarlosNZ
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T00:55:30Z
user: login: fergie-nz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T02:15:56Z
user: login: fergie-nz
body: Looks awesome Carl.

Just one change on awaiting success of all deletions.

The other things are UI or naming nits which is just my preference.


state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T02:32:45Z
user: login: CarlosNZ
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T02:23:49Z
user: login: CarlosNZ
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T02:27:33Z
user: login: CarlosNZ
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T03:21:08Z
user: login: fergie-nz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T04:08:50Z
user: login: fergie-nz
body: Looks great!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T04:23:33Z

Review comments:
diff_hunk: @@ -0,0 +1,149 @@
+import React from 'react';
+import {
+  TableProvider,
+  DataTable,
+  useColumns,
+  createTableStore,
+  NothingHere,
+  useUrlQueryParams,
+  useEditModal,
+  useTranslation,
+  ColumnFormat,
+  CampaignNode,
+  GenericColumnKey,
+  useTableStore,
+  useDeleteConfirmation,
+  useNotification,
+} from '@openmsupply-client/common';
+import { Footer } from './Footer';
+import { CampaignEditModal } from './CampaignEditModal';
+import { AppBarButtons } from './AppBarButtons';
+import { CampaignRowFragment, DraftCampaign, useCampaigns } from '../api';
+
+const CampaignsComponent = () => {
+  const t = useTranslation();
+  const {
+    // filter,
+    updateSortQuery,
+    updatePaginationQuery,
+    queryParams: { sortBy, page, first, offset, filterBy },
+  } = useUrlQueryParams({ initialSort: { key: 'name', dir: 'asc' } });
+
+  const queryParams = { sortBy, first, offset, filterBy };
+  const {
+    query: { data, isError, isLoading },
+    upsert: { upsert },
+    delete: { deleteCampaign },
+    draft,
+    updateDraft,
+  } = useCampaigns(queryParams);
+
+  const pagination = { page, first, offset };
+
+  const { isOpen, onClose, onOpen } = useEditModal();
+
+  const { error, success } = useNotification();
+
+  const { selectedRows } = useTableStore(state => ({
+    selectedRows: Object.keys(state.rowState)
+      .filter(id => state.rowState[id]?.isSelected)
+      .map(selectedId => data?.nodes?.find(({ id }) => selectedId === id))
+      .filter(Boolean),
+  }));
+
+  const save = async () => {
+    const result = await upsert();
+    if (result?.__typename === 'CampaignNode')
+      success(t('messages.campaign-saved'))();
+    else if ('error' in result)
+      error(
+        `${t('messages.error-saving-campaign')} — ${result?.error?.description ?? ''}`
+      )();
+  };
+
+  const confirmAndDelete = useDeleteConfirmation({
+    selectedRows,
+    deleteAction: async () =>
+      selectedRows.forEach(row => {
+        if (row) {
+          deleteCampaign(row.id);
+        }
path: client/packages/system/src/Manage/Campaigns/ListView/ListView.tsx
user: login: CarlosNZ
body: I don't like doing a separate mutation for each deletion, but the current GraphQL Delete endpoint only accepts a single ID. We should probably change this to accept an array of IDs like some of the other ones. New issue you reckon?
created_at: 2025-05-22T00:51:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,8 +74,11 @@ fn validate(
     input: &UpsertCampaign,
 ) -> Result<(), UpsertCampaignError> {
     // Check for duplicate name
-    let campaigns_with_duplicate_name = CampaignRepository::new(connection)
-        .query_by_filter(CampaignFilter::new().name(StringFilter::equal_to(input.name.trim())))?;
+    let campaigns_with_duplicate_name = CampaignRepository::new(connection).query_by_filter(
+        CampaignFilter::new()
+            .name(StringFilter::equal_to(input.name.trim()))
+            .id(EqualFilter::not_equal_to(&input.id)),
+    )?;
path: server/service/src/campaign/upsert.rs
user: login: CarlosNZ
body: This was causing a problem -- it would give an error when updating and exiting row if the name was the same, so I added additional clause to only give this error when *inserting* a new campaign, not updating an existing one.
created_at: 2025-05-22T00:55:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -315,6 +317,7 @@
   "error.no-asset-log-reasons": "There are no asset log reasons to display.",
   "error.no-asset-view-permission": "You do not have permission to view assets.",
   "error.no-assets": "There are no assets to display.",
+  "error.no-campaigns": "No campaigns to display",
path: client/packages/common/src/intl/locales/en/common.json
user: login: fergie-nz
body: Nit: change this to "There are no campaigns to display" for consistency with similar error messages
created_at: 2025-05-22T02:15:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1449,6 +1454,10 @@
   "messages.confirm-delete-assets_one": "This will permanently remove this asset.",
   "messages.confirm-delete-assets_other": "This will permanently remove the {{count}} selected assets.",
   "messages.confirm-delete-bundled-item": "This will remove this item from the bundle",
+  "messages.confirm-delete-campaigns_few": "This will permanently remove {{count}} campaign",
+  "messages.confirm-delete-campaigns_many": "This will permanently remove {{count}} campaigns",
+  "messages.confirm-delete-campaigns_one": "This will permanently remove 1 campaign",
+  "messages.confirm-delete-campaigns_other": "This will permanently remove {{count}} campaigns",
path: client/packages/common/src/intl/locales/en/common.json
user: login: fergie-nz
body: I have a tangent thought that it would be awesome to have a second dynamic value so we don't need so many similar delete confirmation messages

ie:
`This will permanently remove {{count}} {{name}}`
created_at: 2025-05-22T02:18:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+import React, { FC } from 'react';
+import {
+  useTranslation,
+  DetailContainer,
+  Box,
+  useDialog,
+  DialogButton,
+  InputWithLabelRow,
+  BasicTextInput,
+  DateUtils,
+  DateTimePickerInput,
+} from '@openmsupply-client/common';
+import { DraftCampaign, defaultDraftCampaign } from '../api';
+
+interface CampaignEditModalProps {
+  campaign: DraftCampaign;
+  isOpen: boolean;
+  onClose: () => void;
+  updateDraft: (campaign: Partial<DraftCampaign>) => void;
+  upsert: () => Promise<void>;
+}
+
+export const CampaignEditModal: FC<CampaignEditModalProps> = ({
+  campaign,
+  isOpen,
+  onClose,
+  updateDraft,
+  upsert,
+}) => {
+  const t = useTranslation();
+
+  const { Modal } = useDialog({ isOpen, onClose, disableBackdrop: true });
+
+  const { name, startDate, endDate, id } = campaign;
+
+  return (
+    <Modal
+      title={id ? 'Edit Campaign' : 'Create Campaign'}
+      cancelButton={
+        <DialogButton
+          variant="cancel"
+          onClick={() => {
+            onClose();
+            updateDraft(defaultDraftCampaign);
+          }}
+        />
+      }
+      okButton={
+        <DialogButton
+          variant="ok"
+          onClick={async () => {
+            await upsert();
+            onClose();
+            updateDraft(defaultDraftCampaign);
+          }}
+        />
+      }
+      height={1000}
path: client/packages/system/src/Manage/Campaigns/ListView/CampaignEditModal.tsx
user: login: fergie-nz
body: nit again but could make this smaller as full screen height feels unnecessary to me:

![Screenshot 2025-05-22 at 2 15 15 PM](https://github.com/user-attachments/assets/e0120559-2b8f-4b5f-9d07-543785699631)

created_at: 2025-05-22T02:20:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,149 @@
+import React from 'react';
+import {
+  TableProvider,
+  DataTable,
+  useColumns,
+  createTableStore,
+  NothingHere,
+  useUrlQueryParams,
+  useEditModal,
+  useTranslation,
+  ColumnFormat,
+  CampaignNode,
+  GenericColumnKey,
+  useTableStore,
+  useDeleteConfirmation,
+  useNotification,
+} from '@openmsupply-client/common';
+import { Footer } from './Footer';
+import { CampaignEditModal } from './CampaignEditModal';
+import { AppBarButtons } from './AppBarButtons';
+import { CampaignRowFragment, DraftCampaign, useCampaigns } from '../api';
+
+const CampaignsComponent = () => {
+  const t = useTranslation();
+  const {
+    // filter,
+    updateSortQuery,
+    updatePaginationQuery,
+    queryParams: { sortBy, page, first, offset, filterBy },
+  } = useUrlQueryParams({ initialSort: { key: 'name', dir: 'asc' } });
+
+  const queryParams = { sortBy, first, offset, filterBy };
+  const {
+    query: { data, isError, isLoading },
+    upsert: { upsert },
+    delete: { deleteCampaign },
+    draft,
+    updateDraft,
+  } = useCampaigns(queryParams);
+
+  const pagination = { page, first, offset };
+
+  const { isOpen, onClose, onOpen } = useEditModal();
+
+  const { error, success } = useNotification();
+
+  const { selectedRows } = useTableStore(state => ({
+    selectedRows: Object.keys(state.rowState)
+      .filter(id => state.rowState[id]?.isSelected)
+      .map(selectedId => data?.nodes?.find(({ id }) => selectedId === id))
+      .filter(Boolean),
+  }));
+
+  const save = async () => {
+    const result = await upsert();
+    if (result?.__typename === 'CampaignNode')
+      success(t('messages.campaign-saved'))();
+    else if ('error' in result)
+      error(
+        `${t('messages.error-saving-campaign')} — ${result?.error?.description ?? ''}`
+      )();
+  };
+
+  const confirmAndDelete = useDeleteConfirmation({
+    selectedRows,
+    deleteAction: async () =>
+      selectedRows.forEach(row => {
+        if (row) {
+          deleteCampaign(row.id);
+        }
path: client/packages/system/src/Manage/Campaigns/ListView/ListView.tsx
user: login: fergie-nz
body: Seems very reasonable to me to loop through for now. I can't imagine mass campaign deleting occurring other than from testing?

@jmbrunskill do you have any thoughts?
created_at: 2025-05-22T02:22:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,149 @@
+import React from 'react';
+import {
+  TableProvider,
+  DataTable,
+  useColumns,
+  createTableStore,
+  NothingHere,
+  useUrlQueryParams,
+  useEditModal,
+  useTranslation,
+  ColumnFormat,
+  CampaignNode,
+  GenericColumnKey,
+  useTableStore,
+  useDeleteConfirmation,
+  useNotification,
+} from '@openmsupply-client/common';
+import { Footer } from './Footer';
+import { CampaignEditModal } from './CampaignEditModal';
+import { AppBarButtons } from './AppBarButtons';
+import { CampaignRowFragment, DraftCampaign, useCampaigns } from '../api';
+
+const CampaignsComponent = () => {
+  const t = useTranslation();
+  const {
+    // filter,
+    updateSortQuery,
+    updatePaginationQuery,
+    queryParams: { sortBy, page, first, offset, filterBy },
+  } = useUrlQueryParams({ initialSort: { key: 'name', dir: 'asc' } });
+
+  const queryParams = { sortBy, first, offset, filterBy };
+  const {
+    query: { data, isError, isLoading },
+    upsert: { upsert },
+    delete: { deleteCampaign },
+    draft,
+    updateDraft,
+  } = useCampaigns(queryParams);
+
+  const pagination = { page, first, offset };
+
+  const { isOpen, onClose, onOpen } = useEditModal();
+
+  const { error, success } = useNotification();
+
+  const { selectedRows } = useTableStore(state => ({
+    selectedRows: Object.keys(state.rowState)
+      .filter(id => state.rowState[id]?.isSelected)
+      .map(selectedId => data?.nodes?.find(({ id }) => selectedId === id))
+      .filter(Boolean),
+  }));
+
+  const save = async () => {
+    const result = await upsert();
+    if (result?.__typename === 'CampaignNode')
+      success(t('messages.campaign-saved'))();
+    else if ('error' in result)
+      error(
+        `${t('messages.error-saving-campaign')} — ${result?.error?.description ?? ''}`
+      )();
+  };
+
+  const confirmAndDelete = useDeleteConfirmation({
+    selectedRows,
+    deleteAction: async () =>
+      selectedRows.forEach(row => {
+        if (row) {
+          deleteCampaign(row.id);
+        }
path: client/packages/system/src/Manage/Campaigns/ListView/ListView.tsx
user: login: fergie-nz
body: Actually seems fine - we use the same looping in a number of other parts of the app (asset delete, or returns rows for example). And this seems less likely to be an area of the app with high traffic.
created_at: 2025-05-22T02:25:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,149 @@
+import React from 'react';
+import {
+  TableProvider,
+  DataTable,
+  useColumns,
+  createTableStore,
+  NothingHere,
+  useUrlQueryParams,
+  useEditModal,
+  useTranslation,
+  ColumnFormat,
+  CampaignNode,
+  GenericColumnKey,
+  useTableStore,
+  useDeleteConfirmation,
+  useNotification,
+} from '@openmsupply-client/common';
+import { Footer } from './Footer';
+import { CampaignEditModal } from './CampaignEditModal';
+import { AppBarButtons } from './AppBarButtons';
+import { CampaignRowFragment, DraftCampaign, useCampaigns } from '../api';
+
+const CampaignsComponent = () => {
+  const t = useTranslation();
+  const {
+    // filter,
+    updateSortQuery,
+    updatePaginationQuery,
+    queryParams: { sortBy, page, first, offset, filterBy },
+  } = useUrlQueryParams({ initialSort: { key: 'name', dir: 'asc' } });
+
+  const queryParams = { sortBy, first, offset, filterBy };
+  const {
+    query: { data, isError, isLoading },
+    upsert: { upsert },
+    delete: { deleteCampaign },
+    draft,
+    updateDraft,
+  } = useCampaigns(queryParams);
+
+  const pagination = { page, first, offset };
+
+  const { isOpen, onClose, onOpen } = useEditModal();
+
+  const { error, success } = useNotification();
+
+  const { selectedRows } = useTableStore(state => ({
+    selectedRows: Object.keys(state.rowState)
+      .filter(id => state.rowState[id]?.isSelected)
+      .map(selectedId => data?.nodes?.find(({ id }) => selectedId === id))
+      .filter(Boolean),
+  }));
+
+  const save = async () => {
+    const result = await upsert();
+    if (result?.__typename === 'CampaignNode')
+      success(t('messages.campaign-saved'))();
+    else if ('error' in result)
+      error(
+        `${t('messages.error-saving-campaign')} — ${result?.error?.description ?? ''}`
+      )();
+  };
+
+  const confirmAndDelete = useDeleteConfirmation({
+    selectedRows,
+    deleteAction: async () =>
+      selectedRows.forEach(row => {
+        if (row) {
+          deleteCampaign(row.id);
+        }
path: client/packages/system/src/Manage/Campaigns/ListView/ListView.tsx
user: login: fergie-nz
body: However this will give a confirmation of all successfully deleted if one or more of the rows are successfully deleted.


https://github.com/user-attachments/assets/dcdca105-9b1d-47fe-b335-7cbfcf52806b

But I think you can capture success or failure in bulk with the bulk delete patterns like:

```
  const deleteAction = async () => {
    await Promise.all(selectedRows.map(row => mutateAsync(row.id)))
      .then(() => queryClient.invalidateQueries(api.keys.base()))
      .catch(err => {
        throw err;
      });
  };
  ```
created_at: 2025-05-22T02:31:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1449,6 +1454,10 @@
   "messages.confirm-delete-assets_one": "This will permanently remove this asset.",
   "messages.confirm-delete-assets_other": "This will permanently remove the {{count}} selected assets.",
   "messages.confirm-delete-bundled-item": "This will remove this item from the bundle",
+  "messages.confirm-delete-campaigns_few": "This will permanently remove {{count}} campaign",
+  "messages.confirm-delete-campaigns_many": "This will permanently remove {{count}} campaigns",
+  "messages.confirm-delete-campaigns_one": "This will permanently remove 1 campaign",
+  "messages.confirm-delete-campaigns_other": "This will permanently remove {{count}} campaigns",
path: client/packages/common/src/intl/locales/en/common.json
user: login: CarlosNZ
body: That's a cool idea, it seemed pretty wasteful to me too, but probably beyond the scope of this issue as I'd need to change it in all the places that use it. Do you want to make a re-factor issue to address this more widely?
created_at: 2025-05-23T02:23:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+import React, { FC } from 'react';
+import {
+  useTranslation,
+  DetailContainer,
+  Box,
+  useDialog,
+  DialogButton,
+  InputWithLabelRow,
+  BasicTextInput,
+  DateUtils,
+  DateTimePickerInput,
+} from '@openmsupply-client/common';
+import { DraftCampaign, defaultDraftCampaign } from '../api';
+
+interface CampaignEditModalProps {
+  campaign: DraftCampaign;
+  isOpen: boolean;
+  onClose: () => void;
+  updateDraft: (campaign: Partial<DraftCampaign>) => void;
+  upsert: () => Promise<void>;
+}
+
+export const CampaignEditModal: FC<CampaignEditModalProps> = ({
+  campaign,
+  isOpen,
+  onClose,
+  updateDraft,
+  upsert,
+}) => {
+  const t = useTranslation();
+
+  const { Modal } = useDialog({ isOpen, onClose, disableBackdrop: true });
+
+  const { name, startDate, endDate, id } = campaign;
+
+  return (
+    <Modal
+      title={id ? 'Edit Campaign' : 'Create Campaign'}
+      cancelButton={
+        <DialogButton
+          variant="cancel"
+          onClick={() => {
+            onClose();
+            updateDraft(defaultDraftCampaign);
+          }}
+        />
+      }
+      okButton={
+        <DialogButton
+          variant="ok"
+          onClick={async () => {
+            await upsert();
+            onClose();
+            updateDraft(defaultDraftCampaign);
+          }}
+        />
+      }
+      height={1000}
path: client/packages/system/src/Manage/Campaigns/ListView/CampaignEditModal.tsx
user: login: CarlosNZ
body: 100%, this was leftover from the place I copy-pasted it from 😉

Looks much better now: 

![Screenshot 2025-05-23 at 2 27 02 PM](https://github.com/user-attachments/assets/7809c912-6f4b-4692-b45a-991a8f39608e)

Good catch 👍
created_at: 2025-05-23T02:27:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,149 @@
+import React from 'react';
+import {
+  TableProvider,
+  DataTable,
+  useColumns,
+  createTableStore,
+  NothingHere,
+  useUrlQueryParams,
+  useEditModal,
+  useTranslation,
+  ColumnFormat,
+  CampaignNode,
+  GenericColumnKey,
+  useTableStore,
+  useDeleteConfirmation,
+  useNotification,
+} from '@openmsupply-client/common';
+import { Footer } from './Footer';
+import { CampaignEditModal } from './CampaignEditModal';
+import { AppBarButtons } from './AppBarButtons';
+import { CampaignRowFragment, DraftCampaign, useCampaigns } from '../api';
+
+const CampaignsComponent = () => {
+  const t = useTranslation();
+  const {
+    // filter,
+    updateSortQuery,
+    updatePaginationQuery,
+    queryParams: { sortBy, page, first, offset, filterBy },
+  } = useUrlQueryParams({ initialSort: { key: 'name', dir: 'asc' } });
+
+  const queryParams = { sortBy, first, offset, filterBy };
+  const {
+    query: { data, isError, isLoading },
+    upsert: { upsert },
+    delete: { deleteCampaign },
+    draft,
+    updateDraft,
+  } = useCampaigns(queryParams);
+
+  const pagination = { page, first, offset };
+
+  const { isOpen, onClose, onOpen } = useEditModal();
+
+  const { error, success } = useNotification();
+
+  const { selectedRows } = useTableStore(state => ({
+    selectedRows: Object.keys(state.rowState)
+      .filter(id => state.rowState[id]?.isSelected)
+      .map(selectedId => data?.nodes?.find(({ id }) => selectedId === id))
+      .filter(Boolean),
+  }));
+
+  const save = async () => {
+    const result = await upsert();
+    if (result?.__typename === 'CampaignNode')
+      success(t('messages.campaign-saved'))();
+    else if ('error' in result)
+      error(
+        `${t('messages.error-saving-campaign')} — ${result?.error?.description ?? ''}`
+      )();
+  };
+
+  const confirmAndDelete = useDeleteConfirmation({
+    selectedRows,
+    deleteAction: async () =>
+      selectedRows.forEach(row => {
+        if (row) {
+          deleteCampaign(row.id);
+        }
path: client/packages/system/src/Manage/Campaigns/ListView/ListView.tsx
user: login: CarlosNZ
body: Thanks for that, I've done something similar now. b27dedb792

I'm just using the "confirmAndDelete" hook's built-in toasts now, so it's not perfect, but I think it works for this in the rare event that some lines fail to delete.
created_at: 2025-05-23T03:21:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1449,6 +1454,10 @@
   "messages.confirm-delete-assets_one": "This will permanently remove this asset.",
   "messages.confirm-delete-assets_other": "This will permanently remove the {{count}} selected assets.",
   "messages.confirm-delete-bundled-item": "This will remove this item from the bundle",
+  "messages.confirm-delete-campaigns_few": "This will permanently remove {{count}} campaign",
+  "messages.confirm-delete-campaigns_many": "This will permanently remove {{count}} campaigns",
+  "messages.confirm-delete-campaigns_one": "This will permanently remove 1 campaign",
+  "messages.confirm-delete-campaigns_other": "This will permanently remove {{count}} campaigns",
path: client/packages/common/src/intl/locales/en/common.json
user: login: fergie-nz
body: No worries I can make the issue and see if it gains traction.

Did not mean to imply should be added in this issue - was just jotting down my thoughts :)
created_at: 2025-05-23T04:08:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-21T05:05:46Z
body: <!-- GITHUB_ACTION_COMMENT_PR -->
## Bundle size difference
Comparing this PR to `main`

|Old size      | New size     | Diff                     |
|:------------ |:-------------|:-------------------------|
|6.11 MB         |6.12 MB         | 12.22 KB (0.20%) |

author_association: CONTRIBUTOR
user: login: CarlosNZ
created_at: 2025-05-23T03:21:32Z
body: Thanks for the suggeestions, @fergie-nz, should be all taken care of now :) 
author_association: CONTRIBUTOR