@feedmepos/mf-order-setting 0.0.79 → 0.0.80

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.
Files changed (35) hide show
  1. package/dist/{DisplaySystemView-BKTYE0GI.js → DisplaySystemView-B4Ja1kBe.js} +2 -2
  2. package/dist/{KioskDevicesView-B3AWg9QR.js → KioskDevicesView-jYkKGyRX.js} +1 -1
  3. package/dist/{KioskDevicesView.vue_vue_type_script_setup_true_lang-s79NGSsG.js → KioskDevicesView.vue_vue_type_script_setup_true_lang-Wicz8r3Q.js} +4 -4
  4. package/dist/{KioskSettingView-D8-2hQT9.js → KioskSettingView-CDO8VdiK.js} +4 -4
  5. package/dist/{KioskView-CrbUYY75.js → KioskView-Dex_N5wi.js} +6 -6
  6. package/dist/{OrderSettingsView-BPv13A7B.js → OrderSettingsView-D4YqwLji.js} +9728 -9551
  7. package/dist/{app-rzXWOYA2.js → app-DxJcADSR.js} +296 -236
  8. package/dist/app.js +1 -1
  9. package/dist/{firebase-storage-BTc4aPcq.js → firebase-storage-GDIc9Lze.js} +1 -1
  10. package/dist/frontend/mf-order/src/app.d.ts +60 -0
  11. package/dist/frontend/mf-order/src/main.d.ts +60 -0
  12. package/dist/frontend/mf-order/src/stores/order-setting/index.d.ts +27 -0
  13. package/dist/frontend/mf-order/src/views/order-settings/queue/code-setting-band.d.ts +19 -0
  14. package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
  15. package/dist/{index-BQLc4wu7.js → index-BOw_SLS3.js} +1 -1
  16. package/dist/{index-DMgNRp_k.js → index-BP2ZDwqX.js} +2 -2
  17. package/dist/{index-1TKkR2Bs.js → index-CTRKXPzk.js} +3 -3
  18. package/dist/{index-BoJCd-Jk.js → index-Dl-iHqtg.js} +1 -1
  19. package/dist/{order.dto-Ck2nw6IY.js → order.dto-BkJSHx_a.js} +197 -170
  20. package/dist/package/entity/order-setting/order-setting.do.d.ts +79 -0
  21. package/dist/package/entity/order-setting/order-setting.dto.d.ts +158 -0
  22. package/dist/package/entity/order-setting/queue/queue.do.d.ts +158 -0
  23. package/dist/package/entity/order-setting/queue/queue.dto.d.ts +62 -0
  24. package/dist/package/entity/queue/queue.dto.d.ts +62 -0
  25. package/dist/package/entity/restaurant/restaurant.dto.d.ts +158 -0
  26. package/package.json +1 -1
  27. package/src/locales/en-US.json +15 -0
  28. package/src/locales/ja-JP.json +15 -0
  29. package/src/locales/th-TH.json +16 -1
  30. package/src/locales/zh-CN.json +16 -1
  31. package/src/views/order-settings/queue/QueueSetting.vue +294 -2
  32. package/src/views/order-settings/queue/code-setting-band.spec.ts +69 -0
  33. package/src/views/order-settings/queue/code-setting-band.ts +27 -0
  34. package/src/views/order-settings/queue/queue-wait-estimate-dto.spec.ts +131 -0
  35. package/vite.config.ts +1 -1
@@ -15,9 +15,10 @@ import { useCoreStore, useI18n } from '@feedmepos/mf-common'
15
15
  import { useSnackbarFunctions } from '@/components/snackbar'
16
16
  import { useDialog } from '@feedmepos/ui-library'
17
17
  import PresetRemarksSheet from './PresetRemarksSheet.vue'
18
+ import { nextCodeSettingBand } from './code-setting-band'
18
19
 
19
20
  const { t } = useI18n()
20
- const { showSuccess } = useSnackbarFunctions()
21
+ const { showSuccess, showError } = useSnackbarFunctions()
21
22
  const { currentRestaurant } = useCoreStore()
22
23
  const { isLoading, startAsyncCallWithErr, startLoading } = useLoading()
23
24
 
@@ -49,6 +50,11 @@ async function readOrderSetting(): Promise<OrderSettingsDto> {
49
50
  }
50
51
 
51
52
  async function updateOrderSetting() {
53
+ const problem = validateEstimates()
54
+ if (problem) {
55
+ showError(problem)
56
+ return
57
+ }
52
58
  await startAsyncCallWithErr(async () => {
53
59
  if (orderSettingDefault.value) {
54
60
  await OrderSettingApi.updateOrderSetting(
@@ -69,6 +75,7 @@ async function init() {
69
75
  orderSettingDefault.value = await readOrderSetting()
70
76
  if (orderSettingDefault.value?.queueSettings) {
71
77
  queueSettings.value = { ...defaultQueueSetting, ...orderSettingDefault.value.queueSettings }
78
+ normalizeEstimates()
72
79
  }
73
80
 
74
81
  // Load restaurant settings for preset remarks
@@ -94,12 +101,19 @@ function addCodeSetting() {
94
101
  if (!queueSettings.value.codeSettings) {
95
102
  queueSettings.value.codeSettings = []
96
103
  }
104
+ // Continue from the previous segment instead of defaulting to 1-99 again.
105
+ // A party is assigned to a segment by FIRST match, so two segments whose
106
+ // bands overlap are not two lines the server can tell apart: everything the
107
+ // earlier band covers is issued its prefix, and the customer's "parties
108
+ // ahead" would count a series they are not in. Seeding disjoint keeps the
109
+ // config in the shape the position count assumes.
110
+ const previous = queueSettings.value.codeSettings.at(-1)
97
111
  queueSettings.value.codeSettings.push({
98
112
  start: 0,
99
113
  end: 100,
100
114
  current: 0,
101
115
  prefix: '',
102
- pax: { min: 1, max: 99 }
116
+ pax: nextCodeSettingBand(previous?.pax)
103
117
  })
104
118
  }
105
119
 
@@ -107,6 +121,178 @@ function removeCodeSetting(index: number) {
107
121
  queueSettings.value.codeSettings?.splice(index, 1)
108
122
  }
109
123
 
124
+ // ---- Wait estimate -------------------------------------------------------
125
+ //
126
+ // One band table per code setting: "groups ahead in line" -> minutes. There is
127
+ // deliberately no party-size axis inside it. A code setting already carries a
128
+ // pax range, so subdividing pax AGAIN in here duplicated that partition and
129
+ // read as if each row were a separate line — it is not. Position is counted
130
+ // over the whole line (`beforeInLine`) and the auto-call picker is global FIFO,
131
+ // so a large party that arrives fourth is still called fourth.
132
+ //
133
+ // A merchant who wants different estimates per party size adds a SECOND CODE
134
+ // SETTING with its own pax range; that segment carries its own band table.
135
+ // Which buys different minutes and its own code series — not a separate line.
136
+ //
137
+ // `upTo: null` on the last band means "no upper limit", so every position
138
+ // resolves. `paxUpTo` stays on the stored row, pinned to null: the DTO still
139
+ // has the field, and keeping it null is what makes `resolveWaitEstimate`'s
140
+ // unknown-pax fallback land here.
141
+
142
+ function estimateRows(index: number) {
143
+ return (queueSettings.value.codeSettings?.[index] as any)?.waitEstimate ?? []
144
+ }
145
+
146
+ /**
147
+ * The single row being edited.
148
+ *
149
+ * Tolerates a legacy multi-row config by preferring the open-ended row: that is
150
+ * the row `resolveWaitEstimate` falls back to, so it is the one whose numbers
151
+ * were actually reaching customers. `normalizeEstimates` collapses to it on
152
+ * load, so this only matters for the render between load and collapse.
153
+ */
154
+ function estimateRow(index: number): any | null {
155
+ const rows = estimateRows(index)
156
+ if (!rows.length) return null
157
+ return rows.find((r: any) => r?.paxUpTo === null) ?? rows[rows.length - 1]
158
+ }
159
+
160
+ function estimateBands(index: number): any[] {
161
+ return estimateRow(index)?.bands ?? []
162
+ }
163
+
164
+ /**
165
+ * Collapse any stored config to the single open-ended row this editor supports.
166
+ *
167
+ * Called on load. The party-size axis is gone, so a multi-row config cannot be
168
+ * represented — and silently editing only one row while saving all of them
169
+ * would write back numbers the merchant could not see.
170
+ */
171
+ function normalizeEstimates() {
172
+ for (const setting of (queueSettings.value.codeSettings ?? []) as any[]) {
173
+ const rows = setting?.waitEstimate
174
+ if (!Array.isArray(rows) || rows.length <= 1) continue
175
+ const keep = rows.find((r: any) => r?.paxUpTo === null) ?? rows[rows.length - 1]
176
+ setting.waitEstimate = [{ paxUpTo: null, bands: keep?.bands ?? [] }]
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Lower edge of a band, derived from its predecessor's bound.
182
+ *
183
+ * Only the upper bound is stored — bands are contiguous, which validation
184
+ * enforces. Showing "3-4" rather than "Up to 4" saves the reader inferring
185
+ * where the band starts.
186
+ */
187
+ function bandRangeStart(index: number, b: number): number {
188
+ if (b === 0) return 1
189
+ return (estimateBands(index)[b - 1]?.upTo ?? 0) + 1
190
+ }
191
+
192
+ function enableEstimate(index: number) {
193
+ const setting = queueSettings.value.codeSettings?.[index] as any
194
+ if (!setting) return
195
+ // A single open-ended band: the smallest configuration that is already valid.
196
+ // Seeded above zero so the row is valid on sight; `minutes: 0` would now be
197
+ // rejected on save, and a merchant should not have to discover that.
198
+ setting.waitEstimate = [{ paxUpTo: null, bands: [{ upTo: null, minutes: 5 }] }]
199
+ }
200
+
201
+ function disableEstimate(index: number) {
202
+ const setting = queueSettings.value.codeSettings?.[index] as any
203
+ if (!setting) return
204
+ // Explicit empty array, not undefined: the backend preserves an omitted
205
+ // waitEstimate, so clearing has to be stated to take effect.
206
+ setting.waitEstimate = []
207
+ }
208
+
209
+ function addEstimateBand(index: number) {
210
+ const row = estimateRow(index)
211
+ if (!row?.bands?.length) return
212
+ const last = row.bands[row.bands.length - 1]
213
+ // Seed from the PREVIOUS band's bound, not the band count. Counting produced
214
+ // a bound unrelated to what the merchant typed — with band 0 set to 50, the
215
+ // next seeded to 10 and rendered as "51-10", which save then rejected.
216
+ const prevBound = row.bands.length > 1 ? row.bands[row.bands.length - 2].upTo : null
217
+ last.upTo = last.upTo ?? (prevBound ?? 0) + 5
218
+ row.bands.push({ upTo: null, minutes: last.minutes })
219
+ }
220
+
221
+ function removeEstimateBand(index: number, bandIndex: number) {
222
+ const row = estimateRow(index)
223
+ if (!row?.bands) return
224
+ row.bands.splice(bandIndex, 1)
225
+ // The final band is always the open-ended one, whichever band that now is.
226
+ if (row.bands.length) row.bands[row.bands.length - 1].upTo = null
227
+ }
228
+
229
+ function updateBandBound(index: number, bandIndex: number, value: string) {
230
+ const row = estimateRow(index)
231
+ if (row?.bands?.[bandIndex]) row.bands[bandIndex].upTo = value === '' ? null : Number(value)
232
+ }
233
+
234
+ function updateEstimateMinutes(index: number, bandIndex: number, value: string) {
235
+ const row = estimateRow(index)
236
+ if (row?.bands?.[bandIndex]) row.bands[bandIndex].minutes = value === '' ? 0 : Number(value)
237
+ }
238
+
239
+ /**
240
+ * Save-time validation. Returns the first problem found, or null.
241
+ *
242
+ * Deliberately blocks the save rather than warning: a band table with a gap or
243
+ * a missing open-ended tail resolves to no estimate for some customers, which
244
+ * looks like the feature is broken rather than misconfigured.
245
+ */
246
+ function validateEstimates(): string | null {
247
+ const settings = queueSettings.value.codeSettings ?? []
248
+ for (const [i, setting] of settings.entries()) {
249
+ const rows = (setting as any).waitEstimate
250
+ if (!rows?.length) continue
251
+
252
+ const label = (setting.prefix || `#${i + 1}`) as string
253
+ const bands = rows[0]?.bands ?? []
254
+ if (!bands.length) return t('order.waitEstimateNoBands', { segment: label })
255
+
256
+ let prevBound = -1
257
+ let prevMinutes = -1
258
+ for (const [b, band] of bands.entries()) {
259
+ const isLast = b === bands.length - 1
260
+ // The open-ended band must be last and nowhere else, or some positions
261
+ // resolve to nothing while others match two bands.
262
+ if (isLast !== (band.upTo === null)) {
263
+ return t('order.waitEstimateBandTail', { segment: label })
264
+ }
265
+ if (band.upTo !== null) {
266
+ // `minutes` already gets this; `upTo` only got the ascending compare.
267
+ // The server schema is z.number().int().nonnegative().nullable(), so
268
+ // `2.5` passed here and then failed the save with a raw 422 the
269
+ // merchant cannot act on. `0` also passed, because prevBound starts at
270
+ // -1, and rendered as the empty band "1-0".
271
+ if (!Number.isInteger(band.upTo) || band.upTo < 1) {
272
+ return t('order.waitEstimateBandOrder', { segment: label })
273
+ }
274
+ if (band.upTo <= prevBound) return t('order.waitEstimateBandOrder', { segment: label })
275
+ prevBound = band.upTo
276
+ }
277
+ if (!Number.isInteger(band.minutes) || band.minutes < 1) {
278
+ // Was `< 0`, which accepted 0. `enableEstimate` seeds a single band, so
279
+ // a merchant could flip the master switch, add an estimate, save
280
+ // without editing, and publish "0+ min" to every customer with
281
+ // `ahead > 0` — a fabricated number, which is the outcome the rest of
282
+ // this feature is careful to avoid. Silence beats a wrong estimate, so
283
+ // an unedited estimate must not save.
284
+ return t('order.waitEstimateMinutesInvalid', { segment: label })
285
+ }
286
+ // More groups ahead is never faster.
287
+ if (band.minutes < prevMinutes) {
288
+ return t('order.waitEstimateMinutesOrder', { segment: label })
289
+ }
290
+ prevMinutes = band.minutes
291
+ }
292
+ }
293
+ return null
294
+ }
295
+
110
296
  function updateCodeSetting(index: number, key: string, value: any) {
111
297
  if (queueSettings.value.codeSettings && queueSettings.value.codeSettings[index]) {
112
298
  const setting = queueSettings.value.codeSettings[index] as any
@@ -243,6 +429,18 @@ async function saveAllowOverrideQueueCode(value: boolean) {
243
429
  label-placement="right"
244
430
  />
245
431
 
432
+ <!--
433
+ Master switch. Bands are configured per code setting below; this
434
+ decides whether customers see any of them. Off by default, because
435
+ with nothing configured there is no defensible number to show.
436
+ -->
437
+ <FmSwitch
438
+ :model-value="queueSettings.showWaitEstimate ?? false"
439
+ @update:model-value="(v: boolean) => updateQueueSetting('showWaitEstimate', v)"
440
+ :label="t('order.showWaitEstimate')"
441
+ label-placement="right"
442
+ />
443
+
246
444
  <div class="flex flex-col gap-2">
247
445
  <span class="fm-typo-en-body-md-600">{{ t('order.autoSendKitchen') }}</span>
248
446
  <FmSelect
@@ -372,6 +570,100 @@ async function saveAllowOverrideQueueCode(value: boolean) {
372
570
  />
373
571
  </div>
374
572
  </div>
573
+
574
+ <!--
575
+ Wait estimate for this code setting: "groups ahead in line" ->
576
+ minutes. One vertical list, no party-size axis — see the script
577
+ block for why. The last band is always open-ended, so every
578
+ position resolves to something.
579
+ -->
580
+ <div class="border-t border-gray-200 pt-4 flex flex-col gap-3">
581
+ <!-- gap + min-w-0 on the text column and shrink-0 on the button: the
582
+ hint is a full sentence, and without these it pushed the button
583
+ until its own label truncated to "Clear...". -->
584
+ <div class="flex justify-between items-start gap-4">
585
+ <div class="flex flex-col min-w-0">
586
+ <span class="fm-typo-en-body-md-600">{{ t('order.waitEstimate') }}</span>
587
+ <span class="text-xs text-gray-500">{{ t('order.waitEstimateHint') }}</span>
588
+ </div>
589
+ <FmButton
590
+ v-if="!estimateBands(index).length"
591
+ variant="secondary"
592
+ class="shrink-0"
593
+ :label="t('order.waitEstimateEnable')"
594
+ icon="add"
595
+ @click="enableEstimate(index)"
596
+ />
597
+ <FmButton
598
+ v-else
599
+ variant="secondary"
600
+ color="error"
601
+ class="shrink-0"
602
+ :label="t('order.waitEstimateClear')"
603
+ @click="disableEstimate(index)"
604
+ />
605
+ </div>
606
+
607
+ <!-- Vertical, one band per line. A single axis does not need a
608
+ table: this drops the horizontal scroller, the pinned first
609
+ column and the hover-revealed controls the two-axis grid
610
+ required, and it grows downward instead of sideways. -->
611
+ <div v-if="estimateBands(index).length" class="flex flex-col">
612
+ <div class="flex items-center gap-3 text-xs text-gray-500 pb-1">
613
+ <span class="w-[150px] shrink-0">{{ t('order.waitEstimateAheadHeader') }}</span>
614
+ <span>{{ t('order.waitEstimateMinutesHeader') }}</span>
615
+ </div>
616
+ <div
617
+ v-for="(band, b) in estimateBands(index)"
618
+ :key="b"
619
+ class="flex items-center gap-3 py-2 border-t border-gray-200"
620
+ >
621
+ <div class="w-[150px] shrink-0 flex items-center gap-0.5">
622
+ <template v-if="band.upTo !== null">
623
+ <span class="whitespace-nowrap">{{ bandRangeStart(index, b) }}–</span>
624
+ <FmTextField
625
+ type="number"
626
+ class="w-[70px]"
627
+ :model-value="band.upTo"
628
+ @update:model-value="(v: string) => updateBandBound(index, b, v)"
629
+ />
630
+ </template>
631
+ <span v-else class="whitespace-nowrap">
632
+ {{ bandRangeStart(index, b) }}+
633
+ </span>
634
+ </div>
635
+ <div class="flex items-center gap-1">
636
+ <FmTextField
637
+ type="number"
638
+ class="w-[80px]"
639
+ :model-value="band.minutes"
640
+ @update:model-value="(v: string) => updateEstimateMinutes(index, b, v)"
641
+ />
642
+ <span class="text-xs text-gray-500">{{ t('order.waitEstimateMinutesUnit') }}</span>
643
+ </div>
644
+ <!-- Always visible: the two-axis grid hid these until hover to
645
+ save width, which left them invisible but still tappable on
646
+ touch. With one column per row there is room to just show
647
+ them. -->
648
+ <FmButton
649
+ v-if="estimateBands(index).length > 1"
650
+ color="error"
651
+ icon="close"
652
+ :aria-label="t('order.waitEstimateRemoveBand')"
653
+ :title="t('order.waitEstimateRemoveBand')"
654
+ @click="removeEstimateBand(index, b)"
655
+ />
656
+ </div>
657
+ </div>
658
+ <FmButton
659
+ v-if="estimateBands(index).length"
660
+ variant="secondary"
661
+ icon="add"
662
+ class="self-start"
663
+ :label="t('order.waitEstimateAddBand')"
664
+ @click="addEstimateBand(index)"
665
+ />
666
+ </div>
375
667
  </div>
376
668
  </div>
377
669
  </div>
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { nextCodeSettingBand, MAX_PAX } from './code-setting-band'
3
+
4
+ /**
5
+ * Guards the config shape the customer position count assumes.
6
+ *
7
+ * Segments are matched FIRST-match, so overlapping pax bands are not two lines
8
+ * the server can tell apart. The previous default seeded every new segment as
9
+ * pax 1-99, which meant a merchant who narrowed segment 0 to 1-4 and then
10
+ * clicked "add segment" produced exactly the overlap that makes a large party's
11
+ * "parties ahead" count the small-party series too.
12
+ */
13
+ describe('nextCodeSettingBand', () => {
14
+ it('starts the first segment at 1', () => {
15
+ expect(nextCodeSettingBand(null)).toEqual({ min: 1, max: 99 })
16
+ expect(nextCodeSettingBand(undefined)).toEqual({ min: 1, max: 99 })
17
+ })
18
+
19
+ it('starts after the previous segment, never overlapping it', () => {
20
+ expect(nextCodeSettingBand({ min: 1, max: 4 })).toEqual({ min: 5, max: 99 })
21
+ })
22
+
23
+ it('runs the tail segment to the ceiling, leaving no pax uncovered', () => {
24
+ // A naive max(min, 99) gives 100-100 here, so pax 101+ would match no
25
+ // segment at all and get orphaned onto its own line.
26
+ expect(nextCodeSettingBand({ min: 1, max: 99 })).toEqual({ min: 100, max: MAX_PAX })
27
+ })
28
+
29
+ it('leaves no gap between consecutive bands', () => {
30
+ const first = nextCodeSettingBand(null)
31
+ const second = nextCodeSettingBand(first)
32
+ expect(second.min).toBe(first.max + 1)
33
+ })
34
+
35
+ it('never proposes a min above the pax ceiling', () => {
36
+ const band = nextCodeSettingBand({ min: 1, max: MAX_PAX + 500 })
37
+ expect(band.min).toBe(MAX_PAX)
38
+ expect(band.max).toBeGreaterThanOrEqual(band.min)
39
+ })
40
+
41
+ it('chains disjointly while pax range remains', () => {
42
+ // 1-99 then 100-999 covers the realistic case: small parties, large
43
+ // parties. Two segments is what the pax ceiling affords disjointly.
44
+ const first = nextCodeSettingBand(null)
45
+ const second = nextCodeSettingBand(first)
46
+ const overlaps = second.min <= first.max && second.max >= first.min
47
+ expect(overlaps).toBe(false)
48
+ expect([first, second]).toEqual([
49
+ { min: 1, max: 99 },
50
+ { min: 100, max: MAX_PAX }
51
+ ])
52
+ })
53
+
54
+ it('clamps once the pax range is exhausted, and that is safe', () => {
55
+ // A third segment cannot be disjoint — there is no pax left above 999. It
56
+ // clamps to the ceiling and therefore overlaps the segment before it.
57
+ //
58
+ // Deliberately not treated as a failure. Under FIRST-match the earlier
59
+ // segment claims the whole overlap, so the third simply never issues a
60
+ // code, and `sameSegmentFilter` subtracts preceding bands, so the customer
61
+ // count stays correct regardless. The outcome is a dead segment the
62
+ // merchant can see and edit, not a wrong number — which is exactly why the
63
+ // backend defends against overlap instead of trusting this helper.
64
+ const second = nextCodeSettingBand(nextCodeSettingBand(null))
65
+ const third = nextCodeSettingBand(second)
66
+ expect(third.min).toBe(MAX_PAX)
67
+ expect(third.min <= second.max).toBe(true)
68
+ })
69
+ })
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Where a newly added queue segment's pax band should start.
3
+ *
4
+ * A party is assigned to a segment by FIRST match — `getCodeAndUpdateSetting`
5
+ * uses `findIndex`, `matchSegment` uses `find` — so overlapping bands are not
6
+ * two lines the server can distinguish: everything the earlier band covers is
7
+ * issued the earlier prefix, and the customer's "parties ahead" would count a
8
+ * series they are not in. The backend defends against overlap it finds in
9
+ * stored data, but the UI should not mint it in the first place.
10
+ *
11
+ * This used to seed `{ min: 1, max: 99 }` for every segment, so a merchant who
12
+ * narrowed segment 0 and then clicked "add" got a total overlap by default.
13
+ */
14
+ export const MAX_PAX = 999;
15
+
16
+ export type PaxBand = { min: number; max: number };
17
+
18
+ export function nextCodeSettingBand(previous?: PaxBand | null): PaxBand {
19
+ if (!previous) return { min: 1, max: 99 };
20
+ const min = Math.min(previous.max + 1, MAX_PAX);
21
+ // Run the tail segment up to the ceiling rather than stopping at 99. A naive
22
+ // `max(min, 99)` yields the single-pax band 100-100 after a 1-99 first
23
+ // segment, which leaves pax 101+ matching NO segment - and a party matching
24
+ // nothing is orphaned onto its own line by `segmentScopeOf`. Covering to the
25
+ // ceiling keeps the configuration total.
26
+ return { min, max: min <= 99 ? 99 : MAX_PAX };
27
+ }
@@ -0,0 +1,131 @@
1
+ import { OrderSettingUpdateDto } from '@entity'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ /**
5
+ * mf-order saves order settings by running `OrderSettingUpdateDto.parse(...)` on the
6
+ * CLIENT and PUTting the result. Zod's default mode is 'strip', so anything the
7
+ * compiled-in schema does not know about is deleted before the request is sent —
8
+ * silently, and on every save.
9
+ *
10
+ * That is the whole reason `codeSettings[].id` exists and the reason the backend
11
+ * re-attaches `waitEstimate` from storage. These tests pin the client half of that
12
+ * contract: if `waitEstimate` / `id` / `showWaitEstimate` were added to the DO but
13
+ * not reached the DTO this view parses with, the merchant's configuration would
14
+ * never leave the browser and no backend test would notice.
15
+ */
16
+
17
+ const bands = [
18
+ { upTo: 5, minutes: 15 },
19
+ { upTo: 20, minutes: 30 },
20
+ { upTo: null, minutes: 45 }
21
+ ]
22
+
23
+ /** The shape `updateOrderSetting()` builds: whole settings + the edited queueSettings. */
24
+ function payload(overrides: Record<string, unknown> = {}) {
25
+ return {
26
+ queueSettings: {
27
+ enableOrder: true,
28
+ autoCalledLimit: 0,
29
+ duration: 20,
30
+ showWaitEstimate: true,
31
+ codeSettings: [
32
+ {
33
+ id: '6a8541df1ab7f55006729a0a',
34
+ prefix: 'A',
35
+ start: 1,
36
+ end: 99,
37
+ current: 42,
38
+ pax: { min: 1, max: 4 },
39
+ waitEstimate: [{ paxUpTo: null, bands }]
40
+ }
41
+ ]
42
+ },
43
+ ...overrides
44
+ }
45
+ }
46
+
47
+ describe('OrderSettingUpdateDto — the client-side round trip mf-order actually performs', () => {
48
+ it('keeps waitEstimate through a parse, rather than stripping it', () => {
49
+ const parsed: any = OrderSettingUpdateDto.parse(payload())
50
+ const segment = parsed.queueSettings.codeSettings[0]
51
+
52
+ expect(segment.waitEstimate).toEqual([{ paxUpTo: null, bands }])
53
+ })
54
+
55
+ it('keeps the stable segment id, which is what preservation matches on', () => {
56
+ const parsed: any = OrderSettingUpdateDto.parse(payload())
57
+
58
+ expect(parsed.queueSettings.codeSettings[0].id).toBe('6a8541df1ab7f55006729a0a')
59
+ })
60
+
61
+ it('keeps the showWaitEstimate master switch', () => {
62
+ const parsed: any = OrderSettingUpdateDto.parse(payload())
63
+
64
+ expect(parsed.queueSettings.showWaitEstimate).toBe(true)
65
+ })
66
+
67
+ it('preserves an empty waitEstimate array — that is how a merchant clears one', () => {
68
+ const p = payload()
69
+ p.queueSettings.codeSettings[0].waitEstimate = []
70
+
71
+ const parsed: any = OrderSettingUpdateDto.parse(p)
72
+ expect(parsed.queueSettings.codeSettings[0].waitEstimate).toEqual([])
73
+ })
74
+
75
+ /**
76
+ * The editor no longer offers a party-size axis — it writes exactly one row,
77
+ * `paxUpTo: null`. The SCHEMA still accepts several, deliberately: that is what
78
+ * makes a legacy multi-row document loadable rather than a parse error, and it
79
+ * is why `normalizeEstimates` exists to collapse one on load. If this ever
80
+ * starts failing, a legacy config would 422 on save instead of being migrated.
81
+ */
82
+ it('still accepts a legacy multi-row estimate, so old configs can be loaded', () => {
83
+ const p = payload()
84
+ p.queueSettings.codeSettings[0].waitEstimate = [
85
+ { paxUpTo: 2, bands: [{ upTo: null, minutes: 10 }] },
86
+ { paxUpTo: null, bands: [{ upTo: null, minutes: 40 }] }
87
+ ] as any
88
+
89
+ const parsed: any = OrderSettingUpdateDto.parse(p)
90
+ expect(parsed.queueSettings.codeSettings[0].waitEstimate).toHaveLength(2)
91
+ expect(parsed.queueSettings.codeSettings[0].waitEstimate[0].paxUpTo).toBe(2)
92
+ })
93
+
94
+ it('round-trips the single open-ended row the editor now writes', () => {
95
+ const p = payload()
96
+ p.queueSettings.codeSettings[0].waitEstimate = [
97
+ {
98
+ paxUpTo: null,
99
+ bands: [
100
+ { upTo: 2, minutes: 10 },
101
+ { upTo: 4, minutes: 15 },
102
+ { upTo: null, minutes: 15 }
103
+ ]
104
+ }
105
+ ] as any
106
+
107
+ const parsed: any = OrderSettingUpdateDto.parse(p)
108
+ const rows = parsed.queueSettings.codeSettings[0].waitEstimate
109
+ expect(rows).toHaveLength(1)
110
+ expect(rows[0].paxUpTo).toBeNull()
111
+ expect(rows[0].bands.map((b: any) => [b.upTo, b.minutes])).toEqual([
112
+ [2, 10],
113
+ [4, 15],
114
+ [null, 15]
115
+ ])
116
+ })
117
+
118
+ /**
119
+ * The hazard itself, demonstrated rather than asserted about. An unknown key is
120
+ * dropped without error — which is exactly what an older portal bundle does to
121
+ * `waitEstimate`, and why the backend cannot treat an absent field as "clear it".
122
+ */
123
+ it('silently drops unknown keys, which is why absent must mean inherit', () => {
124
+ const p: any = payload()
125
+ p.queueSettings.codeSettings[0].somethingTheBundleDoesNotKnow = 'gone'
126
+
127
+ const parsed: any = OrderSettingUpdateDto.parse(p)
128
+ expect(parsed.queueSettings.codeSettings[0].somethingTheBundleDoesNotKnow).toBeUndefined()
129
+ expect(parsed.queueSettings.codeSettings[0].waitEstimate).toBeDefined()
130
+ })
131
+ })
package/vite.config.ts CHANGED
@@ -18,7 +18,7 @@ export default defineConfig(async (env) => {
18
18
  plugins: [vue()],
19
19
  resolve: {
20
20
  preserveSymlinks: true,
21
- dedupe: ['vue', 'pinia', 'vue-i18n'],
21
+ dedupe: ['vue', 'pinia', 'vue-router', 'vue-i18n'],
22
22
  alias: {
23
23
  '@': fileURLToPath(new URL('./src', import.meta.url)),
24
24
  // Frontend-safe allowlist barrel — keeps zod-openapi (and its zod-instance