@feedmepos/mf-order-setting 0.0.91 → 0.0.93

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 (108) hide show
  1. package/dist/DisplaySystemView-BMRCs7o8.js +8891 -0
  2. package/dist/{KioskDevicesView-Bz6seroM.js → KioskDevicesView-BCZOqou3.js} +1 -1
  3. package/dist/{KioskDevicesView.vue_vue_type_script_setup_true_lang-I39sE8Pe.js → KioskDevicesView.vue_vue_type_script_setup_true_lang-4wFO9co5.js} +3 -3
  4. package/dist/{KioskSettingView-Cajjh4Zm.js → KioskSettingView-DIvpRwFV.js} +3 -3
  5. package/dist/{KioskView-BPSbsMi5.js → KioskView-w4txC9m2.js} +5 -5
  6. package/dist/{OrderSettingsView-jPxZ3JDM.js → OrderSettingsView-BPuBA2QC.js} +28615 -27747
  7. package/dist/{app-DIhv5ssA.js → app-xZAaURng.js} +4594 -3733
  8. package/dist/app.js +1 -1
  9. package/dist/common/order-platform/index.d.ts +20 -0
  10. package/dist/featureFlag-Cbj0uMaM.js +846 -0
  11. package/dist/frontend/mf-order/src/api/brandkit/index.d.ts +3 -0
  12. package/dist/frontend/mf-order/src/api/reservation/index.d.ts +8 -0
  13. package/dist/frontend/mf-order/src/app.d.ts +752 -0
  14. package/dist/frontend/mf-order/src/composables/featureFlag.d.ts +7 -0
  15. package/dist/frontend/mf-order/src/composables/useBusinessFeatureFlag.d.ts +4 -0
  16. package/dist/frontend/mf-order/src/composables/useLegacyOdsAccess.d.ts +4 -0
  17. package/dist/frontend/mf-order/src/composables/useMiniProgramPermission.d.ts +5 -0
  18. package/dist/frontend/mf-order/src/main.d.ts +752 -0
  19. package/dist/frontend/mf-order/src/modules/display-system/legacy-ods/interface.d.ts +80 -0
  20. package/dist/frontend/mf-order/src/stores/order-setting/index.d.ts +3 -3
  21. package/dist/frontend/mf-order/src/stores/restaurant/index.d.ts +3 -0
  22. package/dist/frontend/mf-order/src/views/display-system/legacy-ods/ColorOverrideDialog.vue.d.ts +26 -0
  23. package/dist/frontend/mf-order/src/views/display-system/legacy-ods/ColorSelection.vue.d.ts +15 -0
  24. package/dist/frontend/mf-order/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.vue.d.ts +24 -0
  25. package/dist/frontend/mf-order/src/views/display-system/legacy-ods/LegacyOdsTemplateList.vue.d.ts +4 -0
  26. package/dist/frontend/mf-order/src/views/display-system/view-selection.d.ts +18 -0
  27. package/dist/frontend/mf-order/src/views/order-settings/reservation/CopySettingsSheet.vue.d.ts +186 -0
  28. package/dist/frontend/mf-order/src/views/order-settings/reservation/CustomTimePicker.vue.d.ts +11 -0
  29. package/dist/frontend/mf-order/src/views/order-settings/reservation/ReservationSetting.vue.d.ts +2 -0
  30. package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
  31. package/dist/{index-vaSLX6r3.js → index-BAFwfvqv.js} +2 -2
  32. package/dist/{index-Clp_YRJ2.js → index-BxQKdXyN.js} +1 -1
  33. package/dist/{index-CmqNrkNl.js → index-Cipbdi6i.js} +1 -1
  34. package/dist/order.dto-yLICIGDQ.js +41528 -0
  35. package/dist/package/entity/delivery/delivery.dto.d.ts +7 -0
  36. package/dist/package/entity/food-court/food-court.dto.d.ts +315 -264
  37. package/dist/package/entity/food-court/order-v2.do.d.ts +231 -0
  38. package/dist/package/entity/food-court/order-v2.do.spec.d.ts +1 -0
  39. package/dist/package/entity/incoming-order/incoming-order-to-bill.dto.d.ts +15 -6
  40. package/dist/package/entity/index.d.ts +7 -0
  41. package/dist/package/entity/index.frontend.d.ts +2 -0
  42. package/dist/package/entity/member/member.dto.d.ts +2 -2
  43. package/dist/package/entity/order/dine-in/dine-in.do.d.ts +44 -9
  44. package/dist/package/entity/order/dine-in/dine-in.dto.d.ts +17 -3
  45. package/dist/package/entity/order/menu/menu.dto.d.ts +33 -3
  46. package/dist/package/entity/order/order-item/order-item.dto.d.ts +18 -18
  47. package/dist/package/entity/order/order.do.d.ts +87 -12
  48. package/dist/package/entity/order/order.dto.d.ts +762 -358
  49. package/dist/package/entity/order/order.enum.d.ts +1 -1
  50. package/dist/package/entity/order-platform/foodpanda/foodpanda-order.dto.d.ts +7 -0
  51. package/dist/package/entity/order-platform/grabfood/grabfood-order.do.d.ts +7 -0
  52. package/dist/package/entity/order-platform/menu.dto.d.ts +10 -10
  53. package/dist/package/entity/order-platform/order-platform.dto.d.ts +92 -0
  54. package/dist/package/entity/order-platform/shopeefood/shopeefood-order.do.d.ts +7 -0
  55. package/dist/package/entity/order-platform/tng/tng-menu.do.d.ts +6 -6
  56. package/dist/package/entity/order-setting/order-setting.do.d.ts +866 -5
  57. package/dist/package/entity/order-setting/order-setting.dto.d.ts +10 -10
  58. package/dist/package/entity/order-setting/reservationV2/reservation.do.d.ts +1269 -0
  59. package/dist/package/entity/order-setting/sms/sms.do.d.ts +3 -3
  60. package/dist/package/entity/order-setting/sms/sms.dto.d.ts +3 -3
  61. package/dist/package/entity/queue/queue.dto.d.ts +175 -121
  62. package/dist/package/entity/reservation/reservation-pos.do.d.ts +222 -0
  63. package/dist/package/entity/reservation/reservation.do.d.ts +205 -0
  64. package/dist/package/entity/reservation/reservation.dto.d.ts +428 -0
  65. package/dist/package/entity/reservation/reservation.enum.d.ts +3 -0
  66. package/dist/package/entity/reservation/reservation.utils.d.ts +152 -0
  67. package/dist/package/entity/restaurant/restaurant.dto.d.ts +39 -24
  68. package/dist/package/entity/user/user.do.d.ts +2 -2
  69. package/dist/package/entity/websocket/websocket.dto.d.ts +161 -21
  70. package/dist/package/entity/websocket/websocket.enum.d.ts +1 -1
  71. package/dist/style.css +1 -1
  72. package/env.d.ts +2 -0
  73. package/package.json +3 -2
  74. package/src/api/brandkit/index.ts +24 -0
  75. package/src/api/reservation/index.ts +25 -0
  76. package/src/assets/images/not-found.png +0 -0
  77. package/src/composables/featureFlag.ts +22 -0
  78. package/src/composables/useBusinessFeatureFlag.ts +44 -0
  79. package/src/composables/useLegacyOdsAccess.ts +35 -0
  80. package/src/composables/useMiniProgramPermission.ts +19 -0
  81. package/src/composables/useQrOrderingEnabled.ts +1 -21
  82. package/src/locales/en-US.json +188 -0
  83. package/src/locales/ja-JP.json +188 -0
  84. package/src/locales/th-TH.json +188 -0
  85. package/src/locales/zh-CN.json +188 -0
  86. package/src/modules/display-system/legacy-ods/interface.ts +108 -0
  87. package/src/router/routes.ts +1 -1
  88. package/src/views/display-system/DisplaySystemView.vue +82 -11
  89. package/src/views/display-system/legacy-ods/ColorOverrideDialog.vue +223 -0
  90. package/src/views/display-system/legacy-ods/ColorSelection.vue +67 -0
  91. package/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.spec.ts +185 -0
  92. package/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.vue +1677 -0
  93. package/src/views/display-system/legacy-ods/LegacyOdsTemplateList.spec.ts +124 -0
  94. package/src/views/display-system/legacy-ods/LegacyOdsTemplateList.vue +297 -0
  95. package/src/views/display-system/legacy-ods/brandkit.spec.ts +67 -0
  96. package/src/views/display-system/view-selection.spec.ts +93 -0
  97. package/src/views/display-system/view-selection.ts +54 -0
  98. package/src/views/order-settings/OrderSettingsView.vue +9 -2
  99. package/src/views/order-settings/delivery/delivery.spec.ts +21 -0
  100. package/src/views/order-settings/delivery/delivery.ts +2 -51
  101. package/src/views/order-settings/dine-in/DineInSetting.vue +11 -1
  102. package/src/views/order-settings/reservation/CopySettingsSheet.vue +256 -0
  103. package/src/views/order-settings/reservation/CustomTimePicker.vue +311 -0
  104. package/src/views/order-settings/reservation/ReservationSetting.vue +1549 -0
  105. package/vitest.config.ts +2 -1
  106. package/dist/DisplaySystemView-n7Kqdw-h.js +0 -6564
  107. package/dist/_plugin-vue_export-helper-CHgC5LLL.js +0 -9
  108. package/dist/order.dto-DV1qL3Q_.js +0 -40301
@@ -0,0 +1,108 @@
1
+ export type RecursivePartial<T> = {
2
+ [P in keyof T]?: T[P] extends (...args: any[]) => any
3
+ ? T[P]
4
+ : T[P] extends readonly (infer U)[]
5
+ ? ReadonlyArray<RecursivePartial<U>>
6
+ : T[P] extends (infer U)[]
7
+ ? RecursivePartial<U>[]
8
+ : T[P] extends object
9
+ ? RecursivePartial<T[P]>
10
+ : T[P]
11
+ }
12
+
13
+ export type BaseBrandKit = {
14
+ colors: {
15
+ primary: string
16
+ secondary: string
17
+ accent: string
18
+ background: {
19
+ base: string
20
+ elevated: string
21
+ }
22
+ neutral: {
23
+ light: string
24
+ mid: string
25
+ dark: string
26
+ }
27
+ status: {
28
+ success: string
29
+ warning: string
30
+ error: string
31
+ info?: string
32
+ }
33
+ text: {
34
+ onPrimary: string
35
+ primary: string
36
+ secondary: string
37
+ tertiary: string
38
+ }
39
+ }
40
+ }
41
+
42
+ export type OdsConfigEntry = {
43
+ label?: string
44
+ isDefault: boolean
45
+ restaurantIds?: string[]
46
+ brandkitOverride?: RecursivePartial<BaseBrandKit>
47
+ layout?: OdsLayoutConfig
48
+ display?: OdsDisplayConfig
49
+ }
50
+
51
+ export type OdsFooterMode = 'feedme' | 'settingsIcon'
52
+
53
+ export type OrderGroupingMode = 'type' | 'status'
54
+
55
+ export type OrderStatusBoardLayout = 'horizontal' | 'vertical'
56
+
57
+ export type StatusSectionTemplateName =
58
+ 'default' | 'bordered-card' | 'floating-tab' | 'centered-pill'
59
+
60
+ export type OdsLayoutConfig = {
61
+ background?: {
62
+ url: string
63
+ size?: string
64
+ position?: string
65
+ } | null
66
+ sidePanel?: {
67
+ url: string
68
+ position: 'left' | 'right'
69
+ widthPercent: number
70
+ objectFit?: string
71
+ } | null
72
+ boardWidthPercent?: number
73
+ boardAlign?: 'left' | 'right' | 'center'
74
+ footerMode?: OdsFooterMode
75
+ }
76
+
77
+ export type OdsDisplayConfig = {
78
+ groupBy: OrderGroupingMode
79
+ statusLayout: OrderStatusBoardLayout
80
+ statusSlidingPeriod: number
81
+ currentOrder?: {
82
+ enabled?: boolean
83
+ }
84
+ showProgressBar: boolean
85
+ statusColumnCount: number
86
+ showDeliveryIcons: boolean
87
+ showOrderTypeTabs: boolean
88
+ showOrderCount: boolean
89
+ showEstimatedTime: boolean
90
+ hideCompletedOrders?: boolean
91
+ statusTemplate?: StatusSectionTemplateName
92
+ }
93
+
94
+ export type BrandKit = Partial<BaseBrandKit> & {
95
+ ods?: OdsConfigEntry[]
96
+ [key: string]: unknown
97
+ }
98
+
99
+ export function migrateOdsToArray(ods: unknown, defaultLabel: string): OdsConfigEntry[] {
100
+ if (!ods) return []
101
+ if (Array.isArray(ods)) return ods
102
+ const legacy = ods as Partial<OdsConfigEntry>
103
+ const entry: OdsConfigEntry = { label: defaultLabel, isDefault: true }
104
+ if (legacy.brandkitOverride) entry.brandkitOverride = legacy.brandkitOverride
105
+ if (legacy.layout) entry.layout = legacy.layout
106
+ if (legacy.display) entry.display = legacy.display
107
+ return [entry]
108
+ }
@@ -57,7 +57,7 @@ const routes: RouteRecordRaw[] = [
57
57
  component: withPermission(
58
58
  defineAsyncComponent(() => import('@/views/display-system/DisplaySystemView.vue')),
59
59
  ),
60
- meta: permissionMeta([manageOrderSetting]),
60
+ meta: permissionMeta([{ ...manageOrderSetting, coverSubject: Permission.Subject.Business.miniProgram_brandkit }]),
61
61
  },
62
62
  ];
63
63
 
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref, watch } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
3
4
  import { useCoreStore, useI18n } from '@feedmepos/mf-common'
4
5
  import type { FmTabProps, ISelectItem } from '@feedmepos/ui-library'
5
6
 
@@ -10,21 +11,83 @@ import { useDisplaySystemStore } from '@/stores/display-system'
10
11
  import DisplayScreenEditor from './DisplayScreenEditor.vue'
11
12
  import DisplayTemplateEditor from './DisplayTemplateEditor.vue'
12
13
  import DisplayLayoutMap from './DisplayLayoutMap.vue'
14
+ import { useOrderSettingPermission } from '@/composables/useOrderSettingPermission'
15
+ import { useMiniProgramPermission } from '@/composables/useMiniProgramPermission'
16
+ import { getBusinessBrandkit } from '@/api/brandkit'
17
+ import { availableDisplaySystemViews, defaultDisplaySystemView, isPosV8, requestedDisplaySystemView, type DisplaySystemView } from './view-selection'
18
+ import LegacyOdsTemplateList from './legacy-ods/LegacyOdsTemplateList.vue'
13
19
 
14
- type Tab = 'screens' | 'templates'
15
20
  const { t } = useI18n()
16
21
  const { showError, showSuccess } = useSnackbarFunctions()
22
+ const route = useRoute()
23
+ const router = useRouter()
17
24
  const core = useCoreStore()
18
25
  const store = useDisplaySystemStore()
19
- const selectedTab = ref<Tab>('screens')
26
+ const { canManageOrderSetting } = useOrderSettingPermission()
27
+ const { canManageBrandkit } = useMiniProgramPermission()
28
+ const hasLegacyOdsConfigs = ref(false)
29
+ const availableViews = computed(() =>
30
+ availableDisplaySystemViews({
31
+ canManageOrderSetting: canManageOrderSetting.value,
32
+ canManageBrandkit: canManageBrandkit.value,
33
+ outlets: core.restaurants.value,
34
+ hasLegacyOdsConfigs: hasLegacyOdsConfigs.value
35
+ })
36
+ )
37
+ const selectedView = ref<DisplaySystemView>()
38
+ // The default is settled once the outlets and the current outlet have loaded;
39
+ // after that, picking another outlet on the Screens tab must not swap the view.
40
+ let viewSettled = false
41
+ watch(
42
+ [availableViews, () => core.restaurants.value.length, () => core.currentRestaurant.value?._id],
43
+ ([available, outletCount, currentOutletId]) => {
44
+ const requested = requestedDisplaySystemView(route.query)
45
+ if (requested && available.includes(requested)) {
46
+ selectedView.value = requested
47
+ viewSettled = true
48
+ } else if (!viewSettled || !selectedView.value || !available.includes(selectedView.value)) {
49
+ selectedView.value = defaultDisplaySystemView(available, core.restaurants.value, currentOutletId)
50
+ viewSettled = Boolean(outletCount && currentOutletId)
51
+ }
52
+ },
53
+ { immediate: true }
54
+ )
55
+ const otherView = computed(() => availableViews.value.find((view) => view !== selectedView.value))
56
+ function switchView(view: DisplaySystemView) {
57
+ selectedView.value = view
58
+ viewSettled = true
59
+ const { tab: _tab, ...query } = route.query
60
+ router.replace({ query: { ...query, view } })
61
+ }
62
+ // Only an all-v8 business needs this: one with a v7 outlet already gets the v7 view.
63
+ watch(
64
+ [() => core.currentBusiness.value?._id, canManageBrandkit, () => core.restaurants.value.some((outlet) => outlet.posVersion && !isPosV8(outlet.posVersion))],
65
+ async ([businessId, canManage, hasV7Outlet]) => {
66
+ hasLegacyOdsConfigs.value = false
67
+ if (!businessId || !canManage || hasV7Outlet) return
68
+ try {
69
+ const brandkit = await getBusinessBrandkit(businessId, core.namespace.value)
70
+ hasLegacyOdsConfigs.value = Boolean(Array.isArray(brandkit.ods) ? brandkit.ods.length : brandkit.ods)
71
+ } catch {
72
+ hasLegacyOdsConfigs.value = false
73
+ }
74
+ },
75
+ { immediate: true }
76
+ )
77
+ const selectedTab = ref<'screens' | 'templates'>('screens')
20
78
  const selectedRestaurantId = ref(core.currentRestaurant.value?._id ?? '')
21
79
  const templateEditorOpen = ref(false)
22
80
  const screenEditorOpen = ref(false)
23
81
  const editingTemplate = ref<DisplayTemplate>()
24
82
  const editingScreen = ref<DisplayScreen>()
25
83
  const saving = ref(false)
84
+ const legacyOdsList = ref<InstanceType<typeof LegacyOdsTemplateList>>()
26
85
 
27
86
  const tabs = computed<FmTabProps[]>(() => [{ label: t('order.displaySystem.screens'), value: 'screens' }, { label: t('order.displaySystem.templates'), value: 'templates' }])
87
+ const selectedRestaurant = computed(() =>
88
+ core.restaurants.value.find((item) => item._id === selectedRestaurantId.value)
89
+ )
90
+ const selectedRestaurantIsV8 = computed(() => isPosV8(selectedRestaurant.value?.posVersion))
28
91
  const restaurantItems = computed<ISelectItem[]>(() => core.restaurants.value.map((restaurant) => ({ label: restaurant.profile.name, value: restaurant._id })))
29
92
  const entitlement = computed(() => store.screenCapabilities)
30
93
  // Oldest screens keep the licence, so the newest lose it first. Merchants can
@@ -33,7 +96,7 @@ const unlicensedScreens = computed(() =>
33
96
  unlicensedScreenIds(store.screens, entitlement.value?.screenLimit ?? 0))
34
97
  const remainingScreens = computed(() => Math.max(0, (entitlement.value?.screenLimit ?? 0) - (entitlement.value?.screenUsed ?? 0)))
35
98
  const applicableTemplates = computed(() => store.templates.filter((template) => !template.restaurantIds?.length || template.restaurantIds.includes(selectedRestaurantId.value)))
36
- const canAddScreen = computed(() => Boolean(selectedRestaurantId.value && entitlement.value?.posV8 && remainingScreens.value > 0 && applicableTemplates.value.length))
99
+ const canAddScreen = computed(() => Boolean(selectedRestaurantId.value && selectedRestaurantIsV8.value && entitlement.value?.posV8 && remainingScreens.value > 0 && applicableTemplates.value.length))
37
100
 
38
101
  function templateName(id: string) { return store.templates.find((template) => template._id === id)?.label ?? 'Unknown template' }
39
102
  function orientationMismatch(screen: DisplayScreen) {
@@ -49,7 +112,7 @@ function layoutSummary(template: DisplayTemplate) {
49
112
  function isOnline(screen: DisplayScreen) { return Boolean(screen.lastSeenAt && Date.now() - new Date(screen.lastSeenAt).getTime() < 120_000) }
50
113
 
51
114
  async function loadContext() {
52
- if (!selectedRestaurantId.value) return
115
+ if (!canManageOrderSetting.value || !selectedRestaurantId.value) return
53
116
  try { await Promise.all([store.loadScreens(selectedRestaurantId.value), store.loadScreenCapabilities(selectedRestaurantId.value)]) }
54
117
  catch (error) { showError(error instanceof Error ? error.message : t('order.displaySystem.loadFailed')) }
55
118
  }
@@ -109,6 +172,7 @@ async function toggleStatus(screen: DisplayScreen) {
109
172
 
110
173
  onMounted(async () => {
111
174
  if (!selectedRestaurantId.value && core.restaurants.value.length) selectedRestaurantId.value = core.restaurants.value[0]._id
175
+ if (!canManageOrderSetting.value) return
112
176
  try { await store.loadTemplates() } catch (error) { showError(error instanceof Error ? error.message : t('order.displaySystem.loadFailed')) }
113
177
  await loadContext()
114
178
  })
@@ -116,14 +180,19 @@ watch(() => core.restaurants.value.length, async (length) => { if (!selectedRest
116
180
  </script>
117
181
 
118
182
  <template>
119
- <div class="py-[1.5rem]"><FmPageHead :title="t('order.displaySystem.title')" />
183
+ <div class="py-[1.5rem]"><FmPageHead :title="selectedView === 'v7' ? t('order.displaySystem.legacyOds.title') : t('order.displaySystem.title')" />
120
184
  <div class="px-[1.5rem]">
121
- <div class="display-toolbar"><p class="text-sm text-gray-500">{{ t('order.displaySystem.description') }}</p><FmButton icon="add" size="md" :label="selectedTab === 'screens' ? t('order.displaySystem.addScreen') : t('order.displaySystem.addTemplateAction')" :disabled="selectedTab === 'screens' && !canAddScreen" @click="selectedTab === 'screens' ? addScreen() : addTemplate()" /></div>
185
+ <div class="display-toolbar"><p class="text-sm text-gray-500">{{ selectedView === 'v7' ? t('order.displaySystem.legacyOds.description') : t('order.displaySystem.description') }}</p><div class="display-actions"><FmButton v-if="otherView" variant="plain" size="md" :label="otherView === 'v7' ? t('order.displaySystem.legacyOds.switchToLegacy') : t('order.displaySystem.legacyOds.switchToDisplaySystem')" @click="switchView(otherView)" /><FmButton v-if="selectedView === 'v7'" icon="add" size="md" :label="t('order.displaySystem.legacyOds.addConfiguration')" @click="legacyOdsList?.addOdsEntry()" /><FmButton v-else-if="selectedView === 'v8'" icon="add" size="md" :label="selectedTab === 'screens' ? t('order.displaySystem.addScreen') : t('order.displaySystem.addTemplateAction')" :disabled="selectedTab === 'screens' && !canAddScreen" @click="selectedTab === 'screens' ? addScreen() : addTemplate()" /></div></div>
186
+ <template v-if="selectedView === 'v8'">
122
187
  <!-- The outlet picker and its allowance belong to the Screens tab only:
123
188
  screens are bought and paired per outlet, while templates are
124
189
  business-wide and would read as outlet-scoped next to a picker. -->
125
190
  <div class="display-controls"><FmTabs v-model:model-value="selectedTab" :items="tabs" /><div v-if="selectedTab === 'screens'" class="display-outlet"><p v-if="entitlement" class="display-entitlement">{{ t('order.displaySystem.screensUsed', { used: entitlement.screenUsed, limit: entitlement.screenLimit }) }} · {{ entitlement.capabilities.join(', ') || t('order.displaySystem.noModulesLicensed') }}</p><FmSelect class="display-restaurant-select" :model-value="selectedRestaurantId" :items="restaurantItems" :placeholder="t('order.displaySystem.selectRestaurant')" searchable @update:model-value="selectRestaurant" /></div></div>
126
- <p v-if="selectedTab === 'screens' && !canAddScreen && !store.loadingScreenCapabilities" class="restriction-note">A POS v8 restaurant, an available total screen entitlement, and an applicable template are required.</p>
191
+ <div v-if="selectedTab === 'screens' && selectedRestaurant && !selectedRestaurantIsV8" class="restriction-note">
192
+ <p>{{ selectedRestaurant.posVersion ? t('order.displaySystem.posV8RequiredVersion', { version: selectedRestaurant.posVersion }) : t('order.displaySystem.posV8Required') }}</p>
193
+ <FmButton v-if="availableViews.includes('v7')" variant="plain" :label="t('order.displaySystem.legacyOds.openLegacyTab')" @click="switchView('v7')" />
194
+ </div>
195
+ <p v-else-if="selectedTab === 'screens' && !canAddScreen && !store.loadingScreenCapabilities" class="restriction-note">{{ applicableTemplates.length ? t('order.displaySystem.screenLimitReached') : t('order.displaySystem.noTemplatesDescription') }}</p>
127
196
 
128
197
  <section v-if="selectedTab === 'screens'" class="mt-12">
129
198
  <div v-if="store.loadingScreens" class="flex justify-center py-40"><FmCircularProgress size="lg" /></div>
@@ -132,17 +201,19 @@ watch(() => core.restaurants.value.length, async (length) => { if (!selectedRest
132
201
  <article v-for="screen in store.screens" :key="screen._id" class="resource-card"><div class="flex justify-between gap-12"><div class="min-w-0"><div class="flex flex-wrap items-center gap-8"><h3 class="truncate text-lg font-semibold">{{ screen.name }}</h3><span class="status-badge" :class="screen.status.toLowerCase()">{{ screen.status }}</span><span class="online-dot" :class="{ online: isOnline(screen) }">{{ isOnline(screen) ? t('order.displaySystem.online') : t('order.displaySystem.offline') }}</span><span v-if="unlicensedScreens.has(screen._id)" class="status-badge unlicensed">{{ t('order.displaySystem.unlicensed') }}</span></div><p class="mt-4 text-sm text-gray-500">{{ templateName(screen.templateId) }}</p><p v-if="orientationMismatch(screen)" class="mt-2 text-xs text-orange-700">Reported viewport orientation does not match the template.</p><p v-if="unlicensedScreens.has(screen._id)" class="mt-2 text-xs text-red-700">{{ t('order.displaySystem.unlicensedNote') }}</p></div><div class="flex gap-4"><FmButton :icon="screen.status === 'DISABLED' ? 'play_arrow' : 'pause'" variant="plain" @click="toggleStatus(screen)" /><FmButton icon="edit" variant="plain" @click="editScreen(screen)" /><FmButton icon="delete" variant="plain" @click="removeScreen(screen)" /></div></div><dl class="meta-grid"><div><dt>{{ t('order.displaySystem.deviceId') }}</dt><dd>{{ screen.deviceId || '—' }}</dd></div><div><dt>{{ t('order.displaySystem.lastSeen') }}</dt><dd>{{ screen.lastSeenAt ? new Date(screen.lastSeenAt).toLocaleString() : '—' }}</dd></div></dl></article>
133
202
  </div>
134
203
  </section>
135
- <section v-else class="mt-12">
204
+ <section v-else-if="selectedTab === 'templates'" class="mt-12">
136
205
  <div v-if="store.loadingTemplates" class="flex justify-center py-40"><FmCircularProgress size="lg" /></div>
137
206
  <div v-else-if="!store.templates.length" class="empty-state"><strong>{{ t('order.displaySystem.noTemplates') }}</strong><span>{{ t('order.displaySystem.noTemplatesDescription') }}</span></div>
138
207
  <div v-else class="grid grid-cols-1 gap-16 xl:grid-cols-2"><article v-for="template in store.templates" :key="template._id" class="resource-card"><div class="flex justify-between gap-12"><div class="min-w-0"><div class="flex items-center gap-8"><h3 class="truncate text-lg font-semibold">{{ template.label }}</h3><span class="mode-badge">{{ template.orientation }}</span></div><p class="mt-2 text-xs text-gray-500">{{ templateCapabilities(template).join(', ') || 'INFO' }}</p></div><div class="flex gap-4"><FmButton icon="edit" variant="plain" @click="editTemplate(template)" /><FmButton icon="content_copy" variant="plain" @click="duplicateTemplate(template)" /><FmButton icon="delete" variant="plain" @click="removeTemplate(template)" /></div></div><div class="layout-thumb"><DisplayLayoutMap :node="template.root as never" selected-zone-id="" :start-index="1" /></div><p class="thumb-caption">{{ t('order.displaySystem.areasCount', { count: layoutSummary(template).areas }) }} · {{ t('order.displaySystem.modulesCount', { count: layoutSummary(template).modules }) }}</p></article></div>
139
208
  </section>
209
+ </template>
210
+ <LegacyOdsTemplateList v-else-if="selectedView === 'v7'" ref="legacyOdsList" />
140
211
  </div>
141
212
  </div>
142
- <DisplayTemplateEditor v-model="templateEditorOpen" :template="editingTemplate" :preview-restaurant-id="selectedRestaurantId" :outlet-capabilities="entitlement?.capabilities ?? []" :saving="saving" @save="saveTemplate" />
143
- <DisplayScreenEditor v-if="selectedRestaurantId" v-model="screenEditorOpen" :screen="editingScreen" :templates="applicableTemplates" :restaurant-id="selectedRestaurantId" :saving="saving" @save="saveScreen" />
213
+ <DisplayTemplateEditor v-if="canManageOrderSetting" v-model="templateEditorOpen" :template="editingTemplate" :preview-restaurant-id="selectedRestaurantId" :outlet-capabilities="entitlement?.capabilities ?? []" :saving="saving" @save="saveTemplate" />
214
+ <DisplayScreenEditor v-if="canManageOrderSetting && selectedRestaurantId" v-model="screenEditorOpen" :screen="editingScreen" :templates="applicableTemplates" :restaurant-id="selectedRestaurantId" :saving="saving" @save="saveScreen" />
144
215
  </template>
145
216
 
146
217
  <style scoped>
147
- .display-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e5e7eb;padding-bottom:14px}.display-controls{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:12px}.display-restaurant-select{width:min(360px,100%)}.display-outlet{display:flex;align-items:center;gap:16px}.display-entitlement{color:#6b7280;font-size:12px;white-space:nowrap}.restriction-note{margin-top:12px;border:1px solid #fed7aa;border-radius:10px;padding:10px 12px;color:#9a3412;background:#fff7ed;font-size:13px}.empty-state{display:grid;justify-items:center;gap:6px;border:1px dashed #d1d5db;border-radius:12px;padding:56px 20px;color:#6b7280;text-align:center}.empty-state strong{color:#111827}.layout-thumb{display:flex;height:auto;min-height:96px;margin-top:16px;border-radius:10px;background:#f3f4f6;padding:5px;pointer-events:none}.thumb-caption{margin-top:8px;color:#6b7280;font-size:11px}.resource-card{border:1px solid #e5e7eb;border-radius:12px;padding:20px;background:white;box-shadow:0 2px 8px #0000000a}.mode-badge,.status-badge{border-radius:999px;padding:3px 8px;background:#f3f4f6;font-size:11px;font-weight:700}.status-badge.active{color:#166534;background:#dcfce7}.status-badge.disabled{color:#991b1b;background:#fee2e2}.status-badge.unlicensed{color:#b42318;background:#fef3f2;border:1px solid #fecdca}.online-dot{color:#6b7280;font-size:11px}.online-dot::before{display:inline-block;width:7px;height:7px;margin-right:5px;border-radius:50%;background:#9ca3af;content:''}.online-dot.online{color:#15803d}.online-dot.online::before{background:#22c55e}.meta-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px}.meta-grid dt{color:#6b7280;font-size:11px}.meta-grid dd{overflow:hidden;margin-top:2px;font-size:12px;text-overflow:ellipsis;white-space:nowrap}@media(max-width:640px){.display-toolbar{align-items:flex-start}.display-controls{align-items:stretch;flex-direction:column}.display-outlet{align-items:stretch;flex-direction:column;gap:8px}.display-restaurant-select{width:100%}}
218
+ .display-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e5e7eb;padding-bottom:14px}.display-actions{display:flex;flex-shrink:0;align-items:center;gap:8px}.display-controls{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:12px}.display-restaurant-select{width:min(360px,100%)}.display-outlet{display:flex;align-items:center;gap:16px}.display-entitlement{color:#6b7280;font-size:12px;white-space:nowrap}.restriction-note{margin-top:12px;border:1px solid #fed7aa;border-radius:10px;padding:10px 12px;color:#9a3412;background:#fff7ed;font-size:13px}.empty-state{display:grid;justify-items:center;gap:6px;border:1px dashed #d1d5db;border-radius:12px;padding:56px 20px;color:#6b7280;text-align:center}.empty-state strong{color:#111827}.layout-thumb{display:flex;height:auto;min-height:96px;margin-top:16px;border-radius:10px;background:#f3f4f6;padding:5px;pointer-events:none}.thumb-caption{margin-top:8px;color:#6b7280;font-size:11px}.resource-card{border:1px solid #e5e7eb;border-radius:12px;padding:20px;background:white;box-shadow:0 2px 8px #0000000a}.mode-badge,.status-badge{border-radius:999px;padding:3px 8px;background:#f3f4f6;font-size:11px;font-weight:700}.status-badge.active{color:#166534;background:#dcfce7}.status-badge.disabled{color:#991b1b;background:#fee2e2}.status-badge.unlicensed{color:#b42318;background:#fef3f2;border:1px solid #fecdca}.online-dot{color:#6b7280;font-size:11px}.online-dot::before{display:inline-block;width:7px;height:7px;margin-right:5px;border-radius:50%;background:#9ca3af;content:''}.online-dot.online{color:#15803d}.online-dot.online::before{background:#22c55e}.meta-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px}.meta-grid dt{color:#6b7280;font-size:11px}.meta-grid dd{overflow:hidden;margin-top:2px;font-size:12px;text-overflow:ellipsis;white-space:nowrap}@media(max-width:640px){.display-toolbar{align-items:flex-start}.display-controls{align-items:stretch;flex-direction:column}.display-outlet{align-items:stretch;flex-direction:column;gap:8px}.display-restaurant-select{width:100%}}
148
219
  </style>
@@ -0,0 +1,223 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import ColorSelection from './ColorSelection.vue'
4
+ import {
5
+ type BaseBrandKit,
6
+ type RecursivePartial
7
+ } from '@/modules/display-system/legacy-ods/interface'
8
+ import { useI18n } from '@feedmepos/mf-common'
9
+
10
+ type ColorField = { key: string; title: string; description: string }
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ modelValue: boolean
15
+ title: string
16
+ description?: string
17
+ baseBrandkit: BaseBrandKit
18
+ value?: RecursivePartial<BaseBrandKit>
19
+ colorFields: ColorField[]
20
+ }>(),
21
+ {
22
+ colorFields: () => []
23
+ }
24
+ )
25
+
26
+ const emit = defineEmits<{
27
+ (e: 'update:modelValue', v: boolean): void
28
+ (e: 'save', v: RecursivePartial<BaseBrandKit> | undefined): void
29
+ (e: 'cancel'): void
30
+ }>()
31
+
32
+ const { t } = useI18n()
33
+
34
+ const opened = computed({
35
+ get: () => props.modelValue,
36
+ set: (v) => emit('update:modelValue', v)
37
+ })
38
+
39
+ const draft = ref<RecursivePartial<BaseBrandKit>>({})
40
+
41
+ const cloneValue = () => JSON.parse(JSON.stringify(props.value ?? {}))
42
+
43
+ const syncDraft = () => {
44
+ draft.value = cloneValue()
45
+ }
46
+
47
+ watch(
48
+ () => props.modelValue,
49
+ (v) => {
50
+ if (v) syncDraft()
51
+ }
52
+ )
53
+
54
+ watch(
55
+ () => props.value,
56
+ () => {
57
+ if (props.modelValue) syncDraft()
58
+ },
59
+ { deep: true }
60
+ )
61
+
62
+ const getFromPath = (obj: any, path: string) =>
63
+ path.split('.').reduce((acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined), obj)
64
+
65
+ const pruneEmpty = (value: any): any => {
66
+ if (Array.isArray(value)) {
67
+ const pruned = value.map(pruneEmpty).filter((v) => v !== undefined)
68
+ return pruned.length ? pruned : undefined
69
+ }
70
+ if (value && typeof value === 'object') {
71
+ const entries = Object.entries(value)
72
+ .map(([k, v]) => [k, pruneEmpty(v)] as const)
73
+ .filter(([, v]) => v !== undefined)
74
+ if (!entries.length) return undefined
75
+ return Object.fromEntries(entries)
76
+ }
77
+ return value
78
+ }
79
+
80
+ const setAtPath = (obj: any, path: string, value: any) => {
81
+ const keys = path.split('.')
82
+ let current = obj
83
+ keys.forEach((key, idx) => {
84
+ if (idx === keys.length - 1) {
85
+ current[key] = value
86
+ return
87
+ }
88
+ current[key] = current[key] ?? {}
89
+ current = current[key]
90
+ })
91
+ }
92
+
93
+ const deleteAtPath = (obj: any, path: string) => {
94
+ const keys = path.split('.')
95
+ const stack: { parent: any; key: string }[] = []
96
+ let current = obj
97
+ for (const key of keys) {
98
+ if (!current || typeof current !== 'object') return
99
+ stack.push({ parent: current, key })
100
+ current = current[key]
101
+ }
102
+ const last = stack.pop()
103
+ if (last) {
104
+ delete last.parent[last.key]
105
+ }
106
+ for (let i = stack.length - 1; i >= 0; i--) {
107
+ const { parent, key } = stack[i]
108
+ if (parent[key] && typeof parent[key] === 'object' && !Object.keys(parent[key]).length) {
109
+ delete parent[key]
110
+ }
111
+ }
112
+ }
113
+
114
+ const baseColor = (path: string) => {
115
+ const value = getFromPath(props.baseBrandkit, path)
116
+ if (typeof value === 'string') return value
117
+ return '#000000'
118
+ }
119
+
120
+ const displayColor = (path: string) => {
121
+ const fromOverride = getFromPath(draft.value, path)
122
+ if (typeof fromOverride === 'string') return fromOverride
123
+ return baseColor(path)
124
+ }
125
+
126
+ const toggleOverride = (path: string, enabled: boolean) => {
127
+ if (!enabled) {
128
+ deleteAtPath(draft.value, path)
129
+ const pruned = pruneEmpty(draft.value)
130
+ draft.value = pruned ?? {}
131
+ return
132
+ }
133
+ setAtPath(draft.value, path, baseColor(path))
134
+ }
135
+
136
+ const onReset = (path: string) => {
137
+ setAtPath(draft.value, path, baseColor(path))
138
+ }
139
+
140
+ const onUpdateColor = (path: string, color: string) => {
141
+ setAtPath(draft.value, path, color)
142
+ }
143
+
144
+ const isOverrideEnabled = (path: string) => getFromPath(draft.value, path) !== undefined
145
+
146
+ const onClose = () => {
147
+ syncDraft()
148
+ emit('cancel')
149
+ opened.value = false
150
+ }
151
+
152
+ const onSave = () => {
153
+ const allowedDraft: RecursivePartial<BaseBrandKit> = {}
154
+ for (const field of props.colorFields) {
155
+ const value = getFromPath(draft.value, field.key)
156
+ if (value !== undefined) {
157
+ setAtPath(allowedDraft, field.key, value)
158
+ }
159
+ }
160
+ const cleaned = pruneEmpty(allowedDraft)
161
+ emit('save', cleaned)
162
+ opened.value = false
163
+ }
164
+ </script>
165
+
166
+ <template>
167
+ <FmDialog v-model="opened" :max-width="720">
168
+ <template #dialog-header>
169
+ <div class="fm-typo-en-body-lg-600 text-fm-color-typo-primary">
170
+ {{ title }}
171
+ </div>
172
+ </template>
173
+ <div class="space-y-16 max-h-[70vh] overflow-y-auto">
174
+ <div
175
+ v-for="field in colorFields"
176
+ :key="field.key"
177
+ class="border border-fm-color-neutral-gray-100 rounded-lg overflow-hidden"
178
+ >
179
+ <div
180
+ class="flex items-center justify-between px-16 py-12 border-b border-fm-color-neutral-gray-100"
181
+ >
182
+ <div class="flex flex-col gap-4">
183
+ <div class="fm-typo-en-body-md-600 text-fm-color-typo-primary">
184
+ {{ field.title }}
185
+ </div>
186
+ <div class="fm-typo-en-body-sm-400 text-fm-color-typo-secondary">
187
+ {{ field.description }}
188
+ </div>
189
+ </div>
190
+ <FmSwitch
191
+ :model-value="isOverrideEnabled(field.key)"
192
+ @update:model-value="(v: boolean) => toggleOverride(field.key, v)"
193
+ />
194
+ </div>
195
+ <div
196
+ class="px-16 py-12"
197
+ :class="{
198
+ 'opacity-50 pointer-events-none': !isOverrideEnabled(field.key)
199
+ }"
200
+ >
201
+ <ColorSelection
202
+ :initial-color="baseColor(field.key)"
203
+ :color="displayColor(field.key)"
204
+ @update="(v) => onUpdateColor(field.key, v)"
205
+ @remove="onReset(field.key)"
206
+ :title="field.title"
207
+ :description="field.description"
208
+ />
209
+ </div>
210
+ </div>
211
+ </div>
212
+ <template #dialog-footer>
213
+ <div class="w-full flex justify-end gap-8">
214
+ <FmButton
215
+ variant="secondary"
216
+ :label="t('order.displaySystem.legacyOds.close')"
217
+ @click="onClose"
218
+ />
219
+ <FmButton :label="t('order.displaySystem.legacyOds.save')" @click="onSave" />
220
+ </div>
221
+ </template>
222
+ </FmDialog>
223
+ </template>
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ import { ColorPicker } from 'vue-accessible-color-picker'
3
+ import 'vue-accessible-color-picker/styles'
4
+
5
+ defineProps<{
6
+ initialColor: string
7
+ color: string
8
+ title: string
9
+ description: string
10
+ }>()
11
+
12
+ const handleCopy = (v: string) => {
13
+ void v
14
+ }
15
+
16
+ const emits = defineEmits<{
17
+ (e: 'remove'): void
18
+ (e: 'update', value: string): void
19
+ }>()
20
+ </script>
21
+
22
+ <template>
23
+ <div class="fm-typo-en-body-md-400">
24
+ <div class="flex gap-8 items-center">
25
+ <div class="p-4 relative">
26
+ <FmPopover>
27
+ <template #popover-button>
28
+ <div
29
+ class="aspect-square w-[60px] rounded-sm border border-fm-color-neutral-gray-200 cursor-pointer"
30
+ :style="{
31
+ backgroundColor: color
32
+ }"
33
+ ></div>
34
+ </template>
35
+ <div class="shadow-lg rounded-lg p-8">
36
+ <ColorPicker
37
+ :copy="handleCopy"
38
+ @color-copy="(v) => handleCopy(v.cssColor)"
39
+ :default-format="'hex'"
40
+ :color="color"
41
+ @color-change="(v) => emits('update', v.cssColor)"
42
+ >
43
+ <template #copy-button>
44
+ <FmButton class="w-full h-32" variant="secondary" icon="content_copy" />
45
+ </template>
46
+ </ColorPicker>
47
+ </div>
48
+ </FmPopover>
49
+ <div class="absolute top-8 right-8" v-if="initialColor != color">
50
+ <div
51
+ @click="emits('remove')"
52
+ class="h-16 w-16 rounded-full bg-fm-color-neutral-white flex items-center justify-center cursor-pointer"
53
+ >
54
+ <FmIcon name="close_small" class="!text-[16px]" />
55
+ </div>
56
+ </div>
57
+ </div>
58
+ <div class="space-y-4">
59
+ <div>{{ title }}</div>
60
+ <div class="text-fm-color-typo-secondary">{{ description }}</div>
61
+ </div>
62
+ </div>
63
+ <div class="text-fm-color-typo-secondary px-4">
64
+ {{ color }}
65
+ </div>
66
+ </div>
67
+ </template>