@nuxt-customer-portal/planning 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +115 -0
  4. package/app/components/PlanningAppointmentCreateForm.vue +49 -0
  5. package/app/components/PlanningAppointmentDetails.vue +163 -0
  6. package/app/components/PlanningAppointmentDrawer.vue +109 -0
  7. package/app/components/PlanningAvailabilityCalendar.vue +525 -0
  8. package/app/components/PlanningDashboardOverview.vue +62 -0
  9. package/app/components/PlanningDashboardUpcomingAppointments.vue +81 -0
  10. package/app/components/PlanningPersonalCalendarSettings.vue +218 -0
  11. package/app/components/PlanningPersonalConnection.vue +68 -0
  12. package/app/components/PlanningPersonalMeetingSettings.vue +1 -0
  13. package/app/components/PlanningPolicyFields.vue +196 -0
  14. package/app/components/PlanningSettingsMembers.vue +50 -0
  15. package/app/components/PlanningSettingsPolicy.vue +46 -0
  16. package/app/components/PlanningSettingsSynchronization.vue +433 -0
  17. package/app/components/PlanningSlotPicker.vue +588 -0
  18. package/app/components/PlanningTimezoneReadOnlyHover.vue +172 -0
  19. package/app/components/PlanningTimezoneSelect.vue +101 -0
  20. package/app/composables/usePlanning.ts +180 -0
  21. package/app/composables/usePlanningDashboard.ts +4 -0
  22. package/app/composables/usePlanningTimeDisplay.ts +15 -0
  23. package/app/pages/admin/planning.vue +36 -0
  24. package/app/pages/appointments/[id].vue +13 -0
  25. package/app/pages/appointments/index.vue +384 -0
  26. package/app/pages/availability.vue +585 -0
  27. package/app/pages/booking/[slug].vue +298 -0
  28. package/app/pages/my-appointment-settings.vue +33 -0
  29. package/app/plugins/planning.ts +5 -0
  30. package/i18n/locales/en.json +260 -0
  31. package/i18n/locales/nl.json +260 -0
  32. package/migrations/0000_baseline.sql +58 -0
  33. package/migrations/0001_availability_calendar_title.sql +2 -0
  34. package/migrations/0002_job_activity.sql +11 -0
  35. package/migrations/0003_deduplicate_availability_jobs.sql +19 -0
  36. package/migrations/0004_connection_label.sql +2 -0
  37. package/nuxt.config.ts +15 -0
  38. package/package.json +55 -0
  39. package/portal.manifest.mjs +8 -0
  40. package/server/api/planning/admin/appointments/[id]/audit.get.ts +11 -0
  41. package/server/api/planning/admin/appointments.get.ts +11 -0
  42. package/server/api/planning/admin/jobs/[id]/retry.post.ts +26 -0
  43. package/server/api/planning/admin/jobs.get.ts +199 -0
  44. package/server/api/planning/admin/policy.get.ts +6 -0
  45. package/server/api/planning/admin/policy.put.ts +8 -0
  46. package/server/api/planning/admin/products/[id].put.ts +8 -0
  47. package/server/api/planning/admin/providers/[userId].patch.ts +8 -0
  48. package/server/api/planning/admin/providers.get.ts +6 -0
  49. package/server/api/planning/admin/retry.post.ts +31 -0
  50. package/server/api/planning/appointments/[id]/availability.get.ts +19 -0
  51. package/server/api/planning/appointments/[id]/cancel.post.ts +8 -0
  52. package/server/api/planning/appointments/[id]/files/[assetId].get.ts +17 -0
  53. package/server/api/planning/appointments/[id]/files.get.ts +28 -0
  54. package/server/api/planning/appointments/[id]/pending.delete.ts +8 -0
  55. package/server/api/planning/appointments/[id]/reschedule.put.ts +8 -0
  56. package/server/api/planning/appointments/[id].get.ts +6 -0
  57. package/server/api/planning/appointments.get.ts +4 -0
  58. package/server/api/planning/availability/[id].delete.ts +12 -0
  59. package/server/api/planning/availability/[id].put.ts +8 -0
  60. package/server/api/planning/availability.get.ts +6 -0
  61. package/server/api/planning/availability.post.ts +8 -0
  62. package/server/api/planning/calendar-busy.get.ts +29 -0
  63. package/server/api/planning/calendars.get.ts +8 -0
  64. package/server/api/planning/dashboard.get.ts +36 -0
  65. package/server/api/planning/google/notifications.post.ts +24 -0
  66. package/server/api/planning/oauth/[provider]/callback.get.ts +18 -0
  67. package/server/api/planning/oauth/[provider]/start.post.ts +8 -0
  68. package/server/api/planning/oauth/[provider].delete.ts +15 -0
  69. package/server/api/planning/products.get.ts +11 -0
  70. package/server/api/planning/provider-appointments.get.ts +11 -0
  71. package/server/api/planning/provider.get.ts +6 -0
  72. package/server/api/planning/provider.put.ts +8 -0
  73. package/server/api/store/planning/[productId]/availability.get.ts +8 -0
  74. package/server/api/store/planning/hold.delete.ts +11 -0
  75. package/server/api/store/planning/hold.get.ts +36 -0
  76. package/server/api/store/planning/holds.post.ts +10 -0
  77. package/server/db/schema/planning.ts +213 -0
  78. package/server/plugins/openapi.ts +36 -0
  79. package/server/plugins/planning.ts +6 -0
  80. package/server/tasks/planning/reconcile.ts +30 -0
  81. package/server/utils/access.ts +80 -0
  82. package/server/utils/adapters.ts +468 -0
  83. package/server/utils/appointments.ts +37 -0
  84. package/server/utils/booking.ts +619 -0
  85. package/server/utils/crypto.ts +23 -0
  86. package/server/utils/jobs.ts +671 -0
  87. package/server/utils/management.ts +427 -0
  88. package/server/utils/oauth.ts +104 -0
  89. package/server/utils/staff-appointments.ts +158 -0
  90. package/shared/appointment-calendar.ts +82 -0
  91. package/shared/availability.ts +140 -0
  92. package/shared/emails.ts +99 -0
  93. package/shared/feature.ts +78 -0
  94. package/shared/invitation.ts +58 -0
  95. package/shared/types.ts +110 -0
  96. package/shared/validation.ts +77 -0
@@ -0,0 +1,218 @@
1
+ <script setup lang="ts">
2
+ import { providerSettingsSchema } from '../../shared/validation'
3
+
4
+ const api = usePlanning(),
5
+ { t } = useI18n()
6
+ const calendars = ref<Array<{ id: string; summary: string; accessRole: string }>>([]),
7
+ error = ref(''),
8
+ busy = ref(false),
9
+ ready = ref(false),
10
+ googleConnected = ref(false)
11
+ const providerState = reactive({
12
+ timezone: 'Europe/Amsterdam',
13
+ graceMinutes: 0,
14
+ availabilityCalendarTitle: 'Portal availability',
15
+ busyCalendarIds: [] as string[],
16
+ writeCalendarId: ''
17
+ })
18
+ const mainCalendar = computed(() => calendars.value.find((calendar) => calendar.id === providerState.writeCalendarId))
19
+ const mainConfigured = computed(() => googleConnected.value && !!providerState.writeCalendarId)
20
+ const writableCalendars = computed(() =>
21
+ calendars.value.filter((calendar) => ['owner', 'writer'].includes(calendar.accessRole))
22
+ )
23
+ const additionalCalendars = computed(() =>
24
+ calendars.value.filter((calendar) => calendar.id !== providerState.writeCalendarId)
25
+ )
26
+ const additionalCalendarIds = computed({
27
+ get: () => providerState.busyCalendarIds.filter((id) => id !== providerState.writeCalendarId),
28
+ set: (ids: string[]) => {
29
+ providerState.busyCalendarIds = providerState.writeCalendarId
30
+ ? [...new Set([providerState.writeCalendarId, ...ids])]
31
+ : ids
32
+ }
33
+ })
34
+
35
+ async function load() {
36
+ error.value = ''
37
+ try {
38
+ const settings = await api.provider()
39
+ googleConnected.value = settings.connections.some(
40
+ (connection) => connection.provider === 'google' && connection.healthy
41
+ )
42
+ Object.assign(providerState, {
43
+ timezone: settings.timezone,
44
+ graceMinutes: settings.graceMinutes,
45
+ availabilityCalendarTitle: settings.availabilityCalendarTitle,
46
+ busyCalendarIds: settings.busyCalendarIds,
47
+ writeCalendarId: settings.writeCalendarId || ''
48
+ })
49
+ calendars.value = googleConnected.value ? await api.calendars() : []
50
+ ready.value = true
51
+ } catch {
52
+ error.value = t('planning.loadError')
53
+ }
54
+ }
55
+ await load()
56
+
57
+ async function connectMainCalendar() {
58
+ busy.value = true
59
+ error.value = ''
60
+ try {
61
+ await navigateTo((await api.connect('google')).url, { external: true })
62
+ } catch {
63
+ error.value = t('planning.googleConnectionError')
64
+ } finally {
65
+ busy.value = false
66
+ }
67
+ }
68
+
69
+ async function saveSettings() {
70
+ busy.value = true
71
+ error.value = ''
72
+ try {
73
+ providerState.busyCalendarIds = [...new Set([providerState.writeCalendarId, ...additionalCalendarIds.value])]
74
+ await api.saveProvider(providerState)
75
+ await load()
76
+ } catch {
77
+ error.value = t('planning.actionError')
78
+ } finally {
79
+ busy.value = false
80
+ }
81
+ }
82
+ </script>
83
+
84
+ <template>
85
+ <div class="space-y-6">
86
+ <UAlert v-if="error" :title="error" color="error" variant="outline" />
87
+
88
+ <UCard v-if="ready && !googleConnected">
89
+ <template #header>
90
+ <div class="flex items-center gap-3">
91
+ <span class="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
92
+ <UIcon name="i-lucide-calendar-plus" class="size-5" />
93
+ </span>
94
+ <div>
95
+ <h2 class="font-semibold">{{ t('planning.connectMainCalendar') }}</h2>
96
+ <p class="text-sm text-muted">{{ t('planning.connectMainCalendarDescription') }}</p>
97
+ </div>
98
+ </div>
99
+ </template>
100
+ <div class="space-y-4">
101
+ <UAlert :title="t('planning.mainCalendarPermissions')" icon="i-lucide-shield-check" variant="subtle" />
102
+ <UButton icon="i-simple-icons-google" :loading="busy" @click="connectMainCalendar">
103
+ {{ t('planning.connectGoogleCalendar') }}
104
+ </UButton>
105
+ </div>
106
+ </UCard>
107
+
108
+ <UForm
109
+ v-else-if="ready"
110
+ :state="providerState"
111
+ :schema="providerSettingsSchema"
112
+ novalidate
113
+ class="space-y-6"
114
+ @submit="saveSettings"
115
+ >
116
+ <UCard>
117
+ <template #header>
118
+ <div class="flex flex-wrap items-start justify-between gap-3">
119
+ <div>
120
+ <div class="flex items-center gap-2">
121
+ <h2 class="font-semibold">{{ t('planning.mainCalendar') }}</h2>
122
+ <UBadge v-if="mainConfigured" color="success" variant="subtle">{{ t('planning.connected') }}</UBadge>
123
+ </div>
124
+ <p class="mt-1 text-sm text-muted">
125
+ {{
126
+ mainConfigured
127
+ ? t('planning.mainCalendarFixedDescription')
128
+ : t('planning.chooseMainCalendarDescription')
129
+ }}
130
+ </p>
131
+ </div>
132
+ <UBadge color="primary" variant="outline">{{ t('planning.readWrite') }}</UBadge>
133
+ </div>
134
+ </template>
135
+ <div v-if="mainConfigured" class="flex items-center gap-3 rounded-lg border border-default bg-elevated/40 p-4">
136
+ <span class="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
137
+ <UIcon name="i-lucide-calendar-check" class="size-5" />
138
+ </span>
139
+ <div class="min-w-0 flex-1">
140
+ <p class="truncate font-medium">{{ mainCalendar?.summary || providerState.writeCalendarId }}</p>
141
+ <p class="text-sm text-muted">{{ t('planning.portalCalendarDestination') }}</p>
142
+ </div>
143
+ <UIcon name="i-lucide-lock" class="size-4 text-muted" />
144
+ </div>
145
+ <UFormField v-else name="writeCalendarId" :label="t('planning.chooseMainCalendar')">
146
+ <USelectMenu
147
+ v-model="providerState.writeCalendarId"
148
+ :items="writableCalendars.map((calendar) => ({ value: calendar.id, label: calendar.summary }))"
149
+ value-key="value"
150
+ class="w-full"
151
+ />
152
+ </UFormField>
153
+ </UCard>
154
+
155
+ <UCard>
156
+ <template #header>
157
+ <div class="flex flex-wrap items-start justify-between gap-3">
158
+ <div>
159
+ <h2 class="font-semibold">{{ t('planning.additionalCalendars') }}</h2>
160
+ <p class="mt-1 text-sm text-muted">{{ t('planning.additionalCalendarsDescription') }}</p>
161
+ </div>
162
+ <UBadge color="neutral" variant="outline">{{ t('planning.readOnly') }}</UBadge>
163
+ </div>
164
+ </template>
165
+ <UFormField name="busyCalendarIds">
166
+ <USelectMenu
167
+ v-model="additionalCalendarIds"
168
+ :items="additionalCalendars.map((calendar) => ({ value: calendar.id, label: calendar.summary }))"
169
+ value-key="value"
170
+ multiple
171
+ :placeholder="t('planning.addConflictCalendars')"
172
+ class="w-full"
173
+ />
174
+ </UFormField>
175
+ <p v-if="!additionalCalendars.length" class="mt-3 text-sm text-muted">
176
+ {{ t('planning.noAdditionalCalendars') }}
177
+ </p>
178
+ </UCard>
179
+
180
+ <UCard>
181
+ <template #header
182
+ ><h2 class="font-semibold">{{ t('planning.schedulingPreferences') }}</h2></template
183
+ >
184
+ <div class="grid gap-4 sm:grid-cols-2">
185
+ <UFormField
186
+ name="availabilityCalendarTitle"
187
+ :label="t('planning.availabilityCalendarTitle')"
188
+ :description="t('planning.availabilityCalendarTitleDescription')"
189
+ class="sm:col-span-2"
190
+ >
191
+ <UInput v-model="providerState.availabilityCalendarTitle" class="w-full" />
192
+ </UFormField>
193
+ <UFormField name="timezone" :label="t('planning.timezone')">
194
+ <USelectMenu
195
+ v-model="providerState.timezone"
196
+ :items="Intl.supportedValuesOf('timeZone').map((value) => ({ value, label: value.replaceAll('_', ' ') }))"
197
+ value-key="value"
198
+ class="w-full"
199
+ />
200
+ </UFormField>
201
+ <UFormField name="graceMinutes" :label="t('planning.graceMinutes')">
202
+ <UInputNumber v-model="providerState.graceMinutes" :min="0" class="w-full" />
203
+ </UFormField>
204
+ </div>
205
+ </UCard>
206
+
207
+ <div class="flex justify-end">
208
+ <UButton
209
+ type="submit"
210
+ :icon="mainConfigured ? 'i-lucide-save' : 'i-lucide-calendar-check'"
211
+ class="ml-auto flex min-w-28 justify-center"
212
+ :loading="busy"
213
+ >{{ mainConfigured ? t('planning.save') : t('planning.setMainCalendar') }}</UButton
214
+ >
215
+ </div>
216
+ </UForm>
217
+ </div>
218
+ </template>
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import type { ProviderConfiguration } from '../composables/usePlanning'
3
+
4
+ const { provider } = defineProps<{ provider: 'google' | 'zoom' }>()
5
+ const emit = defineEmits<{ changed: [] }>()
6
+ const api = usePlanning(),
7
+ { t } = useI18n(),
8
+ route = useRoute()
9
+ const settings = ref<ProviderConfiguration>(),
10
+ busy = ref(false),
11
+ error = ref(route.query.connection === 'error' ? t('planning.connectionError') : '')
12
+ const connection = computed(() => settings.value?.connections.find((item) => item.provider === provider))
13
+ async function load() {
14
+ settings.value = await api.provider()
15
+ }
16
+ try {
17
+ await load()
18
+ } catch {
19
+ error.value = t('planning.loadError')
20
+ }
21
+ async function connect(provider: string) {
22
+ busy.value = true
23
+ try {
24
+ window.location.assign((await api.connect(provider)).url)
25
+ } catch {
26
+ error.value = t('planning.connectionError')
27
+ busy.value = false
28
+ }
29
+ }
30
+ async function disconnect(provider: string) {
31
+ busy.value = true
32
+ try {
33
+ await api.disconnect(provider)
34
+ await load()
35
+ emit('changed')
36
+ } catch {
37
+ error.value = t('planning.actionError')
38
+ } finally {
39
+ busy.value = false
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <div class="space-y-4">
46
+ <UAlert v-if="error" :title="error" color="error" variant="outline" />
47
+ <UCard
48
+ ><template #header
49
+ ><h2 class="font-semibold">{{ provider === 'google' ? 'Google' : 'Zoom' }}</h2></template
50
+ >
51
+ <div class="space-y-4">
52
+ <div class="flex flex-wrap items-center gap-3">
53
+ <span class="font-semibold capitalize">{{ provider }}</span
54
+ ><UBadge variant="subtle" :color="connection?.healthy ? 'success' : 'warning'">{{
55
+ t(connection?.healthy ? 'planning.connected' : 'planning.notConnected')
56
+ }}</UBadge
57
+ ><span v-if="connection?.healthy && connection.externalUserLabel" class="text-sm text-muted">{{
58
+ connection.externalUserLabel
59
+ }}</span
60
+ ><UButton :loading="busy" variant="outline" @click="connect(provider)">{{ t('planning.connect') }}</UButton
61
+ ><UButton v-if="connection" color="neutral" variant="ghost" :disabled="busy" @click="disconnect(provider)">{{
62
+ t('planning.disconnect')
63
+ }}</UButton>
64
+ </div>
65
+ </div></UCard
66
+ >
67
+ </div>
68
+ </template>
@@ -0,0 +1 @@
1
+ <template><PlanningPersonalConnection provider="zoom" /></template>
@@ -0,0 +1,196 @@
1
+ <script setup lang="ts">
2
+ import { currencyScale } from '@nuxt-customer-portal/products/shared/money'
3
+ import type { PlanningPolicy } from '../../shared/types'
4
+
5
+ const state = defineModel<PlanningPolicy>({ required: true })
6
+ const overrides = defineModel<Partial<PlanningPolicy>>('overrides')
7
+ const props = withDefaults(defineProps<{ prefix?: string; currencies?: string[]; compact?: boolean }>(), {
8
+ prefix: '',
9
+ currencies: () => ['EUR'],
10
+ compact: false
11
+ })
12
+ const { t } = useI18n()
13
+ const numeric = [
14
+ 'reservationMinutes',
15
+ 'slotIntervalMinutes',
16
+ 'displayIntervalMinutes',
17
+ 'bookingHorizonDays',
18
+ 'minimumNoticeMinutes',
19
+ 'freeChanges',
20
+ 'rescheduleCutoffMinutes',
21
+ 'cancellationCutoffMinutes',
22
+ 'refundPercentage'
23
+ ] as const
24
+ const groups = [
25
+ {
26
+ key: 'schedule',
27
+ icon: 'i-lucide-clock-3',
28
+ fields: ['reservationMinutes', 'slotIntervalMinutes', 'displayIntervalMinutes']
29
+ },
30
+ {
31
+ key: 'bookingWindow',
32
+ icon: 'i-lucide-calendar-range',
33
+ fields: ['bookingHorizonDays', 'minimumNoticeMinutes']
34
+ },
35
+ {
36
+ key: 'changes',
37
+ icon: 'i-lucide-calendar-sync',
38
+ fields: ['freeChanges', 'rescheduleCutoffMinutes']
39
+ }
40
+ ] as const
41
+ const cancellationFields = ['cancellationEnabled', 'cancellationCutoffMinutes', 'refundPercentage'] as const
42
+ function overrideFields(group: (typeof groups)[number]) {
43
+ return group.key === 'changes' ? ([...group.fields, 'changeFees'] as const) : group.fields
44
+ }
45
+ function sectionEnabled(fields: readonly (keyof PlanningPolicy)[]) {
46
+ return overrides.value === undefined || fields.some((key) => Object.hasOwn(overrides.value!, key))
47
+ }
48
+ function toggleSection(fields: readonly (keyof PlanningPolicy)[], enabled: boolean) {
49
+ if (overrides.value === undefined) {
50
+ return
51
+ }
52
+ const next = { ...overrides.value }
53
+ for (const key of fields) {
54
+ if (enabled) {
55
+ Object.assign(next, { [key]: structuredClone(toRaw(state.value[key])) })
56
+ } else {
57
+ Reflect.deleteProperty(next, key)
58
+ }
59
+ }
60
+ overrides.value = next
61
+ }
62
+ if (overrides.value !== undefined) {
63
+ const next = { ...overrides.value }
64
+ for (const fields of [...groups.map(overrideFields), cancellationFields]) {
65
+ if (fields.some((key) => Object.hasOwn(next, key))) {
66
+ for (const key of fields) {
67
+ Object.assign(next, { [key]: structuredClone(toRaw(state.value[key])) })
68
+ }
69
+ }
70
+ }
71
+ overrides.value = next
72
+ }
73
+ function setNumber(key: (typeof numeric)[number], value: number | null | undefined) {
74
+ state.value = { ...state.value, [key]: value ?? 0 }
75
+ }
76
+ function setFee(currency: string, value: number | null | undefined) {
77
+ const changeFees = { ...state.value.changeFees }
78
+ if (value && value > 0) {
79
+ changeFees[currency] = Math.round(value * currencyScale(currency))
80
+ } else {
81
+ Reflect.deleteProperty(changeFees, currency)
82
+ }
83
+ state.value = { ...state.value, changeFees }
84
+ }
85
+ </script>
86
+
87
+ <template>
88
+ <div class="grid gap-4" :class="{ 'lg:grid-cols-2': !compact }">
89
+ <section
90
+ v-for="group in groups"
91
+ :key="group.key"
92
+ class="rounded-lg border border-default bg-muted/20"
93
+ :class="compact ? 'p-3' : 'p-4'"
94
+ >
95
+ <div class="flex items-start gap-3" :class="compact ? 'mb-3' : 'mb-4'">
96
+ <span
97
+ class="flex shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"
98
+ :class="compact ? 'size-8' : 'size-9'"
99
+ >
100
+ <UIcon :name="group.icon" :class="compact ? 'size-4' : 'size-5'" />
101
+ </span>
102
+ <div class="min-w-0 flex-1">
103
+ <h3 class="font-semibold text-highlighted">{{ t(`planning.policyGroup.${group.key}.title`) }}</h3>
104
+ <p v-if="!compact" class="mt-0.5 text-sm text-muted">
105
+ {{ t(`planning.policyGroup.${group.key}.description`) }}
106
+ </p>
107
+ </div>
108
+ <USwitch
109
+ v-if="overrides !== undefined"
110
+ :model-value="sectionEnabled(overrideFields(group))"
111
+ :aria-label="t('planning.overridePolicySection')"
112
+ @update:model-value="toggleSection(overrideFields(group), $event)"
113
+ />
114
+ </div>
115
+ <div v-if="sectionEnabled(overrideFields(group))" class="grid gap-4" :class="{ 'sm:grid-cols-2': !compact }">
116
+ <UFormField v-for="key in group.fields" :key="key" :name="prefix + key" :label="t(`planning.${key}`)">
117
+ <UInputNumber
118
+ :model-value="state[key]"
119
+ :class="compact ? 'w-full' : 'w-36 max-w-full'"
120
+ @update:model-value="setNumber(key, $event)"
121
+ />
122
+ </UFormField>
123
+ </div>
124
+ <div
125
+ v-if="sectionEnabled(overrideFields(group)) && group.key === 'changes' && props.currencies.length"
126
+ class="mt-4 border-t border-default pt-4"
127
+ >
128
+ <p class="mb-3 text-sm font-medium text-highlighted">{{ t('planning.changeFees') }}</p>
129
+ <div class="grid gap-4" :class="{ 'sm:grid-cols-2': !compact }">
130
+ <UFormField
131
+ v-for="currency in props.currencies"
132
+ :key="currency"
133
+ :name="prefix + 'changeFees.' + currency"
134
+ :label="t('planning.changeFeeCurrency', { currency })"
135
+ >
136
+ <UInputNumber
137
+ :model-value="(state.changeFees[currency] || 0) / currencyScale(currency)"
138
+ :min="0"
139
+ :class="compact ? 'w-full' : 'w-36 max-w-full'"
140
+ @update:model-value="setFee(currency, $event)"
141
+ />
142
+ </UFormField>
143
+ </div>
144
+ </div>
145
+ </section>
146
+
147
+ <section class="rounded-lg border border-default bg-muted/20" :class="compact ? 'p-3' : 'p-4'">
148
+ <div class="flex items-start gap-3" :class="compact ? 'mb-3' : 'mb-4'">
149
+ <span
150
+ class="flex shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"
151
+ :class="compact ? 'size-8' : 'size-9'"
152
+ >
153
+ <UIcon name="i-lucide-calendar-x-2" :class="compact ? 'size-4' : 'size-5'" />
154
+ </span>
155
+ <div class="min-w-0 flex-1">
156
+ <h3 class="font-semibold text-highlighted">{{ t('planning.policyGroup.cancellations.title') }}</h3>
157
+ <p v-if="!compact" class="mt-0.5 text-sm text-muted">
158
+ {{ t('planning.policyGroup.cancellations.description') }}
159
+ </p>
160
+ </div>
161
+ <USwitch
162
+ v-if="overrides !== undefined"
163
+ :model-value="sectionEnabled(cancellationFields)"
164
+ :aria-label="t('planning.overridePolicySection')"
165
+ @update:model-value="toggleSection(cancellationFields, $event)"
166
+ />
167
+ </div>
168
+ <UFormField v-if="sectionEnabled(cancellationFields)" :name="prefix + 'cancellationEnabled'" class="mb-4">
169
+ <div class="flex max-w-md items-center justify-between gap-4 rounded-md border border-default bg-default p-3">
170
+ <span class="text-sm font-medium">{{ t('planning.cancellationEnabled') }}</span>
171
+ <USwitch
172
+ :model-value="state.cancellationEnabled"
173
+ :aria-label="t('planning.cancellationEnabled')"
174
+ @update:model-value="state = { ...state, cancellationEnabled: $event }"
175
+ />
176
+ </div>
177
+ </UFormField>
178
+ <div v-if="sectionEnabled(cancellationFields)" class="grid gap-4" :class="{ 'sm:grid-cols-2': !compact }">
179
+ <UFormField :name="prefix + 'cancellationCutoffMinutes'" :label="t('planning.cancellationCutoffMinutes')">
180
+ <UInputNumber
181
+ :model-value="state.cancellationCutoffMinutes"
182
+ :class="compact ? 'w-full' : 'w-36 max-w-full'"
183
+ @update:model-value="setNumber('cancellationCutoffMinutes', $event)"
184
+ />
185
+ </UFormField>
186
+ <UFormField :name="prefix + 'refundPercentage'" :label="t('planning.refundPercentage')">
187
+ <UInputNumber
188
+ :model-value="state.refundPercentage"
189
+ :class="compact ? 'w-full' : 'w-36 max-w-full'"
190
+ @update:model-value="setNumber('refundPercentage', $event)"
191
+ />
192
+ </UFormField>
193
+ </div>
194
+ </section>
195
+ </div>
196
+ </template>
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ import type { ProviderSettings } from '../../shared/types'
3
+
4
+ const api = usePlanning(),
5
+ { t } = useI18n(),
6
+ members = ref<ProviderSettings[]>([]),
7
+ error = ref(''),
8
+ busy = ref(false)
9
+ async function load() {
10
+ try {
11
+ members.value = await api.providers()
12
+ } catch {
13
+ error.value = t('planning.loadError')
14
+ }
15
+ }
16
+ await load()
17
+ async function toggle(id: string, enabled: boolean) {
18
+ busy.value = true
19
+ try {
20
+ await api.setEnabled(id, enabled)
21
+ await load()
22
+ } catch {
23
+ error.value = t('planning.actionError')
24
+ } finally {
25
+ busy.value = false
26
+ }
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <div class="space-y-4">
32
+ <UAlert v-if="error" variant="outline" color="error" :title="error" /><UCard
33
+ ><template #header
34
+ ><h2 class="font-semibold">{{ t('planning.providers') }}</h2></template
35
+ >
36
+ <div class="space-y-4">
37
+ <div v-for="member in members" :key="member.userId" class="flex flex-wrap items-center gap-3">
38
+ <span class="grow">{{ member.name }}</span
39
+ ><UBadge :color="member.googleConnected ? 'success' : 'warning'" variant="subtle">Google</UBadge
40
+ ><UBadge :color="member.zoomConnected ? 'success' : 'warning'" variant="subtle">Zoom</UBadge
41
+ ><USwitch
42
+ :model-value="member.enabled"
43
+ :disabled="busy"
44
+ :aria-label="t('planning.planning') + ' ' + member.name"
45
+ @update:model-value="toggle(member.userId, $event)"
46
+ />
47
+ </div></div
48
+ ></UCard>
49
+ </div>
50
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import { defaultPlanningPolicy, planningPolicySchema } from '@nuxt-customer-portal/products/shared/planning'
3
+
4
+ const api = usePlanning(),
5
+ { t } = useI18n(),
6
+ state = ref(defaultPlanningPolicy()),
7
+ currencies = ref(['EUR']),
8
+ error = ref(''),
9
+ busy = ref(false),
10
+ ready = ref(false)
11
+ try {
12
+ const [policy, settings] = await Promise.all([api.policy(), useProducts().settings()])
13
+ state.value = policy
14
+ currencies.value = settings.currencies
15
+ ready.value = true
16
+ } catch {
17
+ error.value = t('planning.loadError')
18
+ }
19
+ async function save() {
20
+ busy.value = true
21
+ try {
22
+ state.value = await api.savePolicy(state.value)
23
+ } catch {
24
+ error.value = t('planning.actionError')
25
+ } finally {
26
+ busy.value = false
27
+ }
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div class="space-y-4">
33
+ <UAlert v-if="error" variant="outline" color="error" :title="error" /><UCard v-if="ready"
34
+ ><template #header
35
+ ><h2 class="font-semibold">{{ t('planning.organizationPolicy') }}</h2></template
36
+ ><UForm :state="state" :schema="planningPolicySchema" novalidate class="space-y-4" @submit="save"
37
+ ><PlanningPolicyFields v-model="state" :currencies="currencies" />
38
+ <div class="flex justify-end">
39
+ <UButton type="submit" icon="i-lucide-save" class="ml-auto flex min-w-28 justify-center" :loading="busy">{{
40
+ t('planning.save')
41
+ }}</UButton>
42
+ </div></UForm
43
+ ></UCard
44
+ >
45
+ </div>
46
+ </template>