@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,585 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import { localParts, wallInstant } from '../../shared/availability'
4
+ import { availabilitySchema } from '../../shared/validation'
5
+ import type { AvailabilityWindow } from '../../shared/types'
6
+ import type { ProviderConfiguration, AppointmentListItem, CalendarBusyPeriod } from '../composables/usePlanning'
7
+
8
+ const { activeOrganizationRole } = usePortalSession()
9
+ const canManagePlanning = computed(() => ['owner', 'admin'].includes(activeOrganizationRole.value || ''))
10
+
11
+ const api = usePlanning(),
12
+ { t } = useI18n(),
13
+ settings = ref<ProviderConfiguration>(),
14
+ windows = ref<AvailabilityWindow[]>([]),
15
+ appointments = ref<AppointmentListItem[]>([]),
16
+ externalBusy = ref<CalendarBusyPeriod[]>([]),
17
+ error = ref(''),
18
+ busy = ref(false),
19
+ calendarNavigationDirection = ref<'previous' | 'next'>(),
20
+ windowOpen = ref(false),
21
+ deleteOpen = ref(false),
22
+ appointmentDrawerOpen = ref(false),
23
+ drawerAppointment = ref<AppointmentListItem>()
24
+ const selected = ref<AvailabilityWindow>(),
25
+ occurrence = ref(''),
26
+ editOne = ref(false)
27
+ const route = useRoute()
28
+ function weekFromQuery(value: unknown) {
29
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value))
30
+ ? value
31
+ : new Date().toISOString().slice(0, 10)
32
+ }
33
+ const week = ref(weekFromQuery(route.query.week))
34
+ const calendarTimezone = computed({
35
+ get: () =>
36
+ typeof route.query.timezone === 'string' && Intl.supportedValuesOf('timeZone').includes(route.query.timezone)
37
+ ? route.query.timezone
38
+ : providerState.timezone,
39
+ set: (timezone: string) => {
40
+ void navigateTo(
41
+ {
42
+ query: {
43
+ ...route.query,
44
+ week: week.value,
45
+ timezone: timezone === providerState.timezone ? undefined : timezone
46
+ }
47
+ },
48
+ { replace: true }
49
+ )
50
+ }
51
+ })
52
+ const providerState = reactive({
53
+ timezone: 'Europe/Amsterdam',
54
+ graceMinutes: 0,
55
+ busyCalendarIds: [] as string[],
56
+ writeCalendarId: ''
57
+ })
58
+ const windowState = reactive<z.infer<typeof availabilitySchema>>({
59
+ date: week.value,
60
+ endDate: null,
61
+ startTime: '09:00',
62
+ endTime: '17:00',
63
+ recurring: false,
64
+ productIds: null,
65
+ exceptions: []
66
+ })
67
+ const windowFormSchema = computed(() =>
68
+ availabilitySchema.safeExtend({
69
+ productIds: z
70
+ .array(z.string().min(1).max(100))
71
+ .min(1, t('planning.selectAtLeastOneProduct'))
72
+ .max(100)
73
+ .nullable()
74
+ .default(null),
75
+ endDate: z.union([availabilitySchema.shape.endDate, z.literal('')]).transform((v) => v || null)
76
+ })
77
+ )
78
+ const allProducts = computed({
79
+ get: () => windowState.productIds === null,
80
+ set: (value) => {
81
+ windowState.productIds = value ? null : []
82
+ }
83
+ })
84
+ function daysForWeek(value: string) {
85
+ const first = new Date(value)
86
+ first.setUTCDate(first.getUTCDate() - ((first.getUTCDay() + 6) % 7))
87
+ return Array.from({ length: 7 }, (_, i) => new Date(first.getTime() + i * 86400000).toISOString().slice(0, 10))
88
+ }
89
+ const days = computed(() => daysForWeek(week.value))
90
+ const canEditCalendar = computed(() => calendarTimezone.value === providerState.timezone)
91
+ watch(canEditCalendar, (value) => {
92
+ if (!value) {
93
+ windowOpen.value = false
94
+ deleteOpen.value = false
95
+ }
96
+ })
97
+ type CalendarWindow = AvailabilityWindow & {
98
+ sourceDate: string
99
+ split: boolean
100
+ sourceRecurring: boolean
101
+ sourceStart: string
102
+ sourceEnd: string
103
+ }
104
+ const calendarWindows = computed<CalendarWindow[]>(() => {
105
+ const result: CalendarWindow[] = []
106
+ const first = Date.parse(days.value[0]!) - 2 * 86400000
107
+ for (let i = 0; i < 11; i++) {
108
+ const date = new Date(first + i * 86400000).toISOString().slice(0, 10)
109
+ for (const window of windows.value) {
110
+ if (
111
+ date < window.date ||
112
+ (window.endDate && date > window.endDate) ||
113
+ window.exceptions.includes(date) ||
114
+ (window.recurring ? new Date(date).getUTCDay() !== new Date(window.date).getUTCDay() : date !== window.date)
115
+ ) {
116
+ continue
117
+ }
118
+ try {
119
+ const start = localParts(wallInstant(date, window.startTime, window.timezone), calendarTimezone.value)
120
+ const end = localParts(wallInstant(date, window.endTime, window.timezone), calendarTimezone.value)
121
+ const base = {
122
+ ...window,
123
+ recurring: false,
124
+ sourceRecurring: window.recurring,
125
+ sourceStart: wallInstant(date, window.startTime, window.timezone).toISOString(),
126
+ sourceEnd: wallInstant(date, window.endTime, window.timezone).toISOString(),
127
+ sourceDate: date,
128
+ timezone: calendarTimezone.value,
129
+ endDate: null,
130
+ exceptions: [],
131
+ split: start.date !== end.date
132
+ }
133
+ if (start.date === end.date) {
134
+ result.push({ ...base, date: start.date, startTime: start.time, endTime: end.time })
135
+ } else {
136
+ result.push({ ...base, date: start.date, startTime: start.time, endTime: '24:00' })
137
+ if (end.time !== '00:00') {
138
+ result.push({ ...base, date: end.date, startTime: '00:00', endTime: end.time })
139
+ }
140
+ }
141
+ } catch {
142
+ /* Nonexistent wall times do not produce availability. */
143
+ }
144
+ }
145
+ }
146
+ return result
147
+ })
148
+ function editCalendarWindow(date: string, displayed: AvailabilityWindow) {
149
+ if (!canEditCalendar.value) {
150
+ return
151
+ }
152
+ const source = windows.value.find((window) => window.id === displayed.id)
153
+ if (source) {
154
+ openWindow((displayed as CalendarWindow).sourceDate || date, source)
155
+ }
156
+ }
157
+ const endTimeInput = computed({
158
+ get: () => (windowState.endTime === '24:00' ? '00:00' : windowState.endTime),
159
+ set: (value: string) => {
160
+ windowState.endTime = value === '00:00' ? '24:00' : value
161
+ }
162
+ })
163
+ function providerRange(date: string, startTime: string, endTime: string) {
164
+ const start = localParts(wallInstant(date, startTime, calendarTimezone.value), providerState.timezone)
165
+ const end = localParts(wallInstant(date, endTime, calendarTimezone.value), providerState.timezone)
166
+ const midnight = end.time === '00:00' && Date.parse(end.date) === Date.parse(start.date) + 86400000
167
+ if (!midnight && (start.date !== end.date || end.time <= start.time)) {
168
+ throw new Error('Range crosses provider midnight')
169
+ }
170
+ return { date: start.date, startTime: start.time, endTime: midnight ? '24:00' : end.time }
171
+ }
172
+ watch(editOne, (value) => {
173
+ if (selected.value) {
174
+ Object.assign(
175
+ windowState,
176
+ structuredClone(toRaw(selected.value)),
177
+ value ? { date: occurrence.value, recurring: false, endDate: null } : {}
178
+ )
179
+ }
180
+ })
181
+ let preloadedCalendarKey = ''
182
+ async function moveWeek(delta: number) {
183
+ if (calendarNavigationDirection.value) {
184
+ return
185
+ }
186
+ calendarNavigationDirection.value = delta < 0 ? 'previous' : 'next'
187
+ error.value = ''
188
+ const targetWeek = new Date(Date.parse(week.value) + delta * 7 * 86400000).toISOString().slice(0, 10)
189
+ const targetTimezone = calendarTimezone.value
190
+ try {
191
+ const targetBusy = await fetchExternalBusy(daysForWeek(targetWeek), targetTimezone)
192
+ preloadedCalendarKey = `${targetWeek}:${targetTimezone}`
193
+ week.value = targetWeek
194
+ externalBusy.value = targetBusy
195
+ await navigateTo({ query: { ...route.query, week: targetWeek } })
196
+ } catch {
197
+ error.value = t('planning.externalCalendarLoadError')
198
+ } finally {
199
+ calendarNavigationDirection.value = undefined
200
+ }
201
+ }
202
+ async function load() {
203
+ try {
204
+ settings.value = await api.provider()
205
+ Object.assign(providerState, { ...settings.value, writeCalendarId: settings.value.writeCalendarId || '' })
206
+ windows.value = await api.windows()
207
+ appointments.value = await api.providerAppointments()
208
+ await loadExternalBusy()
209
+ } catch {
210
+ error.value = t('planning.loadError')
211
+ }
212
+ }
213
+ async function loadExternalBusy() {
214
+ externalBusy.value = await fetchExternalBusy(days.value)
215
+ }
216
+ async function fetchExternalBusy(targetDays: string[], timezone = calendarTimezone.value) {
217
+ if (!providerState.busyCalendarIds.length && !providerState.writeCalendarId) {
218
+ return []
219
+ }
220
+ const from = wallInstant(targetDays[0]!, '00:00', timezone)
221
+ const to = wallInstant(
222
+ new Date(Date.parse(targetDays[targetDays.length - 1]!) + 86400000).toISOString().slice(0, 10),
223
+ '00:00',
224
+ timezone
225
+ )
226
+ const periods = await api.calendarBusy({ from: from.toISOString(), to: to.toISOString() })
227
+ return Array.isArray(periods) ? periods : []
228
+ }
229
+ // Suspend the page setup until the calendar and its external events are ready.
230
+ // This ensures the all-day row has its final layout on the first rendered frame.
231
+ await load()
232
+ watch(
233
+ () => route.query.week,
234
+ (value) => {
235
+ const routeWeek = weekFromQuery(value)
236
+ if (routeWeek !== week.value) {
237
+ week.value = routeWeek
238
+ }
239
+ }
240
+ )
241
+ watch([week, calendarTimezone], () => {
242
+ if (settings.value) {
243
+ const calendarKey = `${week.value}:${calendarTimezone.value}`
244
+ if (preloadedCalendarKey === calendarKey) {
245
+ preloadedCalendarKey = ''
246
+ return
247
+ }
248
+ void loadExternalBusy().catch(() => {
249
+ error.value = t('planning.externalCalendarLoadError')
250
+ })
251
+ }
252
+ })
253
+ async function moveCalendarWindow(
254
+ date: string,
255
+ window: AvailabilityWindow,
256
+ targetDate: string,
257
+ startTime: string,
258
+ endTime: string
259
+ ) {
260
+ if (busy.value || !canEditCalendar.value) {
261
+ return
262
+ }
263
+ busy.value = true
264
+ error.value = ''
265
+ try {
266
+ const displayed = window as CalendarWindow
267
+ const source = windows.value.find((item) => item.id === window.id)!
268
+ if (displayed.split) {
269
+ throw new Error('Resize the complete window in its original timezone')
270
+ }
271
+ const converted = providerRange(targetDate, startTime, endTime)
272
+ await api.saveWindow(
273
+ {
274
+ ...source,
275
+ ...converted,
276
+ recurring: source.recurring ? false : source.recurring,
277
+ endDate: source.recurring ? null : source.endDate,
278
+ occurrenceDate: source.recurring ? displayed.sourceDate || date : undefined
279
+ },
280
+ window.id
281
+ )
282
+ await load()
283
+ } catch {
284
+ error.value = t('planning.actionError')
285
+ } finally {
286
+ busy.value = false
287
+ }
288
+ }
289
+ function selectCalendarRange(date: string, startTime: string, endTime: string) {
290
+ if (!canEditCalendar.value) {
291
+ return
292
+ }
293
+ try {
294
+ const converted = providerRange(date, startTime, endTime)
295
+ openWindow(converted.date)
296
+ Object.assign(windowState, converted)
297
+ } catch {
298
+ error.value = t('planning.timezoneRangeError')
299
+ }
300
+ }
301
+ function toggleProduct(id: string, checked: boolean) {
302
+ const ids = windowState.productIds || []
303
+ windowState.productIds = checked ? [...new Set([...ids, id])] : ids.filter((value) => value !== id)
304
+ }
305
+ function openWindow(date: string, window?: AvailabilityWindow) {
306
+ if (!canEditCalendar.value) {
307
+ return
308
+ }
309
+ selected.value = window
310
+ occurrence.value = date
311
+ editOne.value = false
312
+ Object.assign(
313
+ windowState,
314
+ window
315
+ ? structuredClone(toRaw(window))
316
+ : {
317
+ date,
318
+ endDate: null,
319
+ startTime: '09:00',
320
+ endTime: '17:00',
321
+ recurring: false,
322
+ productIds: null,
323
+ exceptions: []
324
+ }
325
+ )
326
+ windowOpen.value = true
327
+ }
328
+ function openAppointment(item: AppointmentListItem) {
329
+ drawerAppointment.value = item
330
+ appointmentDrawerOpen.value = true
331
+ }
332
+ async function saveWindow() {
333
+ if (!canEditCalendar.value) {
334
+ return
335
+ }
336
+ busy.value = true
337
+ try {
338
+ await api.saveWindow(
339
+ {
340
+ ...windowState,
341
+ endDate: windowState.endDate || null,
342
+ occurrenceDate: editOne.value ? occurrence.value : undefined
343
+ },
344
+ selected.value?.id
345
+ )
346
+ windowOpen.value = false
347
+ await load()
348
+ } catch {
349
+ error.value = t('planning.actionError')
350
+ } finally {
351
+ busy.value = false
352
+ }
353
+ }
354
+ async function removeWindow() {
355
+ if (!canEditCalendar.value) {
356
+ return
357
+ }
358
+ if (!selected.value) {
359
+ return
360
+ }
361
+ busy.value = true
362
+ try {
363
+ await api.deleteWindow(selected.value.id, editOne.value ? occurrence.value : undefined)
364
+ deleteOpen.value = false
365
+ windowOpen.value = false
366
+ await load()
367
+ } catch {
368
+ error.value = t('planning.actionError')
369
+ } finally {
370
+ busy.value = false
371
+ }
372
+ }
373
+ </script>
374
+
375
+ <template>
376
+ <div class="h-full min-h-0 overflow-hidden">
377
+ <UContainer class="flex h-full min-h-0 flex-col gap-6 py-8">
378
+ <div class="flex flex-wrap items-center justify-between gap-3">
379
+ <div class="flex items-start gap-3">
380
+ <UIcon name="i-lucide-calendar-days" class="mt-1 size-6 shrink-0 text-primary" />
381
+ <h1 class="text-2xl font-semibold">{{ t('planning.availability') }}</h1>
382
+ </div>
383
+ <div class="flex flex-wrap items-center gap-2">
384
+ <PlanningTimezoneSelect v-model="calendarTimezone" :user-timezone="providerState.timezone" :disabled="busy" />
385
+ <UButton
386
+ :icon="calendarNavigationDirection === 'previous' ? 'i-lucide-loader-circle' : 'i-lucide-chevron-left'"
387
+ :aria-label="t('planning.previousWeek')"
388
+ :aria-busy="calendarNavigationDirection === 'previous'"
389
+ variant="outline"
390
+ :ui="{ leadingIcon: calendarNavigationDirection === 'previous' ? 'animate-spin' : '' }"
391
+ @click="moveWeek(-1)"
392
+ />
393
+
394
+ <UButton
395
+ :icon="calendarNavigationDirection === 'next' ? 'i-lucide-loader-circle' : 'i-lucide-chevron-right'"
396
+ :aria-label="t('planning.nextWeek')"
397
+ :aria-busy="calendarNavigationDirection === 'next'"
398
+ variant="outline"
399
+ :ui="{ leadingIcon: calendarNavigationDirection === 'next' ? 'animate-spin' : '' }"
400
+ @click="moveWeek(1)"
401
+ />
402
+
403
+ <PlanningTimezoneReadOnlyHover
404
+ :readonly="!canEditCalendar"
405
+ :user-timezone="providerState.timezone"
406
+ @switch-timezone="calendarTimezone = providerState.timezone"
407
+ >
408
+ <UButton :disabled="!canEditCalendar || busy" @click="openWindow(week)">{{
409
+ t('planning.addWindow')
410
+ }}</UButton>
411
+ </PlanningTimezoneReadOnlyHover>
412
+ </div>
413
+ </div>
414
+ <UAlert v-if="error" variant="outline" color="error" :title="error" /><UAlert
415
+ v-if="settings && !settings.enabled"
416
+ variant="outline"
417
+ color="warning"
418
+ :title="t(canManagePlanning ? 'planning.planningDisabledAdmin' : 'planning.planningDisabled')"
419
+ >
420
+ <template v-if="canManagePlanning" #actions>
421
+ <UButton to="/admin/planning" color="neutral" variant="link">{{ t('planning.settings') }}</UButton>
422
+ </template>
423
+ </UAlert>
424
+ <PlanningAvailabilityCalendar
425
+ class="min-h-0 flex-1"
426
+ :days="days"
427
+ :disabled="busy || !canEditCalendar"
428
+ :timezone="calendarTimezone"
429
+ :user-timezone="providerState.timezone"
430
+ :readonly-timezone="!canEditCalendar"
431
+ :products="settings?.products || []"
432
+ :windows="calendarWindows"
433
+ :appointments="appointments"
434
+ :external-busy="externalBusy"
435
+ @switch-timezone="calendarTimezone = providerState.timezone"
436
+ @select="selectCalendarRange"
437
+ @edit="editCalendarWindow"
438
+ @appointment="openAppointment"
439
+ @move="moveCalendarWindow"
440
+ />
441
+
442
+ <PlanningAppointmentDrawer
443
+ v-if="appointmentDrawerOpen && drawerAppointment"
444
+ v-model:open="appointmentDrawerOpen"
445
+ :item="drawerAppointment"
446
+ :timezone="calendarTimezone"
447
+ staff
448
+ @changed="load"
449
+ />
450
+
451
+ <UModal
452
+ v-if="windowOpen"
453
+ v-model:open="windowOpen"
454
+ :title="t(selected ? 'planning.editAvailability' : 'planning.addWindow')"
455
+ :description="t('planning.availabilityFormDescription', { timezone: providerState.timezone })"
456
+ :ui="{ content: 'sm:max-w-lg' }"
457
+ >
458
+ <template #body>
459
+ <UForm :state="windowState" :schema="windowFormSchema" novalidate class="space-y-6" @submit="saveWindow">
460
+ <UAlert v-if="error" :title="error" color="error" variant="outline" />
461
+ <UFormField v-if="selected?.recurring" :label="t('planning.editOne')"
462
+ ><USwitch v-model="editOne"
463
+ /></UFormField>
464
+ <div class="space-y-4">
465
+ <UFormField name="date" :label="t('planning.date')"
466
+ ><UInput v-model="windowState.date" type="date" class="w-full"
467
+ /></UFormField>
468
+ <div class="grid grid-cols-2 gap-4">
469
+ <UFormField name="startTime" :label="t('planning.startTime')"
470
+ ><UInput v-model="windowState.startTime" type="time" class="w-full"
471
+ /></UFormField>
472
+ <UFormField name="endTime" :label="t('planning.endTime')"
473
+ ><UInput v-model="endTimeInput" type="time" class="w-full"
474
+ /></UFormField>
475
+ </div>
476
+ </div>
477
+ <div class="space-y-4 border-t border-default pt-5">
478
+ <UFormField name="recurring">
479
+ <div class="flex items-start justify-between gap-4">
480
+ <div>
481
+ <p class="text-sm font-medium">{{ t('planning.weekly') }}</p>
482
+ <p class="mt-1 text-sm text-muted">{{ t('planning.weeklyHelp') }}</p>
483
+ </div>
484
+ <USwitch
485
+ v-model="windowState.recurring"
486
+ :disabled="editOne"
487
+ :aria-label="t('planning.weekly')"
488
+ class="mt-0.5 shrink-0"
489
+ />
490
+ </div>
491
+ </UFormField>
492
+ <UFormField
493
+ v-if="windowState.recurring"
494
+ name="endDate"
495
+ :label="t('planning.endDate')"
496
+ :description="t('planning.endDateHelp')"
497
+ >
498
+ <UInput
499
+ :model-value="windowState.endDate || undefined"
500
+ type="date"
501
+ class="w-full"
502
+ @update:model-value="windowState.endDate = $event || null"
503
+ />
504
+ </UFormField>
505
+ </div>
506
+ <div class="space-y-4 border-t border-default pt-5">
507
+ <UFormField>
508
+ <div class="flex items-start justify-between gap-4">
509
+ <div>
510
+ <p class="text-sm font-medium">{{ t('planning.allProducts') }}</p>
511
+ <p class="mt-1 text-sm text-muted">{{ t('planning.allProductsHelp') }}</p>
512
+ </div>
513
+ <USwitch v-model="allProducts" :aria-label="t('planning.allProducts')" class="mt-0.5 shrink-0" />
514
+ </div>
515
+ </UFormField>
516
+ <UFormField v-if="!allProducts" name="productIds" :label="t('planning.products')">
517
+ <div class="space-y-2">
518
+ <label
519
+ v-for="product in settings?.products || []"
520
+ :key="product.id"
521
+ class="flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-elevated"
522
+ :class="
523
+ windowState.productIds?.includes(product.id) ? 'border-primary bg-primary/5' : 'border-default'
524
+ "
525
+ >
526
+ <UCheckbox
527
+ :model-value="windowState.productIds?.includes(product.id) || false"
528
+ :aria-label="product.title"
529
+ @update:model-value="toggleProduct(product.id, Boolean($event))"
530
+ />
531
+ <img
532
+ v-if="product.thumbnailImageId"
533
+ :src="`/api/store/media/${encodeURIComponent(product.thumbnailImageId)}`"
534
+ alt=""
535
+ class="h-12 w-12 shrink-0 rounded-md object-cover"
536
+ />
537
+ <div
538
+ v-else
539
+ class="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-elevated text-muted"
540
+ >
541
+ <UIcon name="i-lucide-package" class="size-5" />
542
+ </div>
543
+ <span class="min-w-0 break-words text-sm font-medium">{{ product.title }}</span>
544
+ </label>
545
+ </div>
546
+ </UFormField>
547
+ </div>
548
+ <div class="flex flex-wrap items-center justify-between gap-3 border-t border-default pt-5">
549
+ <UButton
550
+ v-if="selected"
551
+ color="error"
552
+ variant="ghost"
553
+ icon="i-lucide-trash-2"
554
+ :disabled="busy"
555
+ @click="deleteOpen = true"
556
+ >{{ t('planning.delete') }}</UButton
557
+ >
558
+ <div class="ml-auto flex flex-wrap justify-end gap-2">
559
+ <UButton color="neutral" variant="outline" :disabled="busy" @click="windowOpen = false">{{
560
+ t('planning.cancelAction')
561
+ }}</UButton>
562
+ <UButton
563
+ type="submit"
564
+ icon="i-lucide-save"
565
+ class="ml-auto flex min-w-28 justify-center"
566
+ :loading="busy"
567
+ >{{ t('planning.saveAvailability') }}</UButton
568
+ >
569
+ </div>
570
+ </div>
571
+ </UForm>
572
+ </template>
573
+ </UModal>
574
+
575
+ <UModal v-if="deleteOpen" v-model:open="deleteOpen" :title="t('planning.delete')"
576
+ ><template #body
577
+ ><p>{{ t(editOne ? 'planning.deleteOccurrenceConfirm' : 'planning.deleteSeriesConfirm') }}</p>
578
+ <UButton class="mt-4" color="error" :loading="busy" @click="removeWindow">{{
579
+ t('planning.delete')
580
+ }}</UButton></template
581
+ ></UModal
582
+ >
583
+ </UContainer>
584
+ </div>
585
+ </template>