@feedmepos/mf-order-setting 0.0.91 → 0.0.92

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 (75) hide show
  1. package/dist/{DisplaySystemView-n7Kqdw-h.js → DisplaySystemView-DZS7EVfk.js} +1 -1
  2. package/dist/{KioskDevicesView-Bz6seroM.js → KioskDevicesView-d2WLB0-w.js} +1 -1
  3. package/dist/{KioskDevicesView.vue_vue_type_script_setup_true_lang-I39sE8Pe.js → KioskDevicesView.vue_vue_type_script_setup_true_lang-rs-hNWUW.js} +3 -3
  4. package/dist/{KioskSettingView-Cajjh4Zm.js → KioskSettingView-DFE3_u5c.js} +3 -3
  5. package/dist/{KioskView-BPSbsMi5.js → KioskView-C3Pi3lWW.js} +5 -5
  6. package/dist/{OrderSettingsView-jPxZ3JDM.js → OrderSettingsView-Dxg9RjSd.js} +26999 -25299
  7. package/dist/{app-DIhv5ssA.js → app-BFlQtcsd.js} +3253 -3062
  8. package/dist/app.js +1 -1
  9. package/dist/common/order-platform/index.d.ts +20 -0
  10. package/dist/frontend/mf-order/src/api/reservation/index.d.ts +8 -0
  11. package/dist/frontend/mf-order/src/app.d.ts +84 -0
  12. package/dist/frontend/mf-order/src/main.d.ts +84 -0
  13. package/dist/frontend/mf-order/src/stores/order-setting/index.d.ts +3 -3
  14. package/dist/frontend/mf-order/src/stores/restaurant/index.d.ts +3 -0
  15. package/dist/frontend/mf-order/src/views/order-settings/reservation/CopySettingsSheet.vue.d.ts +186 -0
  16. package/dist/frontend/mf-order/src/views/order-settings/reservation/CustomTimePicker.vue.d.ts +11 -0
  17. package/dist/frontend/mf-order/src/views/order-settings/reservation/ReservationSetting.vue.d.ts +2 -0
  18. package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
  19. package/dist/{index-vaSLX6r3.js → index-ByiTrmdq.js} +2 -2
  20. package/dist/{index-Clp_YRJ2.js → index-CaO7aaaT.js} +1 -1
  21. package/dist/{index-CmqNrkNl.js → index-s2kWGMtz.js} +1 -1
  22. package/dist/order.dto-C829lA69.js +41528 -0
  23. package/dist/package/entity/delivery/delivery.dto.d.ts +7 -0
  24. package/dist/package/entity/food-court/food-court.dto.d.ts +315 -264
  25. package/dist/package/entity/food-court/order-v2.do.d.ts +231 -0
  26. package/dist/package/entity/food-court/order-v2.do.spec.d.ts +1 -0
  27. package/dist/package/entity/incoming-order/incoming-order-to-bill.dto.d.ts +15 -6
  28. package/dist/package/entity/index.d.ts +7 -0
  29. package/dist/package/entity/index.frontend.d.ts +2 -0
  30. package/dist/package/entity/member/member.dto.d.ts +2 -2
  31. package/dist/package/entity/order/dine-in/dine-in.do.d.ts +44 -9
  32. package/dist/package/entity/order/dine-in/dine-in.dto.d.ts +17 -3
  33. package/dist/package/entity/order/menu/menu.dto.d.ts +33 -3
  34. package/dist/package/entity/order/order-item/order-item.dto.d.ts +18 -18
  35. package/dist/package/entity/order/order.do.d.ts +87 -12
  36. package/dist/package/entity/order/order.dto.d.ts +762 -358
  37. package/dist/package/entity/order/order.enum.d.ts +1 -1
  38. package/dist/package/entity/order-platform/foodpanda/foodpanda-order.dto.d.ts +7 -0
  39. package/dist/package/entity/order-platform/grabfood/grabfood-order.do.d.ts +7 -0
  40. package/dist/package/entity/order-platform/menu.dto.d.ts +10 -10
  41. package/dist/package/entity/order-platform/order-platform.dto.d.ts +92 -0
  42. package/dist/package/entity/order-platform/shopeefood/shopeefood-order.do.d.ts +7 -0
  43. package/dist/package/entity/order-platform/tng/tng-menu.do.d.ts +6 -6
  44. package/dist/package/entity/order-setting/order-setting.do.d.ts +866 -5
  45. package/dist/package/entity/order-setting/order-setting.dto.d.ts +10 -10
  46. package/dist/package/entity/order-setting/reservationV2/reservation.do.d.ts +1269 -0
  47. package/dist/package/entity/order-setting/sms/sms.do.d.ts +3 -3
  48. package/dist/package/entity/order-setting/sms/sms.dto.d.ts +3 -3
  49. package/dist/package/entity/queue/queue.dto.d.ts +175 -121
  50. package/dist/package/entity/reservation/reservation-pos.do.d.ts +222 -0
  51. package/dist/package/entity/reservation/reservation.do.d.ts +205 -0
  52. package/dist/package/entity/reservation/reservation.dto.d.ts +428 -0
  53. package/dist/package/entity/reservation/reservation.enum.d.ts +3 -0
  54. package/dist/package/entity/reservation/reservation.utils.d.ts +152 -0
  55. package/dist/package/entity/restaurant/restaurant.dto.d.ts +39 -24
  56. package/dist/package/entity/user/user.do.d.ts +2 -2
  57. package/dist/package/entity/websocket/websocket.dto.d.ts +161 -21
  58. package/dist/package/entity/websocket/websocket.enum.d.ts +1 -1
  59. package/dist/style.css +1 -1
  60. package/package.json +2 -2
  61. package/src/api/reservation/index.ts +25 -0
  62. package/src/assets/images/not-found.png +0 -0
  63. package/src/locales/en-US.json +21 -0
  64. package/src/locales/ja-JP.json +21 -0
  65. package/src/locales/th-TH.json +21 -0
  66. package/src/locales/zh-CN.json +21 -0
  67. package/src/views/order-settings/OrderSettingsView.vue +9 -2
  68. package/src/views/order-settings/delivery/delivery.spec.ts +21 -0
  69. package/src/views/order-settings/delivery/delivery.ts +2 -51
  70. package/src/views/order-settings/dine-in/DineInSetting.vue +11 -1
  71. package/src/views/order-settings/reservation/CopySettingsSheet.vue +256 -0
  72. package/src/views/order-settings/reservation/CustomTimePicker.vue +311 -0
  73. package/src/views/order-settings/reservation/ReservationSetting.vue +1549 -0
  74. package/vitest.config.ts +2 -1
  75. package/dist/order.dto-DV1qL3Q_.js +0 -40301
@@ -0,0 +1,1549 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, computed } from 'vue'
3
+ import { ReservationApi } from '@/api/reservation'
4
+ import { useLoading } from '@/composables/loading'
5
+ import {
6
+ type FdoOrderReservationSettingsV2,
7
+ FdoReservationRange,
8
+ FdoOrderReservationSettingsV2 as ReservationSettingsSchema,
9
+ generateDayTimeSlotsWithStatus,
10
+ categorizeTimeSlotsWithStatus,
11
+ validateTimeRangeOverlap,
12
+ validateSameDayTimeRange,
13
+ validateLeadDuration
14
+ } from '@entity'
15
+ import RestaurantSelector from '../components/RestaurantSelector.vue'
16
+ import CustomTimePicker from './CustomTimePicker.vue'
17
+ import { useCoreStore, useI18n } from '@feedmepos/mf-common'
18
+ import { useSnackbarFunctions } from '@/components/snackbar'
19
+ import { useDialog } from '@feedmepos/ui-library'
20
+ import moment from 'moment'
21
+ import CopySettingsSheet from './CopySettingsSheet.vue'
22
+ import notfound from '@/assets/images/not-found.png'
23
+
24
+ const { t } = useI18n()
25
+ const { showSuccess } = useSnackbarFunctions()
26
+ const { currentRestaurant, restaurants } = useCoreStore()
27
+ const { isLoading, startAsyncCallWithErr } = useLoading()
28
+ const dialog = useDialog()
29
+
30
+ // Check if multi-outlet
31
+ const isMultiOutlet = computed(() => (restaurants.value?.length ?? 0) > 1)
32
+
33
+ // Helper function to generate unique IDs and names for reservation ranges
34
+ const generateRangeDefaults = (index = 0) => ({
35
+ _id: new Date().toISOString() + '-' + index, // Add index to ensure uniqueness
36
+ name: `Dining Area ${index + 1}`
37
+ })
38
+
39
+ // Helper function to generate unique capacity tier ID
40
+ const generateCapacityTierId = () =>
41
+ new Date().toISOString() + '-' + Math.random().toString(36).substr(2, 9)
42
+
43
+ const DEFAULT_GUEST_MESSAGE = `Please take note of the following important details before making a reservation:
44
+
45
+ 1. Dining Time Limit
46
+ - Our restaurant enforces a dining time limit of 1 hour and 30 minutes.
47
+
48
+ 2. 15-Minute Holding Policy
49
+ - We can hold your reservation for a maximum of 15 minutes. If we are unable to reach you after this time, your table may be released without prior notice. If you anticipate being late or need to cancel, please call us to let us know so we can either hold your table or offer it to other guests.`
50
+
51
+ const DEFAULT_CANCELLATION_POLICY = `Cancellation Policy
52
+
53
+ Free cancellation up to 24 hours before your reservation. Please contact the outlet for any last-minute changes.`
54
+
55
+ function createDefaultCapacityTiers() {
56
+ return [
57
+ {
58
+ _id: generateCapacityTierId(),
59
+ minPax: 1,
60
+ maxPax: 2,
61
+ capacity: 8
62
+ },
63
+ {
64
+ _id: generateCapacityTierId(),
65
+ minPax: 3,
66
+ maxPax: 6,
67
+ capacity: 8
68
+ },
69
+ {
70
+ _id: generateCapacityTierId(),
71
+ minPax: 5,
72
+ maxPax: 7,
73
+ capacity: 8
74
+ }
75
+ ]
76
+ }
77
+
78
+ function createDefaultRange(index = 0): FdoReservationRange {
79
+ return {
80
+ ...generateRangeDefaults(index),
81
+ enable: false,
82
+ bookingDuration: 90,
83
+ enablePreorder: true,
84
+ minLeadDuration: {
85
+ value: 0,
86
+ unit: 'day'
87
+ },
88
+ maxLeadDuration: {
89
+ value: 30,
90
+ unit: 'day'
91
+ },
92
+ operatingHours: {
93
+ 0: {
94
+ enable: false,
95
+ hours: []
96
+ },
97
+ 1: {
98
+ enable: true,
99
+ hours: [
100
+ { start: '09:00', end: '14:00' },
101
+ { start: '17:00', end: '22:00' }
102
+ ]
103
+ },
104
+ 2: {
105
+ enable: true,
106
+ hours: [
107
+ { start: '09:00', end: '14:00' },
108
+ { start: '17:00', end: '22:00' }
109
+ ]
110
+ },
111
+ 3: {
112
+ enable: true,
113
+ hours: [
114
+ { start: '09:00', end: '14:00' },
115
+ { start: '17:00', end: '22:00' }
116
+ ]
117
+ },
118
+ 4: {
119
+ enable: true,
120
+ hours: [
121
+ { start: '09:00', end: '14:00' },
122
+ { start: '17:00', end: '22:00' }
123
+ ]
124
+ },
125
+ 5: {
126
+ enable: true,
127
+ hours: [
128
+ { start: '09:00', end: '14:00' },
129
+ { start: '17:00', end: '22:00' }
130
+ ]
131
+ },
132
+ 6: {
133
+ enable: true,
134
+ hours: []
135
+ }
136
+ },
137
+ preferences: [],
138
+ capacityTiers: createDefaultCapacityTiers(),
139
+ slotInterval: 30,
140
+ guestMessage: DEFAULT_GUEST_MESSAGE,
141
+ cancellationPolicy: DEFAULT_CANCELLATION_POLICY
142
+ }
143
+ }
144
+
145
+ function parseTimeToMinutes(time: string) {
146
+ const [hours, minutes] = time.split(':').map(Number)
147
+ return hours * 60 + minutes
148
+ }
149
+
150
+ function isOvernightRange(range: { start: string; end: string }) {
151
+ return parseTimeToMinutes(range.end) <= parseTimeToMinutes(range.start)
152
+ }
153
+
154
+ function normalizeRange<T extends Partial<FdoReservationRange>>(range: T, index = 0): T {
155
+ const defaults = generateRangeDefaults(index)
156
+ return {
157
+ ...range,
158
+ _id: range._id || defaults._id,
159
+ name: range.name || defaults.name,
160
+ capacityTiers: range.capacityTiers?.length ? range.capacityTiers : createDefaultCapacityTiers()
161
+ }
162
+ }
163
+
164
+ function getPreviewHoursForDay(day: 0 | 1 | 2 | 3 | 4 | 5 | 6) {
165
+ const operatingHours = rangeSetting.value.operatingHours
166
+ const previousDay = ((day + 6) % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6
167
+ const currentDayHours = operatingHours[day]
168
+ const previousDayHours = operatingHours[previousDay]
169
+
170
+ const spilloverHours = previousDayHours?.enable
171
+ ? previousDayHours.hours
172
+ .filter((hour) => isOvernightRange(hour))
173
+ .map((hour) => ({ start: '00:00', end: hour.end }))
174
+ : []
175
+
176
+ const currentHours = currentDayHours?.enable ? currentDayHours.hours : []
177
+
178
+ return [...spilloverHours, ...currentHours]
179
+ }
180
+
181
+ function getPreviewOperatingHours(day: 0 | 1 | 2 | 3 | 4 | 5 | 6) {
182
+ const hours = getPreviewHoursForDay(day)
183
+ return {
184
+ enable: hours.length > 0,
185
+ hours
186
+ }
187
+ }
188
+
189
+ const reservationSettings = ref<FdoOrderReservationSettingsV2>({
190
+ ranges: [
191
+ {
192
+ ...createDefaultRange(0),
193
+ operatingHours: {
194
+ 0: {
195
+ enable: false,
196
+ hours: []
197
+ },
198
+ 1: {
199
+ enable: false,
200
+ hours: []
201
+ },
202
+ 2: {
203
+ enable: false,
204
+ hours: []
205
+ },
206
+ 3: {
207
+ enable: false,
208
+ hours: []
209
+ },
210
+ 4: {
211
+ enable: false,
212
+ hours: []
213
+ },
214
+ 5: {
215
+ enable: false,
216
+ hours: []
217
+ },
218
+ 6: {
219
+ enable: false,
220
+ hours: []
221
+ }
222
+ }
223
+ }
224
+ ],
225
+ posCanOverbook: false,
226
+ draftHoldTimeMinutes: 15,
227
+ smsEnabled: true,
228
+ emailEnabled: true,
229
+ notifyOnConfirm: true,
230
+ notifyOnCancel: true
231
+ })
232
+
233
+ const rangeSetting = ref<FdoReservationRange>(createDefaultRange(0))
234
+
235
+ const isSaving = ref(false)
236
+
237
+ const optionText = ref('')
238
+ const selectedPreviewDay = ref(1) // Monday by default
239
+ const editingOption = ref<{ prefIndex: number; optIndex: number } | null>(null)
240
+
241
+ // Track if user explicitly selected custom mode to prevent auto-switching to preset
242
+ const isCustomMode = ref(false)
243
+
244
+ // Track expanded state for each time segment
245
+ const expandedSegments = ref({
246
+ morning: false,
247
+ afternoon: false,
248
+ evening: false
249
+ })
250
+
251
+ // Sync rangeSetting with the first range in reservationSettings
252
+ // This ensures rangeSetting always reflects the first range (for future multi-range support)
253
+ watch(
254
+ () => reservationSettings.value.ranges[0],
255
+ (firstRange) => {
256
+ if (firstRange) {
257
+ rangeSetting.value = firstRange
258
+ }
259
+ },
260
+ { deep: true, immediate: true }
261
+ )
262
+
263
+ // Also sync changes from rangeSetting back to the first range
264
+ watch(
265
+ () => rangeSetting.value,
266
+ (newRangeSetting) => {
267
+ if (reservationSettings.value.ranges[0]) {
268
+ reservationSettings.value.ranges[0] = newRangeSetting
269
+ } else {
270
+ reservationSettings.value.ranges.push(newRangeSetting)
271
+ }
272
+ },
273
+ { deep: true }
274
+ )
275
+
276
+ // Prevent negative values in capacity tiers
277
+ watch(
278
+ () => rangeSetting.value.capacityTiers,
279
+ (tiers) => {
280
+ tiers.forEach((tier) => {
281
+ if (tier.minPax < 1) tier.minPax = 1
282
+ if (tier.maxPax != null && tier.maxPax < tier.minPax) {
283
+ tier.maxPax = tier.minPax
284
+ }
285
+ })
286
+ },
287
+ { deep: true }
288
+ )
289
+
290
+ // Computed property for preset selection
291
+ const selectedPreset = computed(() => {
292
+ // If user explicitly chose custom mode, stay in custom regardless of value
293
+ if (isCustomMode.value) {
294
+ return 'custom'
295
+ }
296
+
297
+ // Otherwise, check if value matches a preset
298
+ if (
299
+ rangeSetting.value.maxLeadDuration.unit === 'day' &&
300
+ (rangeSetting.value.maxLeadDuration.value === 30 ||
301
+ rangeSetting.value.maxLeadDuration.value === 60 ||
302
+ rangeSetting.value.maxLeadDuration.value === 90)
303
+ ) {
304
+ return rangeSetting.value.maxLeadDuration.value
305
+ }
306
+ return 'custom'
307
+ })
308
+
309
+ // Generate time slots based on operating hours and slot interval
310
+ // Now includes unavailable slots (between operating hour gaps)
311
+ const availableSlots = computed(() => {
312
+ const day = selectedPreviewDay.value as 0 | 1 | 2 | 3 | 4 | 5 | 6
313
+ const { slotInterval, bookingDuration } = rangeSetting.value
314
+ const previewOperatingHours = getPreviewOperatingHours(day)
315
+
316
+ if (!previewOperatingHours.enable) {
317
+ return { morning: [], afternoon: [], evening: [] }
318
+ }
319
+
320
+ // Use shared utility to generate slots with availability status
321
+ // This generates slots only from earliest to latest operating hour (not full 24h)
322
+ // Example: If hours are 10:00-12:00, 15:00-21:00, shows 10:00-21:00 range
323
+ const slotsWithStatus = generateDayTimeSlotsWithStatus(
324
+ previewOperatingHours,
325
+ slotInterval,
326
+ bookingDuration
327
+ )
328
+
329
+ // Categorize slots by time of day
330
+ return categorizeTimeSlotsWithStatus(slotsWithStatus)
331
+ })
332
+
333
+ // Computed properties for displaying limited slots (max 2 rows = 10 items per segment)
334
+ const displayedSlots = computed(() => {
335
+ const ITEMS_PER_ROW = 5
336
+ const MAX_ROWS = 2
337
+ const MAX_ITEMS = ITEMS_PER_ROW * MAX_ROWS // 10 items
338
+
339
+ const limitSlots = (slots: any[], isExpanded: boolean) => {
340
+ if (isExpanded || slots.length <= MAX_ITEMS) {
341
+ return slots
342
+ }
343
+ // Show MAX_ITEMS - 1 slots, then replace the last one with "more" indicator
344
+ return slots.slice(0, MAX_ITEMS - 1)
345
+ }
346
+
347
+ return {
348
+ morning: limitSlots(availableSlots.value.morning, expandedSegments.value.morning),
349
+ afternoon: limitSlots(availableSlots.value.afternoon, expandedSegments.value.afternoon),
350
+ evening: limitSlots(availableSlots.value.evening, expandedSegments.value.evening)
351
+ }
352
+ })
353
+
354
+ // Check if segment has more items than can be displayed (needs "more" button)
355
+ const hasMoreItems = computed(() => {
356
+ const MAX_ITEMS = 10
357
+ return {
358
+ morning: availableSlots.value.morning.length > MAX_ITEMS,
359
+ afternoon: availableSlots.value.afternoon.length > MAX_ITEMS,
360
+ evening: availableSlots.value.evening.length > MAX_ITEMS
361
+ }
362
+ })
363
+
364
+ const reservationPreview = computed(() => {
365
+ const today = moment()
366
+ const { minLeadDuration, maxLeadDuration } = rangeSetting.value
367
+ const startDate = today.clone().add(minLeadDuration.value, minLeadDuration.unit).startOf('d')
368
+ const endDate = today.clone().add(maxLeadDuration.value, maxLeadDuration.unit).startOf('d')
369
+
370
+ // Get the hours for start and end days
371
+ const startDayHours = getPreviewHoursForDay(startDate.day() as 0 | 1 | 2 | 3 | 4 | 5 | 6)
372
+ const endDayHours = getPreviewHoursForDay(endDate.day() as 0 | 1 | 2 | 3 | 4 | 5 | 6)
373
+
374
+ // Check if hours exist for both days
375
+ if (!startDayHours || startDayHours.length === 0 || !endDayHours || endDayHours.length === 0) {
376
+ return {
377
+ start: startDate.format('DD MMM YYYY'),
378
+ end: endDate.format('DD MMM YYYY'),
379
+ startHour: '--:--',
380
+ endHour: '--:--'
381
+ }
382
+ }
383
+
384
+ // use operatingHours and get the earlier starting hour to return a start&end for the preview, return the formatted time
385
+ const startHour = [...startDayHours].sort(
386
+ (a, b) => parseTimeToMinutes(a.start) - parseTimeToMinutes(b.start)
387
+ )[0]
388
+ const endHour = [...endDayHours].sort(
389
+ (a, b) =>
390
+ parseTimeToMinutes(b.end) +
391
+ (isOvernightRange(b) ? 24 * 60 : 0) -
392
+ (parseTimeToMinutes(a.end) + (isOvernightRange(a) ? 24 * 60 : 0))
393
+ )[0]
394
+
395
+ return {
396
+ start: startDate.format('DD MMM YYYY'),
397
+ end: endDate.format('DD MMM YYYY'),
398
+ startHour: moment(startHour.start, 'HH:mm').format('hh:mm A'),
399
+ endHour: moment(endHour.end, 'HH:mm').format('hh:mm A')
400
+ }
401
+ })
402
+
403
+ watch(
404
+ () => currentRestaurant.value,
405
+ () => init(),
406
+ { immediate: true }
407
+ )
408
+
409
+ async function readReservationSetting(): Promise<FdoOrderReservationSettingsV2> {
410
+ return await startAsyncCallWithErr(async () => {
411
+ if (!currentRestaurant.value?._id) {
412
+ throw new Error('No restaurant selected')
413
+ }
414
+ return await ReservationApi.getReservationSetting(currentRestaurant.value._id)
415
+ })
416
+ }
417
+
418
+ async function updateReservationSetting() {
419
+ // Check for lead duration validation errors
420
+ const leadDurationError = validateLeadDurationLocal()
421
+ if (leadDurationError) {
422
+ dialog.open({
423
+ title: t('order.validationError'),
424
+ message: leadDurationError,
425
+ primaryActions: { text: t('order.ok'), close: true }
426
+ })
427
+ return
428
+ }
429
+
430
+ // Check for time range validation errors
431
+ if (hasAnyTimeRangeErrors()) {
432
+ dialog.open({
433
+ title: t('order.validationError'),
434
+ message: 'Please fix all time range errors before saving.',
435
+ primaryActions: { text: t('order.ok'), close: true }
436
+ })
437
+ return
438
+ }
439
+
440
+ const capacityTierError = validateCapacityTiers()
441
+ if (capacityTierError) {
442
+ dialog.open({
443
+ title: t('order.validationError'),
444
+ message: capacityTierError,
445
+ primaryActions: { text: t('order.ok'), close: true }
446
+ })
447
+ return
448
+ }
449
+
450
+ isSaving.value = true
451
+ try {
452
+ await startAsyncCallWithErr(async () => {
453
+ if (!currentRestaurant.value?._id) {
454
+ throw new Error('No restaurant selected')
455
+ }
456
+
457
+ // Validate with Zod schema
458
+ const validated = ReservationSettingsSchema.parse(reservationSettings.value)
459
+ await ReservationApi.updateReservationSetting(currentRestaurant.value._id, validated)
460
+ await init()
461
+ })
462
+ showSuccess(t('order.settingUpdated'))
463
+ } finally {
464
+ isSaving.value = false
465
+ }
466
+ }
467
+
468
+ async function init() {
469
+ if (currentRestaurant.value) {
470
+ const restaurantId = currentRestaurant.value._id
471
+ const settings = await readReservationSetting()
472
+ // The outlet changed while this read was in flight: a later init() owns the
473
+ // form, and applying this response would let outlet A's settings be saved
474
+ // to outlet B.
475
+ if (currentRestaurant.value?._id !== restaurantId) return
476
+ reservationSettings.value = {
477
+ ranges: (settings.ranges || []).map((range, index) => normalizeRange(range, index)),
478
+ posCanOverbook: settings.posCanOverbook || false,
479
+ draftHoldTimeMinutes: settings.draftHoldTimeMinutes || 15,
480
+ smsEnabled: settings.smsEnabled ?? true,
481
+ emailEnabled: settings.emailEnabled ?? true,
482
+ notifyOnConfirm: settings.notifyOnConfirm ?? true,
483
+ notifyOnCancel: settings.notifyOnCancel ?? true
484
+ }
485
+ // Sync rangeSetting with the first range if it exists, otherwise reset to default
486
+ if (settings.ranges && settings.ranges.length > 0) {
487
+ rangeSetting.value = normalizeRange(settings.ranges[0], 0)
488
+ } else {
489
+ // Reset to default state when no ranges exist (fresh restaurant). Start from the
490
+ // restaurant's own operating hours when it has them (cloned, so editing the range
491
+ // never mutates the core store's restaurant profile).
492
+ const restaurantHours = currentRestaurant.value.profile?.operatingHours
493
+ rangeSetting.value = {
494
+ ...createDefaultRange(0),
495
+ operatingHours: restaurantHours
496
+ ? JSON.parse(JSON.stringify(restaurantHours))
497
+ : {
498
+ 0: { enable: false, hours: [] },
499
+ 1: { enable: false, hours: [] },
500
+ 2: { enable: false, hours: [] },
501
+ 3: { enable: false, hours: [] },
502
+ 4: { enable: false, hours: [] },
503
+ 5: { enable: false, hours: [] },
504
+ 6: { enable: false, hours: [] }
505
+ }
506
+ }
507
+ }
508
+
509
+ // Reset custom mode - let the computed property determine the initial state
510
+ isCustomMode.value = false
511
+ }
512
+ }
513
+
514
+ function updateSetting<K extends keyof FdoOrderReservationSettingsV2>(
515
+ key: K,
516
+ value: FdoOrderReservationSettingsV2[K]
517
+ ) {
518
+ reservationSettings.value[key] = value
519
+ }
520
+
521
+ function updateMaxLeadPreset(value: number | 'custom') {
522
+ if (value === 'custom') {
523
+ // User explicitly selected custom mode
524
+ isCustomMode.value = true
525
+ // Switch to custom mode - set to a non-preset value to trigger custom UI
526
+ // Keep the current value if it's already custom, otherwise set to 1 day as starting point
527
+ if (
528
+ rangeSetting.value.maxLeadDuration.unit !== 'day' ||
529
+ ![30, 60, 90].includes(rangeSetting.value.maxLeadDuration.value)
530
+ ) {
531
+ // Already custom, don't change
532
+ return
533
+ }
534
+ // Set to 1 day as default custom value
535
+ rangeSetting.value.maxLeadDuration = {
536
+ value: 1,
537
+ unit: 'day'
538
+ }
539
+ return
540
+ }
541
+ // User selected a preset - exit custom mode
542
+ isCustomMode.value = false
543
+ rangeSetting.value.maxLeadDuration = {
544
+ value,
545
+ unit: 'day'
546
+ }
547
+ }
548
+
549
+ function updateMinLeadValue(value: number) {
550
+ rangeSetting.value.minLeadDuration.value = value
551
+ }
552
+
553
+ function updateMinLeadUnit(unit: 'hour' | 'day') {
554
+ rangeSetting.value.minLeadDuration.unit = unit
555
+ }
556
+
557
+ function updateMaxLeadValue(value: number) {
558
+ rangeSetting.value.maxLeadDuration.value = value
559
+ }
560
+
561
+ function updateMaxLeadUnit(unit: 'hour' | 'day') {
562
+ rangeSetting.value.maxLeadDuration.unit = unit
563
+ }
564
+
565
+ function updateSlotInterval(value: number) {
566
+ rangeSetting.value.slotInterval = value
567
+ }
568
+
569
+ function updateBookingDuration(value: number) {
570
+ rangeSetting.value.bookingDuration = value
571
+ }
572
+
573
+ function addPreferenceOption(
574
+ preference: (typeof rangeSetting.value.preferences)[number],
575
+ value?: string
576
+ ) {
577
+ if (value || optionText.value.trim()) {
578
+ if (!preference.options) {
579
+ preference.options = []
580
+ }
581
+ preference.options.push(value ?? optionText.value.trim())
582
+ optionText.value = ''
583
+ }
584
+ }
585
+
586
+ function removePreferenceOption(
587
+ preference: (typeof rangeSetting.value.preferences)[number],
588
+ option: string
589
+ ) {
590
+ if (preference.options) {
591
+ const index = preference.options.indexOf(option)
592
+ if (index > -1) {
593
+ preference.options.splice(index, 1)
594
+ }
595
+ }
596
+ }
597
+
598
+ function addPreference() {
599
+ rangeSetting.value.preferences.push({
600
+ name: '',
601
+ type: 'radio',
602
+ options: []
603
+ })
604
+ }
605
+
606
+ function removePreference(index: number) {
607
+ rangeSetting.value.preferences.splice(index, 1)
608
+ }
609
+
610
+ function startEditingOption(prefIndex: number, optIndex: number) {
611
+ editingOption.value = { prefIndex, optIndex }
612
+ }
613
+
614
+ function saveOptionEdit(prefIndex: number, optIndex: number, newValue: string) {
615
+ if (newValue.trim()) {
616
+ rangeSetting.value.preferences[prefIndex].options[optIndex] = newValue.trim()
617
+ }
618
+ editingOption.value = null
619
+ }
620
+
621
+ function isEditingOption(prefIndex: number, optIndex: number): boolean {
622
+ return editingOption.value?.prefIndex === prefIndex && editingOption.value?.optIndex === optIndex
623
+ }
624
+
625
+ function toggleGuestMessage(enabled: boolean) {
626
+ if (enabled) {
627
+ rangeSetting.value.guestMessage = DEFAULT_GUEST_MESSAGE
628
+ } else {
629
+ rangeSetting.value.guestMessage = null
630
+ }
631
+ }
632
+
633
+ function toggleCancellationPolicy(enabled: boolean) {
634
+ if (enabled) {
635
+ rangeSetting.value.cancellationPolicy = DEFAULT_CANCELLATION_POLICY
636
+ } else {
637
+ rangeSetting.value.cancellationPolicy = null
638
+ }
639
+ }
640
+
641
+ function toggleSegmentExpansion(segment: 'morning' | 'afternoon' | 'evening') {
642
+ expandedSegments.value[segment] = !expandedSegments.value[segment]
643
+ }
644
+
645
+ // Validation for operating hours
646
+ function validateTimeRange(day: 0 | 1 | 2 | 3 | 4 | 5 | 6, hourIndex: number): string | null {
647
+ const dayHours = rangeSetting.value.operatingHours[day].hours
648
+ const range = dayHours[hourIndex]
649
+
650
+ // Validate same day time range (end > start)
651
+ if (!validateSameDayTimeRange(range)) {
652
+ return 'Start and end time cannot be the same'
653
+ }
654
+
655
+ // Check for overlaps with other time ranges
656
+ for (let i = 0; i < dayHours.length; i++) {
657
+ if (i === hourIndex) continue
658
+
659
+ // Use shared utility to check overlap
660
+ if (validateTimeRangeOverlap(range, dayHours[i])) {
661
+ return 'Time ranges cannot overlap'
662
+ }
663
+ }
664
+
665
+ return null
666
+ }
667
+
668
+ function getTimeRangeError<T>(day: T, hourIndex: number): string | null {
669
+ return validateTimeRange(day as 0 | 1 | 2 | 3 | 4 | 5 | 6, hourIndex)
670
+ }
671
+
672
+ function hasAnyTimeRangeErrors(): boolean {
673
+ for (let day = 0; day <= 6; day++) {
674
+ const dayHours = rangeSetting.value.operatingHours[day as 0 | 1 | 2 | 3 | 4 | 5 | 6].hours
675
+ for (let i = 0; i < dayHours.length; i++) {
676
+ if (validateTimeRange(day as 0 | 1 | 2 | 3 | 4 | 5 | 6, i)) {
677
+ return true
678
+ }
679
+ }
680
+ }
681
+ return false
682
+ }
683
+
684
+ function deleteTimeRange(day: 0 | 1 | 2 | 3 | 4 | 5 | 6, hourIndex: number) {
685
+ rangeSetting.value.operatingHours[day].hours.splice(hourIndex, 1)
686
+
687
+ // If no time ranges left, turn off the toggle
688
+ if (rangeSetting.value.operatingHours[day].hours.length === 0) {
689
+ rangeSetting.value.operatingHours[day].enable = false
690
+ }
691
+ }
692
+
693
+ // Validation for lead duration
694
+ function validateLeadDurationLocal(): string | null {
695
+ const { minLeadDuration, maxLeadDuration } = rangeSetting.value
696
+
697
+ // Use shared utility to validate
698
+ if (!validateLeadDuration(minLeadDuration, maxLeadDuration)) {
699
+ return 'Maximum lead time must be greater than minimum lead time'
700
+ }
701
+
702
+ if (minLeadDuration.value < 0 || maxLeadDuration.value < 0) {
703
+ return 'Lead time values must be positive'
704
+ }
705
+
706
+ return null
707
+ }
708
+
709
+ // Validation for capacity tiers: minPax >= 1 and, when set, maxPax >= minPax
710
+ function validateCapacityTiers(): string | null {
711
+ for (const tier of rangeSetting.value.capacityTiers) {
712
+ if (tier.minPax < 1) {
713
+ return 'Minimum guests must be at least 1'
714
+ }
715
+ if (tier.maxPax != null && tier.maxPax < tier.minPax) {
716
+ return 'Maximum guests must be greater than or equal to minimum guests'
717
+ }
718
+ }
719
+ return null
720
+ }
721
+
722
+ function handleCopySettings(copiedSettings: Partial<FdoOrderReservationSettingsV2>) {
723
+ // Apply the copied settings to the current settings
724
+ if (copiedSettings.ranges && copiedSettings.ranges.length > 0) {
725
+ const copiedRange = copiedSettings.ranges[0]
726
+
727
+ // Update the first range in reservationSettings
728
+ if (reservationSettings.value.ranges && reservationSettings.value.ranges.length > 0) {
729
+ // Merge the copied range properties into the existing range
730
+ reservationSettings.value.ranges[0] = {
731
+ ...reservationSettings.value.ranges[0],
732
+ ...copiedRange
733
+ }
734
+
735
+ // This will automatically sync to rangeSetting via the watch
736
+ }
737
+ }
738
+
739
+ // Reset custom mode when copying settings - let computed property determine state
740
+ isCustomMode.value = false
741
+ }
742
+ </script>
743
+
744
+ <template>
745
+ <RestaurantSelector />
746
+ <div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
747
+ <FmCircularProgress size="xxl" />
748
+ </div>
749
+ <div v-else class="p-[1.5rem] flex flex-col gap-48 w-full max-w-4xl">
750
+ <div>
751
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-16">
752
+ {{ t('order.reservationStatus') }}
753
+ </div>
754
+ <FmSwitch
755
+ label-placement="right"
756
+ :label="'Enable reservation'"
757
+ :sublabel="'Enable this to make the outlet available for reservations. This setting does not impact walk-in dining.'"
758
+ :model-value="rangeSetting.enable ?? false"
759
+ @update:model-value="(v: boolean) => (rangeSetting.enable = v)"
760
+ />
761
+
762
+ <div v-if="rangeSetting.enable && isMultiOutlet" class="ml-56 my-8">
763
+ <CopySettingsSheet :current-settings="reservationSettings" @apply="handleCopySettings" />
764
+ </div>
765
+ </div>
766
+
767
+ <template v-if="rangeSetting.enable">
768
+ <!-- Notification Settings Section -->
769
+ <div class="flex flex-col">
770
+ <div class="flex-grow fm-typo-en-title-sm-600 my-8">
771
+ {{ t('order.notificationSettings') }}
772
+ </div>
773
+ <FmCard variant="outlined" class="p-5">
774
+ <div class="mb-5">
775
+ <FmSwitch
776
+ :model-value="reservationSettings.smsEnabled"
777
+ @update:model-value="(v: boolean) => updateSetting('smsEnabled', v)"
778
+ :label="t('order.smsEnabled')"
779
+ label-placement="right"
780
+ :sublabel="t('order.smsEnabledDescription')"
781
+ />
782
+ </div>
783
+ <div class="mb-5">
784
+ <FmSwitch
785
+ :model-value="reservationSettings.emailEnabled"
786
+ @update:model-value="(v: boolean) => updateSetting('emailEnabled', v)"
787
+ :label="t('order.emailEnabled')"
788
+ label-placement="right"
789
+ :sublabel="t('order.emailEnabledDescription')"
790
+ />
791
+ </div>
792
+ <div class="mb-5">
793
+ <FmSwitch
794
+ :model-value="reservationSettings.notifyOnConfirm"
795
+ @update:model-value="(v: boolean) => updateSetting('notifyOnConfirm', v)"
796
+ :label="t('order.notifyOnConfirm')"
797
+ label-placement="right"
798
+ :sublabel="t('order.notifyOnConfirmDescription')"
799
+ />
800
+ </div>
801
+ <div class="mb-5">
802
+ <FmSwitch
803
+ :model-value="reservationSettings.notifyOnCancel"
804
+ @update:model-value="(v: boolean) => updateSetting('notifyOnCancel', v)"
805
+ :label="t('order.notifyOnCancel')"
806
+ label-placement="right"
807
+ :sublabel="t('order.notifyOnCancelDescription')"
808
+ />
809
+ </div>
810
+ <div class="mb-5">
811
+ <div class="mb-8 fm-typo-en-body-md-600 flex items-center gap-8">
812
+ {{ t('order.draftHoldTimeMinutes') }}
813
+ <FmTooltip
814
+ :content="'The amount of time a reservation is held before it is automatically released.'"
815
+ >
816
+ <FmIcon name="info" outline size="sm" class="cursor-pointer" />
817
+ </FmTooltip>
818
+ </div>
819
+ <FmTextField
820
+ type="number"
821
+ :model-value="reservationSettings.draftHoldTimeMinutes"
822
+ @update:model-value="
823
+ (v: string | number) =>
824
+ updateSetting('draftHoldTimeMinutes', v === '' ? 15 : Number(v))
825
+ "
826
+ suffix="minutes"
827
+ class="max-w-md"
828
+ />
829
+ </div>
830
+ <div class="mb-5">
831
+ <FmSwitch
832
+ :model-value="reservationSettings.posCanOverbook"
833
+ @update:model-value="(v: boolean) => updateSetting('posCanOverbook', v)"
834
+ :label="t('order.posCanOverbook')"
835
+ label-placement="right"
836
+ :sublabel="t('order.posCanOverbookDescription')"
837
+ />
838
+ </div>
839
+ </FmCard>
840
+ </div>
841
+
842
+ <div class="flex flex-col">
843
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">
844
+ {{ t('order.reservationAvailability') }}
845
+ </div>
846
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
847
+ This shows when guests can make reservations based on your current settings. The
848
+ availability updates automatically.
849
+ </div>
850
+ <!-- Availability settings (eg. the min/max lead, and time interval + booking duration) -->
851
+ <div class="mb-24">
852
+ <div class="mb-8">Preview</div>
853
+ <div class="flex items-center gap-4 mb-8">
854
+ <div
855
+ class="w-fit border-1 border-fm-color-neutral-gray-200 rounded-md py-4 px-8 bg-fm-color-neutral-gray-100 flex items-center gap-8 text-fm-color-typo-tertiary"
856
+ >
857
+ <FmIcon name="calendar_month" outline />
858
+ <div class="pr-6">
859
+ {{ reservationPreview.start + ' ' + reservationPreview.startHour }}
860
+ </div>
861
+ </div>
862
+ <div>to</div>
863
+ <div
864
+ class="w-fit border-1 border-fm-color-neutral-gray-200 rounded-md py-4 px-8 bg-fm-color-neutral-gray-100 flex items-center gap-8 text-fm-color-typo-tertiary"
865
+ >
866
+ <FmIcon name="calendar_month" outline />
867
+ <div class="pr-6">
868
+ {{ reservationPreview.end + ' ' + reservationPreview.endHour }}
869
+ </div>
870
+ </div>
871
+ </div>
872
+ <div class="fm-typo-en-body-md-400 text-fm-color-typo-secondary">
873
+ Reservations will open on
874
+ <strong> {{ reservationPreview.start + ', ' + reservationPreview.startHour }} </strong>.
875
+ Guests will not be able to make reservations before this time.
876
+ </div>
877
+ </div>
878
+
879
+ <div class="mb-24">
880
+ <div class="mb-8">Presets</div>
881
+ <FmRadioGroup
882
+ :model-value="selectedPreset"
883
+ @update:model-value="(v: number | 'custom') => updateMaxLeadPreset(v)"
884
+ >
885
+ <FmRadio label="30 days" :value="30">
886
+ <template #label>
887
+ <div>30 days <span class="text-fm-color-typo-secondary">(default)</span></div>
888
+ </template>
889
+ </FmRadio>
890
+ <FmRadio label="60 days" :value="60" />
891
+ <FmRadio label="90 days" :value="90" />
892
+ <FmRadio label="Custom" :value="'custom'" />
893
+ </FmRadioGroup>
894
+
895
+ <!-- Custom preset fields -->
896
+ <div
897
+ v-if="selectedPreset === 'custom'"
898
+ class="ml-32 mt-12 p-16 border rounded-md bg-fm-color-neutral-gray-50"
899
+ >
900
+ <div class="mb-16">
901
+ <div class="mb-8 fm-typo-en-body-md-600 flex items-center gap-8">
902
+ Minimum Lead Time
903
+ <FmTooltip
904
+ :content="'The earliest time a guest can make a reservation. For example, if you set this to 1 day, guests can only make reservations from tomorrow onwards.'"
905
+ >
906
+ <FmIcon name="info" outline size="sm" class="cursor-pointer" />
907
+ </FmTooltip>
908
+ </div>
909
+ <div class="flex items-center gap-8">
910
+ <FmStepperField
911
+ :model-value="rangeSetting.minLeadDuration.value"
912
+ @update:model-value="(v: number) => updateMinLeadValue(v)"
913
+ :min="0"
914
+ class=""
915
+ />
916
+ <FmSelect
917
+ :model-value="rangeSetting.minLeadDuration.unit"
918
+ @update:model-value="(v: 'hour' | 'day') => updateMinLeadUnit(v)"
919
+ :items="[
920
+ { label: 'Hours', value: 'hour' },
921
+ { label: 'Days', value: 'day' }
922
+ ]"
923
+ class="w-120"
924
+ />
925
+ </div>
926
+ </div>
927
+
928
+ <div>
929
+ <div class="mb-8 fm-typo-en-body-md-600 flex items-center gap-8">
930
+ Maximum Lead Time
931
+ <FmTooltip
932
+ :content="'The furthest in advance a guest can make a reservation. For example, if you set this to 30 days, guests can only make reservations up to 30 days from today.'"
933
+ >
934
+ <FmIcon name="info" outline size="sm" class="cursor-pointer" />
935
+ </FmTooltip>
936
+ </div>
937
+ <div class="flex items-center gap-8">
938
+ <FmStepperField
939
+ :model-value="rangeSetting.maxLeadDuration.value"
940
+ @update:model-value="(v: number) => updateMaxLeadValue(v)"
941
+ :min="0"
942
+ class=""
943
+ />
944
+ <FmSelect
945
+ :model-value="rangeSetting.maxLeadDuration.unit"
946
+ @update:model-value="(v: 'hour' | 'day') => updateMaxLeadUnit(v)"
947
+ :items="[
948
+ { label: 'Hours', value: 'hour' },
949
+ { label: 'Days', value: 'day' }
950
+ ]"
951
+ class="w-120"
952
+ />
953
+ </div>
954
+ </div>
955
+
956
+ <!-- Validation error message -->
957
+ <div v-if="validateLeadDurationLocal()" class="mt-16 text-sm text-red-600">
958
+ {{ validateLeadDurationLocal() }}
959
+ </div>
960
+ </div>
961
+ </div>
962
+ </div>
963
+
964
+ <div class="flex flex-col">
965
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">Time Settings</div>
966
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-12">
967
+ Choose the start and end time for reservations, and the interval between each time slot.
968
+ Available time slots will be generated automatically.
969
+ </div>
970
+
971
+ <!-- Operating hour setting -->
972
+ <div class="mb-32">
973
+ <div class="grid grid-cols-[1fr_1fr_3fr] items-start">
974
+ <div
975
+ class="p-12 fm-typo-en-body-md-600 text-fm-color-typo-secondary bg-fm-color-neutral-gray-100"
976
+ >
977
+ Day
978
+ </div>
979
+ <div
980
+ class="p-12 fm-typo-en-body-md-600 text-fm-color-typo-secondary bg-fm-color-neutral-gray-100"
981
+ >
982
+ Open / Closed
983
+ </div>
984
+ <div
985
+ class="p-12 fm-typo-en-body-md-600 text-fm-color-typo-secondary bg-fm-color-neutral-gray-100"
986
+ >
987
+ Reservation Time Range
988
+ </div>
989
+ <template v-for="day in [1, 2, 3, 4, 5, 6, 0] as const" :key="day">
990
+ <div class="fm-typo-en-body-md-400 py-12 px-12">
991
+ {{ moment().day(day).format('dddd') }}
992
+ </div>
993
+ <template v-if="rangeSetting.operatingHours[day as 0 | 1 | 2 | 3 | 4 | 5 | 6]">
994
+ <template
995
+ v-for="hours in [rangeSetting.operatingHours[day as 0 | 1 | 2 | 3 | 4 | 5 | 6]]"
996
+ :key="`hours-${day}`"
997
+ >
998
+ <div class="px-12 py-8">
999
+ <FmSwitch
1000
+ label-placement="right"
1001
+ :model-value="hours.enable"
1002
+ @update:model-value="
1003
+ (v) => {
1004
+ rangeSetting.operatingHours[day].enable = v
1005
+ rangeSetting.operatingHours[day].hours = v
1006
+ ? [
1007
+ {
1008
+ start: '00:00',
1009
+ end: '23:59'
1010
+ }
1011
+ ]
1012
+ : []
1013
+ }
1014
+ "
1015
+ :label="hours.enable ? 'Open' : 'Closed'"
1016
+ />
1017
+ </div>
1018
+ <div class="px-12 self-center">
1019
+ <div v-if="!hours.enable" class="">-</div>
1020
+ <div v-else class="flex flex-col">
1021
+ <div v-for="(hour, hi) in hours.hours" :key="hi" class="flex flex-col gap-4">
1022
+ <div class="flex gap-4 items-center justify-between">
1023
+ <div class="flex gap-12 items-center flex-1 justify-start py-8">
1024
+ <CustomTimePicker
1025
+ v-model="hour.start"
1026
+ :min-time="hours.hours[hi - 1]?.end ?? '00:00'"
1027
+ />
1028
+ <div class="text-center w-16 flex-shrink-0">to</div>
1029
+ <CustomTimePicker
1030
+ v-model="hour.end"
1031
+ :min-time="hour.start"
1032
+ :restrict-min-time="false"
1033
+ />
1034
+ </div>
1035
+ <div class="mr-8 w-32 flex-shrink">
1036
+ <FmButton
1037
+ variant="plain"
1038
+ icon="add"
1039
+ v-if="hi == 0"
1040
+ :disabled="hours.hours.length >= 2"
1041
+ @click="hours.hours.push({ start: hour.end, end: '23:59' })"
1042
+ />
1043
+ <FmButton
1044
+ variant="plain"
1045
+ icon="delete"
1046
+ v-if="hi > 0"
1047
+ @click="
1048
+ deleteTimeRange(day as unknown as 0 | 1 | 2 | 3 | 4 | 5 | 6, hi)
1049
+ "
1050
+ />
1051
+ </div>
1052
+ </div>
1053
+ <div v-if="getTimeRangeError(day, hi)" class="text-sm text-red-600 ml-4">
1054
+ {{ getTimeRangeError(day, hi) }}
1055
+ </div>
1056
+ </div>
1057
+ </div>
1058
+ </div>
1059
+ </template>
1060
+ </template>
1061
+ </template>
1062
+ </div>
1063
+ </div>
1064
+
1065
+ <FmCard variant="outlined" class="grid grid-cols-[1fr_2fr]">
1066
+ <div class="border-r py-[24px] px-16">
1067
+ <div class="mb-24">
1068
+ <div class="mb-8 fm-typo-en-body-lg-600 flex items-center gap-8">
1069
+ Time Interval
1070
+ <FmTooltip :content="'How often guests can start a reservation'">
1071
+ <FmIcon name="info" outline size="sm" class="cursor-pointer" />
1072
+ </FmTooltip>
1073
+ </div>
1074
+ <FmRadioGroup
1075
+ :model-value="rangeSetting.slotInterval"
1076
+ @update:model-value="(v: number) => updateSlotInterval(v)"
1077
+ >
1078
+ <FmRadio label="15 min" :value="15" />
1079
+ <FmRadio label="30 min (default)" :value="30">
1080
+ <template #label
1081
+ >30 min <span class="text-fm-color-typo-secondary">(default)</span></template
1082
+ >
1083
+ </FmRadio>
1084
+ <FmRadio label="60 min" :value="60" />
1085
+ </FmRadioGroup>
1086
+ </div>
1087
+
1088
+ <div class="mb-24">
1089
+ <div class="mb-8 fm-typo-en-body-lg-600 flex items-center gap-8">
1090
+ Dining Duration
1091
+ <FmTooltip :content="'How long a table is reserved for each booking'">
1092
+ <FmIcon name="info" outline size="sm" class="cursor-pointer" />
1093
+ </FmTooltip>
1094
+ </div>
1095
+ <FmRadioGroup
1096
+ :model-value="rangeSetting.bookingDuration"
1097
+ @update:model-value="(v: number) => updateBookingDuration(v)"
1098
+ >
1099
+ <FmRadio label="60 min" :value="60" />
1100
+ <FmRadio label="90 min" :value="90">
1101
+ <template #label
1102
+ >90 min <span class="text-fm-color-typo-secondary">(default)</span></template
1103
+ >
1104
+ </FmRadio>
1105
+
1106
+ <FmRadio label="120 min" :value="120" />
1107
+ </FmRadioGroup>
1108
+ </div>
1109
+ </div>
1110
+
1111
+ <div class="p-16 flex flex-col gap-16">
1112
+ <div class="flex items-center justify-between w-full">
1113
+ <div class="fm-typo-en-body-lg-600">Available reservation slots (preview)</div>
1114
+ <FmSelect
1115
+ v-model="selectedPreviewDay"
1116
+ :items="[
1117
+ { label: 'Monday', value: 1 },
1118
+ { label: 'Tuesday', value: 2 },
1119
+ { label: 'Wednesday', value: 3 },
1120
+ { label: 'Thursday', value: 4 },
1121
+ { label: 'Friday', value: 5 },
1122
+ { label: 'Saturday', value: 6 },
1123
+ { label: 'Sunday', value: 0 }
1124
+ ]"
1125
+ />
1126
+ </div>
1127
+
1128
+ <template v-if="availableSlots.morning.length > 0">
1129
+ <div class="fm-typo-en-body-md-600 text-[#4B4B4B]">Morning</div>
1130
+ <div class="grid grid-cols-5 gap-8">
1131
+ <div
1132
+ v-for="slot in displayedSlots.morning"
1133
+ :key="slot.time"
1134
+ class="border-1 rounded-md text-center p-8 transition-colors"
1135
+ :class="{
1136
+ 'bg-[#fafafa] text-fm-color-typo-primary': slot.available,
1137
+ 'bg-[#f0f0f0] text-fm-color-typo-disabled border-dashed': !slot.available
1138
+ }"
1139
+ >
1140
+ {{ slot.time }}
1141
+ </div>
1142
+ <div
1143
+ v-if="hasMoreItems.morning && !expandedSegments.morning"
1144
+ class="border-2 rounded-md text-center p-8 transition-colors cursor-pointer border-fm-color-primary text-fm-color-primary"
1145
+ @click="toggleSegmentExpansion('morning')"
1146
+ >
1147
+ more
1148
+ </div>
1149
+ </div>
1150
+ </template>
1151
+
1152
+ <template v-if="availableSlots.afternoon.length > 0">
1153
+ <div class="fm-typo-en-body-md-600 text-[#4B4B4B]">Afternoon</div>
1154
+ <div class="grid grid-cols-5 gap-8">
1155
+ <div
1156
+ v-for="slot in displayedSlots.afternoon"
1157
+ :key="slot.time"
1158
+ class="border-1 rounded-md text-center p-8 transition-colors"
1159
+ :class="{
1160
+ 'bg-[#fafafa] text-fm-color-typo-primary': slot.available,
1161
+ 'bg-[#f0f0f0] text-fm-color-typo-disabled border-dashed': !slot.available
1162
+ }"
1163
+ >
1164
+ {{ slot.time }}
1165
+ </div>
1166
+ <div
1167
+ v-if="hasMoreItems.afternoon && !expandedSegments.afternoon"
1168
+ class="border-2 rounded-md text-center p-8 transition-colors cursor-pointer border-fm-color-primary text-fm-color-primary"
1169
+ @click="toggleSegmentExpansion('afternoon')"
1170
+ >
1171
+ more
1172
+ </div>
1173
+ </div>
1174
+ </template>
1175
+
1176
+ <template v-if="availableSlots.evening.length > 0">
1177
+ <div class="fm-typo-en-body-md-600 text-[#4B4B4B]">Evening</div>
1178
+ <div class="grid grid-cols-5 gap-8">
1179
+ <div
1180
+ v-for="slot in displayedSlots.evening"
1181
+ :key="slot.time"
1182
+ class="border-1 rounded-md text-center p-8 transition-colors"
1183
+ :class="{
1184
+ 'bg-[#fafafa] text-fm-color-typo-primary': slot.available,
1185
+ 'bg-[#f0f0f0] text-fm-color-typo-disabled border-dashed': !slot.available
1186
+ }"
1187
+ >
1188
+ {{ slot.time }}
1189
+ </div>
1190
+ <div
1191
+ v-if="hasMoreItems.evening && !expandedSegments.evening"
1192
+ class="border-2 rounded-md text-center p-8 transition-colors cursor-pointer border-fm-color-primary text-fm-color-primary"
1193
+ @click="toggleSegmentExpansion('evening')"
1194
+ >
1195
+ more
1196
+ </div>
1197
+ </div>
1198
+ </template>
1199
+
1200
+ <div
1201
+ v-if="
1202
+ !availableSlots.morning.length &&
1203
+ !availableSlots.afternoon.length &&
1204
+ !availableSlots.evening.length
1205
+ "
1206
+ class="text-center text-fm-color-typo-secondary flex flex-col items-center gap-16 p-24"
1207
+ >
1208
+ <img :src="notfound" class="aspect-square w-[150px]" />
1209
+ Uh-oh! This outlet is closed on the selected day. <br />
1210
+ Please select another day to view available time slots.
1211
+ </div>
1212
+ </div>
1213
+ </FmCard>
1214
+ </div>
1215
+
1216
+ <div class="flex flex-col">
1217
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">Table Capacity Settings</div>
1218
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
1219
+ Set how many tables are available for reservation per time slot. You can reduce
1220
+ availability to keep tables for walk-ins.
1221
+ </div>
1222
+
1223
+ <!-- Table Capacity Settings -->
1224
+ <div>
1225
+ <div class="grid grid-cols-[4fr_4fr_4fr_1fr]">
1226
+ <div class="bg-fm-color-neutral-gray-100 p-12">Table Type</div>
1227
+ <div class="bg-fm-color-neutral-gray-100 p-12">Guest Range</div>
1228
+ <div class="bg-fm-color-neutral-gray-100 p-12 flex items-center gap-4">
1229
+ Available Table Count
1230
+ <FmTooltip :content="'Adjust how many tables can be booked for each time slot'">
1231
+ <FmIcon name="info" outline size="sm" class="cursor-pointer" />
1232
+ </FmTooltip>
1233
+ </div>
1234
+ <div class="bg-fm-color-neutral-gray-100 p-12"></div>
1235
+ </div>
1236
+ <div
1237
+ class="grid grid-cols-[4fr_4fr_4fr_1fr] items-center gap-8"
1238
+ v-for="tier in rangeSetting.capacityTiers"
1239
+ :key="tier._id"
1240
+ >
1241
+ <div class="p-8">{{ tier.minPax }}{{ tier.maxPax ? `-${tier.maxPax}` : '+' }} pax</div>
1242
+ <div class="flex items-center gap-4 pr-24">
1243
+ <FmStepperField v-model="tier.minPax" :min="1" />
1244
+ <div>to</div>
1245
+ <FmStepperField
1246
+ :model-value="tier.maxPax ?? null"
1247
+ @update:model-value="(v) => (tier.maxPax = v)"
1248
+ :min="tier.minPax"
1249
+ />
1250
+ </div>
1251
+ <div class="flex items-center gap-8">
1252
+ <FmButton
1253
+ variant="tertiary"
1254
+ icon="remove"
1255
+ @click="tier.capacity--"
1256
+ :disabled="tier.capacity <= 1"
1257
+ class="!rounded-full !w-10 !h-10 !bg-fm-color-neutral-gray-100"
1258
+ />
1259
+ <div class="w-32 text-center">{{ tier.capacity }}</div>
1260
+ <FmButton
1261
+ variant="tertiary"
1262
+ icon="add"
1263
+ @click="tier.capacity++"
1264
+ class="!rounded-full !w-10 !h-10 !bg-fm-color-neutral-gray-100"
1265
+ />
1266
+
1267
+ <!-- <FmStepperField :model-value="tier.capacity ?? null" @update:model-value="(v) => (tier.capacity = v)" /> -->
1268
+ </div>
1269
+ <FmButton
1270
+ icon="delete"
1271
+ variant="plain"
1272
+ @click="
1273
+ rangeSetting.capacityTiers.splice(rangeSetting.capacityTiers.indexOf(tier), 1)
1274
+ "
1275
+ />
1276
+ </div>
1277
+ <div>
1278
+ <FmButton
1279
+ label="Add table type"
1280
+ icon="add"
1281
+ variant="plain"
1282
+ @click="
1283
+ rangeSetting.capacityTiers.push({
1284
+ _id: generateCapacityTierId(),
1285
+ minPax: Math.max(1, ([...rangeSetting.capacityTiers].pop()?.maxPax ?? 0) + 1),
1286
+ maxPax: null,
1287
+ capacity: 0
1288
+ })
1289
+ "
1290
+ />
1291
+ </div>
1292
+ </div>
1293
+ </div>
1294
+
1295
+ <div>
1296
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-16">Preorder</div>
1297
+ <FmSwitch
1298
+ label-placement="right"
1299
+ :label="'Enable preorder'"
1300
+ :sublabel="'Enable this to allow guests to place preorders when making a reservation. This allows them to order food in advance.'"
1301
+ :model-value="rangeSetting.enablePreorder"
1302
+ @update:model-value="(v: boolean) => (rangeSetting.enablePreorder = v)"
1303
+ />
1304
+ </div>
1305
+
1306
+ <div class="flex flex-col">
1307
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">Guest Preferences</div>
1308
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
1309
+ Allow guests to select preferences during reservation, such as seating preferences,
1310
+ allergies, or other notes.
1311
+ </div>
1312
+
1313
+ <FmSwitch
1314
+ class="mb-8"
1315
+ label="Enable preferences"
1316
+ label-placement="right"
1317
+ :model-value="rangeSetting.preferences.length > 0"
1318
+ @update:model-value="
1319
+ (v) => {
1320
+ if (v == true) {
1321
+ rangeSetting.preferences = [
1322
+ {
1323
+ name: '',
1324
+ type: 'radio',
1325
+ options: []
1326
+ }
1327
+ ]
1328
+ } else {
1329
+ rangeSetting.preferences = []
1330
+ }
1331
+ }
1332
+ "
1333
+ />
1334
+
1335
+ <FmCard
1336
+ v-for="(preference, pIndex) in rangeSetting.preferences"
1337
+ class="p-16 mb-12"
1338
+ variant="outlined"
1339
+ :key="pIndex"
1340
+ >
1341
+ <div class="fm-typo-en-body-lg-600 mb-8">Category title</div>
1342
+ <div class="grid grid-cols-[6fr_3fr_1fr] items-start gap-24 mb-24">
1343
+ <div>
1344
+ <FmTextField v-model="preference.name" placeholder="Enter a name" />
1345
+ <div class="fm-typo-en-body-md-400 text-fm-color-typo-tertiary mt-8">
1346
+ This title will be shown to guests during reservation.
1347
+ </div>
1348
+ </div>
1349
+ <FmSelect
1350
+ v-model="preference.type"
1351
+ :items="[
1352
+ { label: 'Single choice', value: 'radio' },
1353
+ { label: 'Multiple choice', value: 'checkbox' }
1354
+ ]"
1355
+ />
1356
+ <FmButton variant="tertiary" icon="delete" @click="removePreference(pIndex)" />
1357
+ </div>
1358
+
1359
+ <div v-if="preference.type == 'checkbox' || preference.type == 'radio'">
1360
+ <div class="mb-8 fm-typo-en-body-md-600">Options</div>
1361
+ <div
1362
+ v-for="(option, oIndex) in preference.options"
1363
+ :key="oIndex"
1364
+ class="flex items-center gap-4 gap-y-8 mb-8"
1365
+ >
1366
+ <div class="flex items-center w-full">
1367
+ <FmCheckbox
1368
+ v-if="preference.type == 'checkbox'"
1369
+ disabled
1370
+ :value="option"
1371
+ :model-value="false"
1372
+ readonly
1373
+ class="mr-8"
1374
+ />
1375
+ <FmRadio
1376
+ v-if="preference.type == 'radio'"
1377
+ disabled
1378
+ :value="option"
1379
+ :model-value="false"
1380
+ readonly
1381
+ />
1382
+
1383
+ <!-- Editable option label -->
1384
+ <input
1385
+ v-if="isEditingOption(pIndex, oIndex)"
1386
+ type="text"
1387
+ class="flex-1 outline-none border-b-2 border-fm-color-primary px-4 py-2"
1388
+ :value="option"
1389
+ @blur="
1390
+ (e) => saveOptionEdit(pIndex, oIndex, (e.target as HTMLInputElement).value)
1391
+ "
1392
+ @keyup.enter="
1393
+ (e) => saveOptionEdit(pIndex, oIndex, (e.target as HTMLInputElement).value)
1394
+ "
1395
+ ref="optionInput"
1396
+ />
1397
+ <div
1398
+ v-else-if="option != 'Other'"
1399
+ class="flex-1 cursor-pointer hover:bg-fm-color-neutral-gray-100 px-4 py-2 rounded transition-colors"
1400
+ @click="startEditingOption(pIndex, oIndex)"
1401
+ title="Click to edit"
1402
+ >
1403
+ {{ option }}
1404
+ </div>
1405
+ <div
1406
+ v-else-if="option == 'Other'"
1407
+ class="flex-1 px-4 py-2 border-b border-fm-color-neutral-gray-200"
1408
+ >
1409
+ Other:
1410
+ </div>
1411
+ </div>
1412
+ <FmButton
1413
+ variant="plain"
1414
+ icon="close"
1415
+ :class="{ 'opacity-0 pointer-events-nonex': oIndex == 0 }"
1416
+ @click="oIndex != 0 && removePreferenceOption(preference, option)"
1417
+ />
1418
+ </div>
1419
+ <div class="flex items-center">
1420
+ <FmCheckbox
1421
+ v-if="preference.type == 'checkbox'"
1422
+ disabled
1423
+ :value="null"
1424
+ :model-value="false"
1425
+ readonly
1426
+ />
1427
+ <div v-if="preference.type == 'radio'" class="flex items-center">
1428
+ <FmRadio disabled :value="null" :model-value="false" readonly />
1429
+ </div>
1430
+ <div class="flex items-center w-full">
1431
+ <FmButton
1432
+ variant="tertiary"
1433
+ class="text-fm-color-typo-secondary"
1434
+ label="add option"
1435
+ @click="
1436
+ addPreferenceOption(preference, `Option ${preference.options.length + 1}`)
1437
+ "
1438
+ >
1439
+ <template #default>
1440
+ <div>Add option</div>
1441
+ </template>
1442
+ </FmButton>
1443
+ <template v-if="!preference.options.includes('Other')">
1444
+ <div>or</div>
1445
+ <FmButton
1446
+ variant="plain"
1447
+ label='add "Other"'
1448
+ @click="addPreferenceOption(preference, 'Other')"
1449
+ />
1450
+ </template>
1451
+ </div>
1452
+ </div>
1453
+ </div>
1454
+ </FmCard>
1455
+
1456
+ <FmCard
1457
+ variant="outlined"
1458
+ class="border-dashed p-16"
1459
+ v-if="rangeSetting.preferences.length"
1460
+ >
1461
+ <FmButton
1462
+ label="Add another preference category"
1463
+ icon="add"
1464
+ variant="plain"
1465
+ class="border-1 border-fm-color-primary rounded-lg"
1466
+ @click="addPreference"
1467
+ />
1468
+ </FmCard>
1469
+ </div>
1470
+
1471
+ <div class="flex flex-col">
1472
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">Guest Message</div>
1473
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
1474
+ Add important information or notes guests should know before their visit.
1475
+ </div>
1476
+
1477
+ <FmSwitch
1478
+ class="mb-16"
1479
+ label="Enable guest message"
1480
+ label-placement="right"
1481
+ :model-value="
1482
+ rangeSetting.guestMessage !== null && rangeSetting.guestMessage !== undefined
1483
+ "
1484
+ @update:model-value="toggleGuestMessage"
1485
+ />
1486
+
1487
+ <div v-if="rangeSetting.guestMessage !== null && rangeSetting.guestMessage !== undefined">
1488
+ <div class="mb-8 fm-typo-en-body-md-600">Message</div>
1489
+ <FmTextarea
1490
+ v-model="rangeSetting.guestMessage"
1491
+ :maxLength="600"
1492
+ placeholder="Please take note of the following important details before making a reservation:"
1493
+ class="mb-4"
1494
+ />
1495
+ <div class="text-right text-fm-color-typo-tertiary fm-typo-en-body-sm-400">
1496
+ {{ rangeSetting.guestMessage?.length || 0 }} / 600 characters
1497
+ </div>
1498
+ </div>
1499
+ </div>
1500
+
1501
+ <div class="flex flex-col">
1502
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">Cancellation Policy</div>
1503
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
1504
+ Set the rules guests should follow when cancelling a reservation.
1505
+ </div>
1506
+
1507
+ <FmSwitch
1508
+ class="mb-16"
1509
+ label="Enable cancellation policy"
1510
+ label-placement="right"
1511
+ :model-value="
1512
+ rangeSetting.cancellationPolicy !== null &&
1513
+ rangeSetting.cancellationPolicy !== undefined
1514
+ "
1515
+ @update:model-value="toggleCancellationPolicy"
1516
+ />
1517
+
1518
+ <div
1519
+ v-if="
1520
+ rangeSetting.cancellationPolicy !== null &&
1521
+ rangeSetting.cancellationPolicy !== undefined
1522
+ "
1523
+ >
1524
+ <div class="mb-8 fm-typo-en-body-md-600">Message</div>
1525
+ <FmTextarea
1526
+ v-model="rangeSetting.cancellationPolicy"
1527
+ :maxLength="200"
1528
+ placeholder="Cancellation Policy"
1529
+ class="mb-4"
1530
+ />
1531
+ <div class="text-right text-fm-color-typo-tertiary fm-typo-en-body-sm-400">
1532
+ {{ rangeSetting.cancellationPolicy?.length || 0 }} / 200 characters
1533
+ </div>
1534
+ </div>
1535
+ </div>
1536
+ </template>
1537
+
1538
+ <!-- Save Button -->
1539
+ <div class="flex mt-5">
1540
+ <FmButton
1541
+ variant="primary"
1542
+ :label="t('order.saveAllChanges')"
1543
+ class="mr-auto"
1544
+ @click="updateReservationSetting"
1545
+ :loading="isSaving"
1546
+ />
1547
+ </div>
1548
+ </div>
1549
+ </template>