@12-apps/discounts 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import type { JSX } from "react";
4
+
5
+ import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined";
6
+
7
+ import {
8
+ BodyHeading,
9
+ DetailColumns,
10
+ Fact,
11
+ TagList,
12
+ type KindListCardProps,
13
+ } from "@12-apps/ui/data-display/CardKit";
14
+ import { Chip } from "@12-apps/ui/data-display/Chip";
15
+ import {
16
+ BaseListCard,
17
+ type ListCardCellConfig,
18
+ } from "@12-apps/ui/data-display/DataViews";
19
+
20
+ import type { WireTarget, WireTargetGroup } from "./api";
21
+ import { fill, type DiscountsWebCopy } from "./copy";
22
+ import { DiscountActionsMenu, type DiscountActionsMenuProps } from "./discount-actions-menu";
23
+ import { EMPTY, type DiscountsFormatters } from "./format";
24
+ import type { DiscountListItem } from "./row";
25
+
26
+ /**
27
+ * One rule as a full-width row, with an expanded body.
28
+ *
29
+ * The reason this layout exists at all is the target list: a `string[]` has no
30
+ * honest table cell. Truncated to "3 items" it says nothing; printed in full it
31
+ * destroys the column. In an expanded body it simply wraps.
32
+ */
33
+
34
+ /** `RUNNING` is the only good one; the other two are why a rule is not firing. */
35
+ const WINDOW_COLOR: Record<string, "success" | "info" | "neutral"> = {
36
+ RUNNING: "success",
37
+ SCHEDULED: "info",
38
+ ENDED: "neutral",
39
+ };
40
+
41
+ /**
42
+ * Target ids resolved to names, falling back to the raw ID.
43
+ *
44
+ * The id IS the honest fallback. A rule can name a row that has since been
45
+ * deleted, and dropping it would show a scope of "Category" over an empty list
46
+ * — which reads as "applies to nothing" when it in fact still applies to
47
+ * whatever that id is.
48
+ */
49
+ function targetNames(
50
+ ids: readonly string[],
51
+ groups: readonly WireTargetGroup[] | undefined,
52
+ targetType: string,
53
+ ): string[] {
54
+ const options: WireTarget[] =
55
+ groups?.find((group) => group.targetType === targetType)?.targets ?? [];
56
+ if (options.length === 0) return [...ids];
57
+ return ids.map((id) => options.find((option) => option.id === id)?.name ?? id);
58
+ }
59
+
60
+ /** How many rows this rule is pointed at, given its scope. */
61
+ function targetCount(row: DiscountListItem): number {
62
+ if (row.scope === "CATEGORY") return row.categoryIds.length;
63
+ if (row.scope === "ITEM") return row.menuItemIds.length;
64
+ return 0;
65
+ }
66
+
67
+ /**
68
+ * Declared HERE and handed to the list's own config, and kept on the card too.
69
+ *
70
+ * Not redundancy. Inside a list group the group's config wins and the card's is
71
+ * ignored — that is what makes the columns line up by construction. Outside
72
+ * one, the card's own config is the only thing it has: a standalone row with
73
+ * neither falls back to the named slots and renders no summary at all.
74
+ */
75
+ export function discountCells(copy: DiscountsWebCopy): ListCardCellConfig<DiscountListItem>[] {
76
+ return [
77
+ {
78
+ id: "discount",
79
+ primary: (row) => row.name,
80
+ secondary: (row) =>
81
+ row.code == null
82
+ ? (copy.labels.trigger[row.trigger as keyof typeof copy.labels.trigger] ?? row.trigger)
83
+ : fill(copy.card.withCode, { code: row.code }),
84
+ },
85
+ {
86
+ id: "where",
87
+ primary: (row) => copy.labels.scope[row.scope as keyof typeof copy.labels.scope] ?? row.scope,
88
+ secondary: (row) => {
89
+ if (row.scope === "ORDER") return copy.card.wholeOrder;
90
+ const count = targetCount(row);
91
+ return count === 1 ? copy.card.oneTarget : fill(copy.card.manyTargets, { count });
92
+ },
93
+ },
94
+ {
95
+ id: "window",
96
+ primary: (row) =>
97
+ copy.labels.window[row.windowState] ?? row.windowState,
98
+ secondary: (row) => row.windowLabel,
99
+ },
100
+ {
101
+ id: "value",
102
+ align: "end",
103
+ width: "max-content",
104
+ strong: true,
105
+ primary: (row) => row.valueLabel,
106
+ },
107
+ ];
108
+ }
109
+
110
+ /** The rule side: when it fires, how often, and whether it is running. */
111
+ function RuleFacts({
112
+ row,
113
+ copy,
114
+ formatters,
115
+ }: {
116
+ row: DiscountListItem;
117
+ copy: DiscountsWebCopy;
118
+ formatters: DiscountsFormatters;
119
+ }): JSX.Element {
120
+ const cap = row.usageLimit === null ? copy.card.unlimited : String(row.usageLimit);
121
+ return (
122
+ <>
123
+ <BodyHeading>{copy.card.ruleHeading}</BodyHeading>
124
+ <Fact
125
+ label={copy.screen.columns.window}
126
+ value={
127
+ <Chip
128
+ label={copy.labels.window[row.windowState] ?? row.windowState}
129
+ size="sm"
130
+ variant={row.windowState === "RUNNING" ? "filled" : "outlined"}
131
+ color={WINDOW_COLOR[row.windowState] ?? "neutral"}
132
+ />
133
+ }
134
+ />
135
+ <Fact label={copy.card.usage} value={`${row.usageCount} / ${cap}`} />
136
+ <Fact label={copy.card.minSubtotal} value={formatters.money(row.minSubtotalCents)} />
137
+ <Fact
138
+ label={copy.card.perBuyerLimit}
139
+ value={row.perBuyerLimit === null ? copy.card.unlimited : String(row.perBuyerLimit)}
140
+ />
141
+ <Fact label={copy.screen.columns.code} value={row.code ?? EMPTY} />
142
+ </>
143
+ );
144
+ }
145
+
146
+ /** The targets side: what it actually covers. */
147
+ function TargetFacts({
148
+ row,
149
+ copy,
150
+ groups,
151
+ }: {
152
+ row: DiscountListItem;
153
+ copy: DiscountsWebCopy;
154
+ groups: readonly WireTargetGroup[] | undefined;
155
+ }): JSX.Element {
156
+ const ids = row.scope === "CATEGORY" ? row.categoryIds : row.menuItemIds;
157
+ const names =
158
+ row.scope === "ORDER" ? [] : targetNames(ids, groups, row.scope === "CATEGORY" ? "CATEGORY" : "ITEM");
159
+ return (
160
+ <>
161
+ <BodyHeading>{copy.card.targetsHeading}</BodyHeading>
162
+ {row.scope === "ORDER" ? (
163
+ <Fact label={copy.screen.columns.scope} value={copy.card.wholeOrder} />
164
+ ) : (
165
+ <TagList items={names} empty={copy.card.noTargets} />
166
+ )}
167
+ </>
168
+ );
169
+ }
170
+
171
+ type DiscountListCardProps = KindListCardProps &
172
+ Omit<DiscountActionsMenuProps, "row"> & {
173
+ row: DiscountListItem;
174
+ copy: DiscountsWebCopy;
175
+ };
176
+
177
+ export function DiscountListCard({
178
+ row,
179
+ selection,
180
+ copy,
181
+ ...menu
182
+ }: DiscountListCardProps): JSX.Element {
183
+ return (
184
+ <BaseListCard
185
+ testId={`discount-list-card-${row.id}`}
186
+ selected={selection.selected}
187
+ onToggleSelect={selection.onToggleSelect}
188
+ cells={discountCells(copy) as readonly ListCardCellConfig<never>[]}
189
+ row={row}
190
+ leading={<LocalOfferOutlinedIcon sx={{ fontSize: 22, opacity: 0.5 }} />}
191
+ menu={<DiscountActionsMenu row={row} copy={copy} {...menu} />}
192
+ >
193
+ <DetailColumns
194
+ left={<RuleFacts row={row} copy={copy} formatters={menu.formatters} />}
195
+ right={<TargetFacts row={row} copy={copy} groups={menu.groups} />}
196
+ />
197
+ </BaseListCard>
198
+ );
199
+ }
@@ -0,0 +1,159 @@
1
+ "use client";
2
+
3
+ import { useState, type JSX } from "react";
4
+
5
+ import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
6
+ import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
7
+ import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
8
+ import { useFormContext } from "@12-apps/ui/form/total-form";
9
+
10
+ import { fill, type DiscountsWebCopy } from "./copy";
11
+ import type { WireTarget, WireTargetGroup } from "./api";
12
+
13
+ /**
14
+ * What a scoped discount points at, picked from the host's own collections.
15
+ *
16
+ * There is no shared multi-select in `@12-apps/ui`, so this composes
17
+ * `Autocomplete` in multiple mode — chips plus type-to-filter, the same control
18
+ * `AddressAutocomplete` is built on. A collection that NESTS gets
19
+ * `CategorySelect` instead: a flat combobox would put a subcategory beside its
20
+ * parent with nothing to tell them apart.
21
+ *
22
+ * Which collections exist is not decided here. The surface reads them from
23
+ * `GET /discounts/targets`, which answers whatever the host registered — so
24
+ * adding a third discountable dimension is a host-side registration and this
25
+ * file does not change.
26
+ *
27
+ * The selection is held by the PARENT form, outside `FormContainer`, and merged
28
+ * in at submit: `total-form` values are strings and an id list is not one.
29
+ *
30
+ * The whole block renders `null` at ORDER scope. An order-wide discount covers
31
+ * everything, so a target list there is not "empty" — it is meaningless, and
32
+ * leaving a stale one on screen would suggest otherwise.
33
+ */
34
+
35
+ /** One labelled multi-select of targets, with its own input-text state. */
36
+ function TargetField({
37
+ label,
38
+ placeholder,
39
+ requiredMessage,
40
+ options,
41
+ ids,
42
+ onChange,
43
+ dataTestId,
44
+ }: {
45
+ label: string;
46
+ placeholder: string;
47
+ requiredMessage: string;
48
+ options: WireTarget[];
49
+ ids: readonly string[];
50
+ onChange: (next: string[]) => void;
51
+ dataTestId: string;
52
+ }): JSX.Element {
53
+ const [query, setQuery] = useState("");
54
+ const byId = new Map(options.map((option) => [option.id, option]));
55
+ // An id whose option is missing — a target archived since the rule was saved
56
+ // — still renders as a chip, so saving cannot silently drop it.
57
+ const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
58
+
59
+ return (
60
+ <FormControl fullWidth>
61
+ <FormLabel error={ids.length === 0}>{label}</FormLabel>
62
+ <div data-testid={dataTestId}>
63
+ <Autocomplete<WireTarget>
64
+ multiple
65
+ value={query}
66
+ onChange={setQuery}
67
+ suggestions={options}
68
+ selectedItems={selected}
69
+ onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
70
+ getKey={(option) => option.id}
71
+ getLabel={(option) => option.name}
72
+ placeholder={placeholder}
73
+ inputAriaLabel={label}
74
+ />
75
+ </div>
76
+ {ids.length === 0 && (
77
+ <FormMessage error dataTestId={`${dataTestId}-message`}>
78
+ {requiredMessage}
79
+ </FormMessage>
80
+ )}
81
+ </FormControl>
82
+ );
83
+ }
84
+
85
+ /** The ids currently chosen for one dimension. */
86
+ interface DiscountTargetSelection {
87
+ categoryIds: string[];
88
+ menuItemIds: string[];
89
+ onCategoryIdsChange: (next: string[]) => void;
90
+ onMenuItemIdsChange: (next: string[]) => void;
91
+ }
92
+
93
+ /** Which selection a dimension reads and writes. */
94
+ function bind(
95
+ targetType: string,
96
+ selection: DiscountTargetSelection,
97
+ ): { ids: string[]; onChange: (next: string[]) => void } {
98
+ if (targetType === "CATEGORY") {
99
+ return { ids: selection.categoryIds, onChange: selection.onCategoryIdsChange };
100
+ }
101
+ return { ids: selection.menuItemIds, onChange: selection.onMenuItemIdsChange };
102
+ }
103
+
104
+ /** The scope a collection's picker is shown at. */
105
+ function scopeFor(targetType: string): string {
106
+ return targetType === "CATEGORY" ? "CATEGORY" : "ITEM";
107
+ }
108
+
109
+ /**
110
+ * The scope-driven target block.
111
+ *
112
+ * Reads the LIVE `scope` from the form context, so flipping the toggle swaps
113
+ * the picker immediately without the parent form having to mirror the value
114
+ * into its own state.
115
+ */
116
+ export function DiscountTargetPicker({
117
+ groups,
118
+ copy,
119
+ selection,
120
+ }: {
121
+ groups: readonly WireTargetGroup[];
122
+ copy: DiscountsWebCopy;
123
+ selection: DiscountTargetSelection;
124
+ }): JSX.Element | null {
125
+ const { values } = useFormContext();
126
+ const scope = values.scope ?? "ORDER";
127
+ const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
128
+ if (!group) return null;
129
+
130
+ const { ids, onChange } = bind(group.targetType, selection);
131
+ const label = fill(copy.targets.pick, { collection: group.label });
132
+ const dataTestId = `discount-${group.slug}-targets`;
133
+
134
+ if (group.nests) {
135
+ return (
136
+ <CategorySelect
137
+ label={label}
138
+ fullWidth
139
+ allowParentSelection
140
+ options={group.targets}
141
+ value={ids}
142
+ onChange={onChange}
143
+ error={ids.length === 0 ? copy.targets.required : undefined}
144
+ dataTestId={dataTestId}
145
+ />
146
+ );
147
+ }
148
+ return (
149
+ <TargetField
150
+ label={label}
151
+ placeholder={fill(copy.targets.search, { collection: group.label })}
152
+ requiredMessage={copy.targets.required}
153
+ options={group.targets}
154
+ ids={ids}
155
+ onChange={onChange}
156
+ dataTestId={dataTestId}
157
+ />
158
+ );
159
+ }
@@ -0,0 +1,314 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState, type JSX } from "react";
4
+ import { useSearchParams } from "react-router-dom";
5
+
6
+ import AddIcon from "@mui/icons-material/Add";
7
+
8
+ import { useServerDataViews } from "@12-apps/app-shell/react";
9
+ import { CardActionsProvider, useRowConfirm } from "@12-apps/ui/data-display/CardKit";
10
+ import { DataViewsGrid, type DataViewState } from "@12-apps/ui/data-display/DataViews";
11
+ import type { RowAction } from "@12-apps/ui/data-display/DataViews";
12
+ import { ErrorState } from "@12-apps/ui/data-display/ErrorState";
13
+ import { LoadingState } from "@12-apps/ui/data-display/LoadingState";
14
+ import { Dialog, DialogContent } from "@12-apps/ui/feedback/Dialog";
15
+ import { HeaderButton } from "@12-apps/ui/form/HeaderButton";
16
+ import { Dashboard } from "@12-apps/ui/layout/Dashboard";
17
+ import { Text } from "@12-apps/ui/typography/Text";
18
+ import { exportRows } from "@12-apps/ui/utils";
19
+
20
+ import type { DiscountsApiClient } from "./api";
21
+ import { fill, type DiscountsWebCopy } from "./copy";
22
+ import { DiscountActionsMenu } from "./discount-actions-menu";
23
+ import { DiscountCard } from "./discount-card";
24
+ import { DiscountForm } from "./discount-form";
25
+ import type { CurrencyFieldComponent } from "./discount-form-fields";
26
+ import { DiscountListCard, discountCells } from "./discount-list-card";
27
+ import type { DiscountsFormatters } from "./format";
28
+ import { toListItem, type DiscountListItem } from "./row";
29
+ import {
30
+ discountColumns,
31
+ discountExportColumns,
32
+ discountFilters,
33
+ discountsAppliedState,
34
+ discountsQueryToParams,
35
+ discountsSearch,
36
+ } from "./table-config";
37
+ import { useDiscountsData } from "./use-discounts-data";
38
+
39
+ /**
40
+ * The promotions list: a grid in SERVER mode — the URL params ARE the query —
41
+ * a create dialog in the header, and a self-contained menu per row and card.
42
+ *
43
+ * No saved views, deliberately: a saved-view scope is a host registry entry and
44
+ * a database constraint, and a package cannot add either.
45
+ */
46
+
47
+ export interface DiscountsScreenProps {
48
+ api: DiscountsApiClient;
49
+ copy: DiscountsWebCopy;
50
+ formatters: DiscountsFormatters;
51
+ currencyField: CurrencyFieldComponent;
52
+ onError: (error: unknown, context: string) => void;
53
+ /** The crumbs above the title. The host owns its own information hierarchy. */
54
+ breadcrumb?: readonly { label: string; href?: string }[];
55
+ }
56
+
57
+ /** The bulk delete's confirmation, described from the SELECTION. */
58
+ function useBulkDelete(
59
+ api: DiscountsApiClient,
60
+ copy: DiscountsWebCopy,
61
+ refresh: () => void,
62
+ onError: (error: unknown, context: string) => void,
63
+ ) {
64
+ return useRowConfirm<DiscountListItem>({
65
+ write: async (rows) => {
66
+ const results = await Promise.all(rows.map((row) => api.remove(row.id)));
67
+ const failed = results.find((result) => !result.ok);
68
+ if (failed && !failed.ok) {
69
+ onError(failed, "discounts.delete");
70
+ // Throwing is what keeps the popup open carrying the message; the
71
+ // selection is still there to retry.
72
+ throw new Error(failed.error);
73
+ }
74
+ refresh();
75
+ },
76
+ describe: (rows) =>
77
+ rows.length === 1
78
+ ? {
79
+ title: copy.actions.deleteTitle,
80
+ entityName: rows[0]?.name,
81
+ description: copy.actions.deleteDescription,
82
+ confirmText: copy.actions.delete,
83
+ }
84
+ : {
85
+ title: fill(copy.actions.deleteManyTitle, { count: rows.length }),
86
+ description: copy.actions.deleteManyDescription,
87
+ confirmText: copy.actions.delete,
88
+ },
89
+ errorText: copy.actions.deleteFailed,
90
+ dataTestId: "discount-bulk-delete-confirm",
91
+ });
92
+ }
93
+
94
+ /** The header's controls: the explainer, the export, and the create button. */
95
+ function HeaderControls({
96
+ rows,
97
+ copy,
98
+ onCreate,
99
+ }: {
100
+ rows: DiscountListItem[];
101
+ copy: DiscountsWebCopy;
102
+ onCreate: () => void;
103
+ }): JSX.Element {
104
+ return (
105
+ <>
106
+ <Dashboard.Info title={copy.screen.aboutTitle}>{copy.screen.aboutBody}</Dashboard.Info>
107
+ <Dashboard.Spacer />
108
+ <Dashboard.Export
109
+ formats={[
110
+ { id: "csv", label: "CSV (.csv)" },
111
+ { id: "json", label: "JSON (.json)" },
112
+ ]}
113
+ onExport={(format) =>
114
+ exportRows(
115
+ format === "json" ? "json" : "csv",
116
+ rows,
117
+ discountExportColumns(copy),
118
+ copy.screen.exportFileName,
119
+ )
120
+ }
121
+ />
122
+ <Dashboard.Action>
123
+ <HeaderButton
124
+ text={copy.screen.create}
125
+ icon={<AddIcon fontSize="small" />}
126
+ onClick={onCreate}
127
+ dataTestId="new-discount-button"
128
+ />
129
+ </Dashboard.Action>
130
+ </>
131
+ );
132
+ }
133
+
134
+ /** Everything the row menu, both cards and the edit form are bound to. */
135
+ type MenuBinding = Omit<Parameters<typeof DiscountActionsMenu>[0], "row">;
136
+
137
+ /**
138
+ * The grid, split from the page container so neither crosses the size gate.
139
+ *
140
+ * The row menu, the tile and the list row all receive the SAME binding, which
141
+ * is what keeps an edit opened from any of the three offering the same pickers.
142
+ */
143
+ function DiscountsGrid({
144
+ rows,
145
+ copy,
146
+ menu,
147
+ appliedState,
148
+ server,
149
+ onVisibleRowsChange,
150
+ bulkDelete,
151
+ }: {
152
+ rows: DiscountListItem[];
153
+ copy: DiscountsWebCopy;
154
+ menu: MenuBinding;
155
+ appliedState: DataViewState;
156
+ server: ReturnType<typeof useServerDataViews>;
157
+ onVisibleRowsChange: (next: DiscountListItem[]) => void;
158
+ bulkDelete: ReturnType<typeof useBulkDelete>;
159
+ }): JSX.Element {
160
+ return (
161
+ <>
162
+ <DataViewsGrid<DiscountListItem>
163
+ inlineFilters
164
+ rows={rows}
165
+ columns={discountColumns(copy)}
166
+ fields={discountFilters(copy)}
167
+ appliedState={appliedState}
168
+ getRowId={(row) => row.id}
169
+ onVisibleRowsChange={onVisibleRowsChange}
170
+ dataTestId="discounts-grid"
171
+ testIdPrefix="discounts"
172
+ rowActions={
173
+ [
174
+ {
175
+ id: "delete",
176
+ label: copy.actions.delete,
177
+ color: "danger",
178
+ onSelect: (selected) => bulkDelete.request(selected),
179
+ },
180
+ ] satisfies RowAction<DiscountListItem>[]
181
+ }
182
+ renderRowMenu={(row) => <DiscountActionsMenu row={row} {...menu} />}
183
+ renderCard={(row, selection) => (
184
+ <DiscountCard row={row} selection={selection} {...menu} />
185
+ )}
186
+ renderListRow={(row, selection) => (
187
+ <DiscountListCard row={row} selection={selection} {...menu} />
188
+ )}
189
+ listGroup={{ cells: discountCells(copy) }}
190
+ server={server}
191
+ emptyState={
192
+ <Text variant="body" as="p">
193
+ {copy.screen.empty}
194
+ </Text>
195
+ }
196
+ />
197
+ {bulkDelete.dialog}
198
+ </>
199
+ );
200
+ }
201
+
202
+ /** The create dialog, mounted only while open so the next open re-seeds empty. */
203
+ function CreateDialog({
204
+ open,
205
+ onClose,
206
+ onSaved,
207
+ menu,
208
+ }: {
209
+ open: boolean;
210
+ onClose: () => void;
211
+ onSaved: () => void;
212
+ menu: MenuBinding;
213
+ }): JSX.Element {
214
+ return (
215
+ <Dialog
216
+ open={open}
217
+ onClose={onClose}
218
+ title={menu.copy.form.createTitle}
219
+ size="md"
220
+ showCloseButton
221
+ dataTestId="discount-dialog"
222
+ >
223
+ {open && (
224
+ <DialogContent>
225
+ <DiscountForm
226
+ {...menu}
227
+ groups={menu.groups ?? []}
228
+ editing={null}
229
+ onSaved={onSaved}
230
+ />
231
+ </DialogContent>
232
+ )}
233
+ </Dialog>
234
+ );
235
+ }
236
+
237
+ export function DiscountsScreen(props: DiscountsScreenProps): JSX.Element {
238
+ const { api, copy, formatters, currencyField, onError, breadcrumb } = props;
239
+ const [searchParams] = useSearchParams();
240
+ const data = useDiscountsData(api, discountsSearch(searchParams), onError);
241
+ const [createOpen, setCreateOpen] = useState(false);
242
+ const [visibleRows, setVisibleRows] = useState<DiscountListItem[]>([]);
243
+ const bulkDelete = useBulkDelete(api, copy, data.refresh, onError);
244
+
245
+ // ONE instant for the whole render: two rows judged against two different
246
+ // "now"s could disagree about which of them is still running.
247
+ const rows = useMemo(() => {
248
+ const now = new Date();
249
+ return (data.page?.data ?? []).map((record) => toListItem(record, formatters, copy, now));
250
+ }, [data.page, formatters, copy]);
251
+
252
+ // Seeded ONCE from the URL. Re-applying it on every render would wipe the
253
+ // operator's column-visibility choices each time the query synced back.
254
+ const [appliedState] = useState<DataViewState>(() => discountsAppliedState(searchParams, copy));
255
+
256
+ const server = useServerDataViews({
257
+ totalCount: data.page?.pagination.total ?? 0,
258
+ page: data.page?.pagination.page ?? 1,
259
+ pageSize: data.page?.pagination.pageSize ?? 20,
260
+ toParams: discountsQueryToParams,
261
+ });
262
+
263
+ const menu: MenuBinding = { api, copy, formatters, currencyField, groups: data.groups, onError };
264
+
265
+ if (data.loading) return <LoadingState dataTestId="discounts-loading" />;
266
+ if (data.error !== null) {
267
+ return (
268
+ <ErrorState
269
+ title={copy.screen.loadFailed}
270
+ message={data.error}
271
+ retryLabel={copy.screen.retry}
272
+ onRetry={data.refresh}
273
+ />
274
+ );
275
+ }
276
+
277
+ return (
278
+ <Dashboard testIdPrefix="discounts-dashboard">
279
+ {breadcrumb && <Dashboard.Breadcrumb items={[...breadcrumb]} />}
280
+ <Dashboard.Header title={copy.screen.title}>
281
+ <HeaderControls rows={visibleRows} copy={copy} onCreate={() => setCreateOpen(true)} />
282
+ </Dashboard.Header>
283
+ <Dashboard.Body>
284
+ <CreateDialog
285
+ open={createOpen}
286
+ menu={menu}
287
+ onClose={() => setCreateOpen(false)}
288
+ onSaved={() => {
289
+ setCreateOpen(false);
290
+ data.refresh();
291
+ }}
292
+ />
293
+ <CardActionsProvider
294
+ // The menus read the refresh and the error channel from here; the
295
+ // tenant is already baked into `apiBase`, so there is nothing for a
296
+ // slug to do but be a second place it could disagree.
297
+ tenantSlug=""
298
+ onRefresh={data.refresh}
299
+ errorTitle={copy.actions.actionFailed}
300
+ >
301
+ <DiscountsGrid
302
+ rows={rows}
303
+ copy={copy}
304
+ menu={menu}
305
+ appliedState={appliedState}
306
+ server={server}
307
+ onVisibleRowsChange={setVisibleRows}
308
+ bulkDelete={bulkDelete}
309
+ />
310
+ </CardActionsProvider>
311
+ </Dashboard.Body>
312
+ </Dashboard>
313
+ );
314
+ }