@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
package/src/react/api.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ComboRequirement } from "../engine/types";
|
|
2
|
+
|
|
3
|
+
import type { DiscountsFormatters } from "./format";
|
|
4
|
+
import type { DiscountsResult, DiscountsTransport } from "./transport";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The wire, and the one place the FORM's shape becomes the API's.
|
|
8
|
+
*
|
|
9
|
+
* A form holds strings — every value in a text input is one — while the API
|
|
10
|
+
* takes integers, basis points and nulls. That coercion is a real rule set, not
|
|
11
|
+
* plumbing, and it has exactly one rule worth stating: a BLANK field means "no
|
|
12
|
+
* value" (`null`), never zero, because `0` is a real and different answer for a
|
|
13
|
+
* limit or a minimum. A host repeating this per call site is how one screen
|
|
14
|
+
* starts saving `0` where another saves `null`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A discount as the CRUD endpoints echo it — JSON, so dates are ISO strings. */
|
|
18
|
+
export interface DiscountWireRecord {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
type: string;
|
|
22
|
+
percentOffBp: number | null;
|
|
23
|
+
amountOffCents: number | null;
|
|
24
|
+
bundlePriceCents?: number | null;
|
|
25
|
+
freeUnits?: number | null;
|
|
26
|
+
maxComboApplications?: number | null;
|
|
27
|
+
comboRequirements?: ComboRequirement[];
|
|
28
|
+
scope: string;
|
|
29
|
+
trigger: string;
|
|
30
|
+
code: string | null;
|
|
31
|
+
startsAt: string | null;
|
|
32
|
+
endsAt: string | null;
|
|
33
|
+
minSubtotalCents: number | null;
|
|
34
|
+
usageLimit: number | null;
|
|
35
|
+
perBuyerLimit: number | null;
|
|
36
|
+
usageCount: number;
|
|
37
|
+
stackable: boolean;
|
|
38
|
+
active: boolean;
|
|
39
|
+
categoryIds: string[];
|
|
40
|
+
menuItemIds: string[];
|
|
41
|
+
createdAt: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The pagination envelope the list endpoint answers with. */
|
|
45
|
+
export interface DiscountsPagination {
|
|
46
|
+
page: number;
|
|
47
|
+
pageSize: number;
|
|
48
|
+
total: number;
|
|
49
|
+
pageCount: number;
|
|
50
|
+
hasNextPage: boolean;
|
|
51
|
+
hasPreviousPage: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DiscountsPage {
|
|
55
|
+
data: DiscountWireRecord[];
|
|
56
|
+
pagination: DiscountsPagination;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One selectable target, as `GET /discounts/targets` answers it. */
|
|
60
|
+
export interface WireTarget {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
parentId?: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One registered collection and its rows. */
|
|
67
|
+
export interface WireTargetGroup {
|
|
68
|
+
targetType: string;
|
|
69
|
+
slug: string;
|
|
70
|
+
label: string;
|
|
71
|
+
nests: boolean;
|
|
72
|
+
targets: WireTarget[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The form's payload, still in FORM shape. */
|
|
76
|
+
export interface DiscountFormPayload {
|
|
77
|
+
name: string;
|
|
78
|
+
type: string;
|
|
79
|
+
/** 0–100 with up to two decimals, in the operator's own notation. */
|
|
80
|
+
percentOff: string;
|
|
81
|
+
/** A money amount, as the currency field stores it. */
|
|
82
|
+
amountOff: string;
|
|
83
|
+
/** What the matched combo group costs, as the currency field stores it. */
|
|
84
|
+
bundlePrice: string;
|
|
85
|
+
/** How many of the matched units are free, as typed. */
|
|
86
|
+
freeUnits: string;
|
|
87
|
+
/** How many combos one cart may claim. Blank = as often as it fits. */
|
|
88
|
+
maxComboApplications: string;
|
|
89
|
+
scope: string;
|
|
90
|
+
trigger: string;
|
|
91
|
+
code: string;
|
|
92
|
+
startsAt: string;
|
|
93
|
+
endsAt: string;
|
|
94
|
+
minSubtotal: string;
|
|
95
|
+
usageLimit: string;
|
|
96
|
+
perBuyerLimit: string;
|
|
97
|
+
stackable: boolean;
|
|
98
|
+
active: boolean;
|
|
99
|
+
categoryIds: string[];
|
|
100
|
+
menuItemIds: string[];
|
|
101
|
+
/** The combo's groups, in the operator's own order. Empty at every other scope. */
|
|
102
|
+
comboRequirements: readonly ComboRequirement[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A money amount as integer cents, or null when blank. */
|
|
106
|
+
function toCents(typed: string, formatters: DiscountsFormatters): number | null {
|
|
107
|
+
const amount = formatters.parseDecimal(typed);
|
|
108
|
+
return amount === null ? null : Math.round(amount * 100);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A whole-number cap, or null when blank ("no cap"). */
|
|
112
|
+
function toCount(typed: string, formatters: DiscountsFormatters): number | null {
|
|
113
|
+
const parsed = formatters.parseDecimal(typed);
|
|
114
|
+
return parsed === null ? null : Math.trunc(parsed);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A percentage as BASIS POINTS.
|
|
119
|
+
*
|
|
120
|
+
* The form takes 0–100 with two decimals because that is how an operator thinks
|
|
121
|
+
* about a promotion; the column stores basis points because that is how the
|
|
122
|
+
* engine computes without a float. The `Math.round` after the ×100 is what
|
|
123
|
+
* keeps "12,5" exactly 1250 rather than 1249.9999999999998.
|
|
124
|
+
*/
|
|
125
|
+
function toBasisPoints(typed: string, formatters: DiscountsFormatters): number | null {
|
|
126
|
+
const percent = formatters.parseDecimal(typed);
|
|
127
|
+
return percent === null ? null : Math.round(percent * 100);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Blank → null, so an unused branch's leftover text never reaches a column. */
|
|
131
|
+
function toNullable(value: string): string | null {
|
|
132
|
+
const trimmed = value.trim();
|
|
133
|
+
return trimmed === "" ? null : trimmed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The wire body both writes send — a discount is always saved WHOLE. */
|
|
137
|
+
export function toWriteBody(
|
|
138
|
+
input: DiscountFormPayload,
|
|
139
|
+
formatters: DiscountsFormatters,
|
|
140
|
+
): Record<string, unknown> {
|
|
141
|
+
const isCombo = input.scope === "COMBO";
|
|
142
|
+
return {
|
|
143
|
+
name: input.name.trim(),
|
|
144
|
+
type: input.type,
|
|
145
|
+
// Exactly one value column, chosen by the TYPE, with the other three
|
|
146
|
+
// forced to null. The four are mutually exclusive at the database
|
|
147
|
+
// (`discounts_value_check`), so carrying a leftover from a branch the
|
|
148
|
+
// operator flipped away from is a 500 on a form they filled in correctly —
|
|
149
|
+
// the same reason the server's own folder nulls them rather than passing
|
|
150
|
+
// them through.
|
|
151
|
+
percentOffBp:
|
|
152
|
+
input.type === "PERCENTAGE" ? toBasisPoints(input.percentOff, formatters) : null,
|
|
153
|
+
amountOffCents: input.type === "FIXED_AMOUNT" ? toCents(input.amountOff, formatters) : null,
|
|
154
|
+
bundlePriceCents:
|
|
155
|
+
input.type === "BUNDLE_PRICE" ? toCents(input.bundlePrice, formatters) : null,
|
|
156
|
+
freeUnits: input.type === "FREE_UNITS" ? toCount(input.freeUnits, formatters) : null,
|
|
157
|
+
// The combo SPEC, narrowed to the scope for the reason the id lists below
|
|
158
|
+
// are: a rule whose scope moved off COMBO keeps neither its groups nor its
|
|
159
|
+
// cap, or it would go on being matched as a combo by a screen that no
|
|
160
|
+
// longer shows one.
|
|
161
|
+
comboRequirements: isCombo ? [...input.comboRequirements] : [],
|
|
162
|
+
maxComboApplications: isCombo ? toCount(input.maxComboApplications, formatters) : null,
|
|
163
|
+
scope: input.scope,
|
|
164
|
+
trigger: input.trigger,
|
|
165
|
+
code: input.trigger === "CODE" ? toNullable(input.code) : null,
|
|
166
|
+
startsAt: toNullable(input.startsAt),
|
|
167
|
+
endsAt: toNullable(input.endsAt),
|
|
168
|
+
minSubtotalCents: toCents(input.minSubtotal, formatters),
|
|
169
|
+
usageLimit: toCount(input.usageLimit, formatters),
|
|
170
|
+
perBuyerLimit: toCount(input.perBuyerLimit, formatters),
|
|
171
|
+
stackable: input.stackable,
|
|
172
|
+
active: input.active,
|
|
173
|
+
// Narrowed to the scope on the way OUT as well as on the way in: leaving a
|
|
174
|
+
// stale id list on a rule whose scope changed is how a promotion goes on
|
|
175
|
+
// covering something its screen no longer shows.
|
|
176
|
+
categoryIds: input.scope === "CATEGORY" ? input.categoryIds : [],
|
|
177
|
+
menuItemIds: input.scope === "ITEM" ? input.menuItemIds : [],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The bound wire client the screens use. Nothing else performs I/O. */
|
|
182
|
+
export interface DiscountsApiClient {
|
|
183
|
+
list(search: string): Promise<DiscountsPage>;
|
|
184
|
+
targets(): Promise<readonly WireTargetGroup[]>;
|
|
185
|
+
create(input: DiscountFormPayload): Promise<DiscountsResult<DiscountWireRecord>>;
|
|
186
|
+
update(id: string, input: DiscountFormPayload): Promise<DiscountsResult<{ id: string }>>;
|
|
187
|
+
remove(id: string): Promise<DiscountsResult<{ id: string }>>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createDiscountsApiClient(
|
|
191
|
+
apiBase: string,
|
|
192
|
+
transport: DiscountsTransport,
|
|
193
|
+
formatters: DiscountsFormatters,
|
|
194
|
+
): DiscountsApiClient {
|
|
195
|
+
const collection = `${apiBase}/discounts`;
|
|
196
|
+
const item = (id: string): string => `${collection}/${encodeURIComponent(id)}`;
|
|
197
|
+
return {
|
|
198
|
+
list: (search) => transport.get<DiscountsPage>(search ? `${collection}?${search}` : collection),
|
|
199
|
+
targets: () =>
|
|
200
|
+
transport
|
|
201
|
+
.get<{ data: WireTargetGroup[] }>(`${collection}/targets`)
|
|
202
|
+
.then((payload) => payload.data),
|
|
203
|
+
create: (input) =>
|
|
204
|
+
transport.send<DiscountWireRecord>(collection, "POST", toWriteBody(input, formatters)),
|
|
205
|
+
update: (id, input) =>
|
|
206
|
+
transport.send<{ id: string }>(item(id), "PATCH", toWriteBody(input, formatters)),
|
|
207
|
+
remove: (id) => transport.send<{ id: string }>(item(id), "DELETE"),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@12-apps/ui/form/Button";
|
|
6
|
+
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
7
|
+
import { Input } from "@12-apps/ui/form/Input";
|
|
8
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
9
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
10
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
11
|
+
|
|
12
|
+
import type { ComboRequirement } from "../engine/types";
|
|
13
|
+
|
|
14
|
+
import type { WireTargetGroup } from "./api";
|
|
15
|
+
import { fill, type DiscountsWebCopy } from "./copy";
|
|
16
|
+
import { TargetPickerField } from "./target-picker-field";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The combo builder — how an operator says "2 refrigerantes, 2 hambúrgueres e
|
|
20
|
+
* 2 batatas" (FUT-268).
|
|
21
|
+
*
|
|
22
|
+
* A combo is a list of GROUPS. Each group is a quantity and the rows that can
|
|
23
|
+
* fill it, and a cart matches the combo when every group is satisfiable at
|
|
24
|
+
* once. That shape is already the engine's (`ComboRequirement`), the
|
|
25
|
+
* validator's and the schema's — `discount_combo_slots` with its own targets —
|
|
26
|
+
* so this file adds no concept. It adds the only thing that was missing: a way
|
|
27
|
+
* to author one.
|
|
28
|
+
*
|
|
29
|
+
* Two decisions worth stating, because both are visible in the markup:
|
|
30
|
+
*
|
|
31
|
+
* **A group can name categories AND items at once.** "2 refrigerantes" is a
|
|
32
|
+
* category; "2 batatas médias" is an item; and a real menu mixes them inside
|
|
33
|
+
* one group ("any drink, or specifically the large fries"). So each group gets
|
|
34
|
+
* every registered collection's picker rather than a scope toggle — the union
|
|
35
|
+
* is what `ComboRequirement` already stores, and forcing a choice would make
|
|
36
|
+
* the common offer unexpressible.
|
|
37
|
+
*
|
|
38
|
+
* **Position is the array index, and it is meaningful.** The list an operator
|
|
39
|
+
* builds is the list a card reads back; two groups of the same size are
|
|
40
|
+
* otherwise indistinguishable once stored. Removing a group therefore
|
|
41
|
+
* renumbers the ones after it, which is what an operator expects from a list
|
|
42
|
+
* they are editing — and `toTargetRows` stamps the index as `position` on the
|
|
43
|
+
* way to the database.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** How many units one application of the combo takes out of the cart. */
|
|
47
|
+
export function comboUnits(slots: readonly ComboRequirement[]): number {
|
|
48
|
+
return slots.reduce((total, slot) => total + (Number.isFinite(slot.quantity) ? slot.quantity : 0), 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An empty group: one unit, nothing chosen yet. */
|
|
52
|
+
export function blankSlot(): ComboRequirement {
|
|
53
|
+
return { quantity: 1, categoryIds: [], menuItemIds: [] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Replace one group, leaving the rest (and their order) alone. */
|
|
57
|
+
function replaceAt(
|
|
58
|
+
slots: readonly ComboRequirement[],
|
|
59
|
+
index: number,
|
|
60
|
+
next: ComboRequirement,
|
|
61
|
+
): ComboRequirement[] {
|
|
62
|
+
return slots.map((slot, position) => (position === index ? next : slot));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Which id list of a group a collection reads and writes. */
|
|
66
|
+
function idsFor(slot: ComboRequirement, targetType: string): readonly string[] {
|
|
67
|
+
return targetType === "CATEGORY" ? slot.categoryIds : slot.menuItemIds;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withIds(slot: ComboRequirement, targetType: string, ids: string[]): ComboRequirement {
|
|
71
|
+
return targetType === "CATEGORY" ? { ...slot, categoryIds: ids } : { ...slot, menuItemIds: ids };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One group: its quantity, one picker per registered collection, and the
|
|
76
|
+
* control that removes it.
|
|
77
|
+
*
|
|
78
|
+
* The quantity is a `number` input rather than a masked one — it counts units,
|
|
79
|
+
* not money, so there is no locale in it. It is held as a NUMBER in state
|
|
80
|
+
* rather than as the form's string, because the summary line above adds them up
|
|
81
|
+
* and the validator compares them against `freeUnits`; parsing on every read
|
|
82
|
+
* would put the same `Number()` in three places.
|
|
83
|
+
*/
|
|
84
|
+
function ComboSlotRow({
|
|
85
|
+
slot,
|
|
86
|
+
index,
|
|
87
|
+
groups,
|
|
88
|
+
copy,
|
|
89
|
+
onChange,
|
|
90
|
+
onRemove,
|
|
91
|
+
}: {
|
|
92
|
+
slot: ComboRequirement;
|
|
93
|
+
index: number;
|
|
94
|
+
groups: readonly WireTargetGroup[];
|
|
95
|
+
copy: DiscountsWebCopy;
|
|
96
|
+
onChange: (next: ComboRequirement) => void;
|
|
97
|
+
onRemove: () => void;
|
|
98
|
+
}): JSX.Element {
|
|
99
|
+
const position = index + 1;
|
|
100
|
+
const empty = slot.categoryIds.length === 0 && slot.menuItemIds.length === 0;
|
|
101
|
+
return (
|
|
102
|
+
<Box
|
|
103
|
+
data-testid={`combo-slot-${index}`}
|
|
104
|
+
sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 2 }}
|
|
105
|
+
>
|
|
106
|
+
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
|
107
|
+
<Text weight="medium">{fill(copy.combo.slot, { position })}</Text>
|
|
108
|
+
<Button
|
|
109
|
+
variant="text"
|
|
110
|
+
onClick={onRemove}
|
|
111
|
+
// The label carries the POSITION, so a screen reader announces which
|
|
112
|
+
// group this removes — "remove" alone is three identical buttons.
|
|
113
|
+
aria-label={fill(copy.combo.removeSlot, { position })}
|
|
114
|
+
data-testid={`combo-slot-${index}-remove`}
|
|
115
|
+
>
|
|
116
|
+
✕
|
|
117
|
+
</Button>
|
|
118
|
+
</Stack>
|
|
119
|
+
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
120
|
+
<Input
|
|
121
|
+
type="number"
|
|
122
|
+
label={copy.combo.quantity}
|
|
123
|
+
value={String(slot.quantity)}
|
|
124
|
+
onChange={(event) => onChange({ ...slot, quantity: Number(event.target.value) })}
|
|
125
|
+
data-testid={`combo-slot-${index}-quantity`}
|
|
126
|
+
/>
|
|
127
|
+
{groups.map((group) => (
|
|
128
|
+
<TargetPickerField
|
|
129
|
+
key={group.targetType}
|
|
130
|
+
group={group}
|
|
131
|
+
label={fill(copy.combo.pick, { collection: group.label })}
|
|
132
|
+
placeholder={fill(copy.targets.search, { collection: group.label })}
|
|
133
|
+
// The refusal belongs to the GROUP, not to either picker: a group
|
|
134
|
+
// naming a category is complete even with no items in it. So the
|
|
135
|
+
// message hangs below both, once, and only when both are empty.
|
|
136
|
+
requiredMessage={undefined}
|
|
137
|
+
ids={idsFor(slot, group.targetType)}
|
|
138
|
+
onChange={(ids) => onChange(withIds(slot, group.targetType, ids))}
|
|
139
|
+
dataTestId={`combo-slot-${index}-${group.slug}`}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
{empty && (
|
|
143
|
+
<FormMessage error dataTestId={`combo-slot-${index}-message`}>
|
|
144
|
+
{copy.form.comboSlotTargetRequired}
|
|
145
|
+
</FormMessage>
|
|
146
|
+
)}
|
|
147
|
+
</Stack>
|
|
148
|
+
</Box>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The whole builder: the heading, the groups, the add button and the read-back.
|
|
154
|
+
*
|
|
155
|
+
* The read-back line is not decoration. A combo is the one promotion whose
|
|
156
|
+
* meaning is spread across several controls, and "the combo takes 6 items in 3
|
|
157
|
+
* groups" is the sentence that catches a mistyped quantity before a save — the
|
|
158
|
+
* same number `freeUnits` is validated against, computed once and shown.
|
|
159
|
+
*/
|
|
160
|
+
export function ComboSlotBuilder({
|
|
161
|
+
slots,
|
|
162
|
+
groups,
|
|
163
|
+
copy,
|
|
164
|
+
onChange,
|
|
165
|
+
}: {
|
|
166
|
+
slots: readonly ComboRequirement[];
|
|
167
|
+
groups: readonly WireTargetGroup[];
|
|
168
|
+
copy: DiscountsWebCopy;
|
|
169
|
+
onChange: (next: ComboRequirement[]) => void;
|
|
170
|
+
}): JSX.Element {
|
|
171
|
+
return (
|
|
172
|
+
<FormControl fullWidth>
|
|
173
|
+
<FormLabel>{copy.combo.title}</FormLabel>
|
|
174
|
+
<Text variant="caption">{copy.combo.hint}</Text>
|
|
175
|
+
<Stack spacing={2} sx={{ mt: 1 }} data-testid="combo-slots">
|
|
176
|
+
{slots.map((slot, index) => (
|
|
177
|
+
<ComboSlotRow
|
|
178
|
+
// The index IS the identity here: `position` is the array index,
|
|
179
|
+
// and a group carries no id of its own until the database gives it
|
|
180
|
+
// one. Removing a group renumbers the rest, which is exactly what
|
|
181
|
+
// the operator sees happen.
|
|
182
|
+
key={index}
|
|
183
|
+
slot={slot}
|
|
184
|
+
index={index}
|
|
185
|
+
groups={groups}
|
|
186
|
+
copy={copy}
|
|
187
|
+
onChange={(next) => onChange(replaceAt(slots, index, next))}
|
|
188
|
+
onRemove={() => onChange(slots.filter((_, position) => position !== index))}
|
|
189
|
+
/>
|
|
190
|
+
))}
|
|
191
|
+
{slots.length === 0 && (
|
|
192
|
+
<Text variant="caption" data-testid="combo-slots-empty">
|
|
193
|
+
{copy.combo.empty}
|
|
194
|
+
</Text>
|
|
195
|
+
)}
|
|
196
|
+
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
|
|
197
|
+
<Button
|
|
198
|
+
variant="outline"
|
|
199
|
+
onClick={() => onChange([...slots, blankSlot()])}
|
|
200
|
+
data-testid="combo-add-slot"
|
|
201
|
+
>
|
|
202
|
+
{copy.combo.addSlot}
|
|
203
|
+
</Button>
|
|
204
|
+
{slots.length > 0 && (
|
|
205
|
+
<Text variant="caption" data-testid="combo-summary">
|
|
206
|
+
{fill(copy.combo.summary, { units: comboUnits(slots), groups: slots.length })}
|
|
207
|
+
</Text>
|
|
208
|
+
)}
|
|
209
|
+
</Stack>
|
|
210
|
+
</Stack>
|
|
211
|
+
</FormControl>
|
|
212
|
+
);
|
|
213
|
+
}
|