@12-apps/discounts 1.2.0 → 1.4.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.
- package/README.md +231 -16
- package/package.json +55 -6
- package/prisma/discounts.prisma +181 -0
- package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
- package/scripts/sync-discounts-schema.mjs +53 -0
- package/src/engine/kinds.ts +62 -0
- package/src/index.ts +5 -0
- package/src/manifest/index.ts +45 -17
- package/src/manifest/web.ts +55 -0
- package/src/react/api.ts +209 -0
- package/src/react/combo-slot-builder.tsx +213 -0
- package/src/react/copy.ts +296 -0
- package/src/react/create-web-discounts.tsx +162 -0
- package/src/react/discount-actions-menu.tsx +127 -0
- package/src/react/discount-card.tsx +92 -0
- package/src/react/discount-form-fields.tsx +245 -0
- package/src/react/discount-form.tsx +393 -0
- package/src/react/discount-list-card.tsx +199 -0
- package/src/react/discount-target-picker.tsx +114 -0
- package/src/react/discounts-screen.tsx +314 -0
- package/src/react/format.ts +126 -0
- package/src/react/index.ts +72 -0
- package/src/react/pt-BR.ts +157 -0
- package/src/react/row.ts +66 -0
- package/src/react/table-config.ts +238 -0
- package/src/react/target-picker-field.tsx +138 -0
- package/src/react/transport.ts +115 -0
- package/src/react/use-discounts-data.ts +103 -0
- package/src/server/collections.ts +290 -0
- package/src/server/copy.ts +12 -0
- package/src/server/index.ts +23 -0
- package/src/server/logging.ts +20 -3
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +74 -9
- package/src/server/targets.ts +102 -0
|
@@ -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,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { useFormContext } from "@12-apps/ui/form/total-form";
|
|
6
|
+
|
|
7
|
+
import type { ComboRequirement } from "../engine/types";
|
|
8
|
+
|
|
9
|
+
import type { WireTargetGroup } from "./api";
|
|
10
|
+
import { ComboSlotBuilder } from "./combo-slot-builder";
|
|
11
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
12
|
+
import { TargetPickerField } from "./target-picker-field";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a scoped discount points at, picked from the host's own collections.
|
|
16
|
+
*
|
|
17
|
+
* This file is the ROUTER: it reads the scope and decides which shape the
|
|
18
|
+
* targets take. The controls themselves live next door — one collection's
|
|
19
|
+
* picker in `target-picker-field`, the quantified group list in
|
|
20
|
+
* `combo-slot-builder` — because the combo builder needs the former per group
|
|
21
|
+
* and a file cannot import its own importer.
|
|
22
|
+
*
|
|
23
|
+
* Which collections exist is not decided here. The surface reads them from
|
|
24
|
+
* `GET /discounts/targets`, which answers whatever the host registered — so
|
|
25
|
+
* adding a third discountable dimension is a host-side registration and this
|
|
26
|
+
* file does not change.
|
|
27
|
+
*
|
|
28
|
+
* The selection is held by the PARENT form, outside `FormContainer`, and merged
|
|
29
|
+
* in at submit: `total-form` values are strings and an id list is not one.
|
|
30
|
+
*
|
|
31
|
+
* The whole block renders `null` at ORDER scope. An order-wide discount covers
|
|
32
|
+
* everything, so a target list there is not "empty" — it is meaningless, and
|
|
33
|
+
* leaving a stale one on screen would suggest otherwise.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** What the form currently has chosen, in every shape a scope can need. */
|
|
37
|
+
interface DiscountTargetSelection {
|
|
38
|
+
categoryIds: string[];
|
|
39
|
+
menuItemIds: string[];
|
|
40
|
+
/** The combo's groups, in the operator's own order. Empty at every other scope. */
|
|
41
|
+
comboRequirements: readonly ComboRequirement[];
|
|
42
|
+
onCategoryIdsChange: (next: string[]) => void;
|
|
43
|
+
onMenuItemIdsChange: (next: string[]) => void;
|
|
44
|
+
onComboRequirementsChange: (next: ComboRequirement[]) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Which selection a dimension reads and writes. */
|
|
48
|
+
function bind(
|
|
49
|
+
targetType: string,
|
|
50
|
+
selection: DiscountTargetSelection,
|
|
51
|
+
): { ids: string[]; onChange: (next: string[]) => void } {
|
|
52
|
+
if (targetType === "CATEGORY") {
|
|
53
|
+
return { ids: selection.categoryIds, onChange: selection.onCategoryIdsChange };
|
|
54
|
+
}
|
|
55
|
+
return { ids: selection.menuItemIds, onChange: selection.onMenuItemIdsChange };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The scope a collection's picker is shown at. */
|
|
59
|
+
function scopeFor(targetType: string): string {
|
|
60
|
+
return targetType === "CATEGORY" ? "CATEGORY" : "ITEM";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The scope-driven target block.
|
|
65
|
+
*
|
|
66
|
+
* Reads the LIVE `scope` from the form context, so flipping the toggle swaps
|
|
67
|
+
* the picker immediately without the parent form having to mirror the value
|
|
68
|
+
* into its own state.
|
|
69
|
+
*
|
|
70
|
+
* COMBO is the third branch (FUT-268) and it is a different SHAPE, not another
|
|
71
|
+
* picker: a combo is a list of quantified groups, so it gets the builder and
|
|
72
|
+
* every collection at once, while CATEGORY and ITEM each get exactly one
|
|
73
|
+
* collection's picker. ORDER still renders nothing — an order-wide discount
|
|
74
|
+
* covers everything, so a target list there is not empty, it is meaningless.
|
|
75
|
+
*/
|
|
76
|
+
export function DiscountTargetPicker({
|
|
77
|
+
groups,
|
|
78
|
+
copy,
|
|
79
|
+
selection,
|
|
80
|
+
}: {
|
|
81
|
+
groups: readonly WireTargetGroup[];
|
|
82
|
+
copy: DiscountsWebCopy;
|
|
83
|
+
selection: DiscountTargetSelection;
|
|
84
|
+
}): JSX.Element | null {
|
|
85
|
+
const { values } = useFormContext();
|
|
86
|
+
const scope = values.scope ?? "ORDER";
|
|
87
|
+
|
|
88
|
+
if (scope === "COMBO") {
|
|
89
|
+
return (
|
|
90
|
+
<ComboSlotBuilder
|
|
91
|
+
slots={selection.comboRequirements}
|
|
92
|
+
groups={groups}
|
|
93
|
+
copy={copy}
|
|
94
|
+
onChange={selection.onComboRequirementsChange}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const group = groups.find((entry) => scopeFor(entry.targetType) === scope);
|
|
100
|
+
if (!group) return null;
|
|
101
|
+
|
|
102
|
+
const { ids, onChange } = bind(group.targetType, selection);
|
|
103
|
+
return (
|
|
104
|
+
<TargetPickerField
|
|
105
|
+
group={group}
|
|
106
|
+
label={fill(copy.targets.pick, { collection: group.label })}
|
|
107
|
+
placeholder={fill(copy.targets.search, { collection: group.label })}
|
|
108
|
+
requiredMessage={copy.targets.required}
|
|
109
|
+
ids={ids}
|
|
110
|
+
onChange={onChange}
|
|
111
|
+
dataTestId={`discount-${group.slug}-targets`}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -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
|
+
}
|