@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,525 @@
1
+ <script setup lang="ts">
2
+ import type { AvailabilityWindow } from '../../shared/types'
3
+ import type { AppointmentListItem, CalendarBusyPeriod } from '../composables/usePlanning'
4
+ import { localParts } from '../../shared/availability'
5
+
6
+ const { appointmentTime } = usePlanningTimeDisplay()
7
+ const displayTime = (value: number) => appointmentTime(new Date(Date.UTC(2026, 0, 1, 0, value)), 'UTC')
8
+ const props = defineProps<{
9
+ disabled?: boolean
10
+ readonlyTimezone?: boolean
11
+ userTimezone?: string
12
+ days: string[]
13
+ timezone: string
14
+ products: Array<{ id: string; title: string }>
15
+ windows: Array<AvailabilityWindow & { sourceRecurring?: boolean; sourceStart?: string; sourceEnd?: string }>
16
+ appointments: AppointmentListItem[]
17
+ externalBusy: CalendarBusyPeriod[]
18
+ }>()
19
+ const emit = defineEmits<{
20
+ switchTimezone: []
21
+ select: [date: string, startTime: string, endTime: string]
22
+ edit: [date: string, window: AvailabilityWindow]
23
+ appointment: [appointment: AppointmentListItem]
24
+ move: [date: string, window: AvailabilityWindow, targetDate: string, startTime: string, endTime: string]
25
+ }>()
26
+ const { t, locale } = useI18n()
27
+ const scroller = ref<HTMLElement>()
28
+ const drag = ref<{ date: string; start: number; current: number; pointerId: number; y: number; moved: boolean }>()
29
+ const moving = ref<{
30
+ window: AvailabilityWindow
31
+ date: string
32
+ targetDate: string
33
+ start: number
34
+ end: number
35
+ initialStart: number
36
+ initialEnd: number
37
+ y: number
38
+ x: number
39
+ top: number
40
+ height: number
41
+ mode: 'move' | 'start' | 'end'
42
+ changed: boolean
43
+ }>()
44
+ function startBlock(
45
+ event: PointerEvent,
46
+ date: string,
47
+ window: AvailabilityWindow,
48
+ mode: 'move' | 'start' | 'end' = 'move'
49
+ ) {
50
+ if (props.disabled || event.button !== 0) {
51
+ return
52
+ }
53
+ const column = (event.currentTarget as HTMLElement).closest('[data-calendar-date]') as HTMLElement
54
+ const rect = column.getBoundingClientRect()
55
+ ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
56
+ moving.value = {
57
+ window,
58
+ date,
59
+ targetDate: date,
60
+ start: minutes(window.startTime),
61
+ end: minutes(window.endTime),
62
+ initialStart: minutes(window.startTime),
63
+ initialEnd: minutes(window.endTime),
64
+ y: event.clientY,
65
+ x: event.clientX,
66
+ top: rect.top,
67
+ height: rect.height,
68
+ mode,
69
+ changed: false
70
+ }
71
+ }
72
+ function moveBlock(event: PointerEvent) {
73
+ const m = moving.value
74
+ if (!m) {
75
+ return
76
+ }
77
+ if (Math.abs(event.clientY - m.y) + Math.abs(event.clientX - m.x) < 5 && !m.changed) {
78
+ return
79
+ }
80
+ m.changed = true
81
+ const delta = Math.round((((event.clientY - m.y) / m.height) * visibleRange.value.duration) / 15) * 15
82
+ if (m.mode === 'move') {
83
+ const duration = m.initialEnd - m.initialStart
84
+ m.start = Math.max(0, Math.min(1440 - duration, m.initialStart + delta))
85
+ m.end = m.start + duration
86
+ const column = Array.from(scroller.value?.querySelectorAll<HTMLElement>('[data-calendar-date]') || []).find(
87
+ (el) => {
88
+ const r = el.getBoundingClientRect()
89
+ return event.clientX >= r.left && event.clientX < r.right
90
+ }
91
+ )
92
+ m.targetDate = column?.dataset.calendarDate || m.targetDate
93
+ } else if (m.mode === 'start') {
94
+ m.start = Math.max(0, Math.min(m.end - 15, m.initialStart + delta))
95
+ } else {
96
+ m.end = Math.max(m.start + 15, Math.min(1440, m.initialEnd + delta))
97
+ }
98
+ }
99
+ function finishBlock() {
100
+ if (props.disabled) {
101
+ moving.value = undefined
102
+ return
103
+ }
104
+ const m = moving.value
105
+ moving.value = undefined
106
+ if (!m) {
107
+ return
108
+ }
109
+ if (m.changed) {
110
+ emit('move', m.date, m.window, m.targetDate, time(m.start), time(m.end))
111
+ } else {
112
+ emit('edit', m.date, m.window)
113
+ }
114
+ }
115
+ const visibleRange = computed(() => {
116
+ const starts = [8 * 60],
117
+ ends = [24 * 60]
118
+ for (const date of props.days) {
119
+ for (const window of windowsFor(date)) {
120
+ starts.push(minutes(window.startTime))
121
+ ends.push(minutes(window.endTime))
122
+ }
123
+ for (const appointment of appointmentsFor(date)) {
124
+ starts.push(appointment.startMinute)
125
+ ends.push(appointment.endMinute)
126
+ }
127
+ for (const period of externalBusyFor(date)) {
128
+ starts.push(period.startMinute)
129
+ ends.push(period.endMinute)
130
+ }
131
+ }
132
+ const start = Math.floor(Math.min(...starts) / 60) * 60
133
+ const end = Math.min(1440, Math.ceil(Math.max(...ends) / 60) * 60)
134
+ return { start, end, duration: end - start }
135
+ })
136
+ const hours = computed(() =>
137
+ Array.from({ length: visibleRange.value.duration / 60 }, (_, i) => visibleRange.value.start / 60 + i)
138
+ )
139
+ const hasAllDayEvents = computed(() => props.days.some((date) => allDayBusyFor(date).length > 0))
140
+ const selection = computed(() =>
141
+ drag.value?.moved
142
+ ? {
143
+ start: Math.min(drag.value.start, drag.value.current),
144
+ end: Math.min(1440, Math.max(drag.value.start, drag.value.current) + 15)
145
+ }
146
+ : null
147
+ )
148
+ const minutes = (time: string) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5))
149
+ const time = (value: number) =>
150
+ `${String(Math.floor(value / 60)).padStart(2, '0')}:${String(value % 60).padStart(2, '0')}`
151
+ const position = (start: number, end: number) => ({
152
+ top: `${((start - visibleRange.value.start) / visibleRange.value.duration) * 100}%`,
153
+ height: `${(Math.max(15, end - start) / visibleRange.value.duration) * 100}%`
154
+ })
155
+ function productNames(window: AvailabilityWindow) {
156
+ return window.productIds === null
157
+ ? [t('planning.calendarAllProducts')]
158
+ : window.productIds.map((id) => props.products.find((product) => product.id === id)?.title || t('planning.product'))
159
+ }
160
+ function windowsFor(date: string) {
161
+ return props.windows.filter(
162
+ (w) =>
163
+ date >= w.date &&
164
+ (!w.endDate || date <= w.endDate) &&
165
+ !w.exceptions.includes(date) &&
166
+ (w.recurring ? new Date(date).getUTCDay() === new Date(w.date).getUTCDay() : date === w.date)
167
+ )
168
+ }
169
+ function appointmentsFor(date: string) {
170
+ return props.appointments
171
+ .filter((a) => localParts(new Date(a.start), props.timezone).date === date)
172
+ .map((a) => {
173
+ const start = minutes(localParts(new Date(a.start), props.timezone).time)
174
+ const end = localParts(new Date(a.end), props.timezone)
175
+ return { ...a, startMinute: start, endMinute: end.date > date ? 1440 : minutes(end.time) }
176
+ })
177
+ }
178
+ function externalBusyFor(date: string) {
179
+ return (Array.isArray(props.externalBusy) ? props.externalBusy : [])
180
+ .filter((period) => !period.allDay)
181
+ .map((period, index) => {
182
+ const start = localParts(new Date(period.start), props.timezone)
183
+ const end = localParts(new Date(period.end), props.timezone)
184
+ if (start.date > date || end.date < date || (end.date === date && end.time === '00:00')) {
185
+ return null
186
+ }
187
+ return {
188
+ ...period,
189
+ id: `${period.start}-${period.end}-${index}`,
190
+ startMinute: start.date < date ? 0 : minutes(start.time),
191
+ endMinute: end.date > date ? 1440 : minutes(end.time)
192
+ }
193
+ })
194
+ .filter((period): period is NonNullable<typeof period> => !!period && period.endMinute > period.startMinute)
195
+ }
196
+ function allDayBusyFor(date: string) {
197
+ return (Array.isArray(props.externalBusy) ? props.externalBusy : []).filter(
198
+ (period) => period.allDay && period.startDate && period.endDate && date >= period.startDate && date < period.endDate
199
+ )
200
+ }
201
+ function eventStyle(date: string, key: string, start: number, end: number) {
202
+ const verticalPosition = position(start, end)
203
+ const items = [
204
+ ...appointmentsFor(date).map((appointment) => ({
205
+ key: `appointment-${appointment.id}`,
206
+ start: appointment.startMinute,
207
+ end: appointment.endMinute
208
+ })),
209
+ ...externalBusyFor(date).map((period) => ({
210
+ key: `external-${period.id}`,
211
+ start: period.startMinute,
212
+ end: period.endMinute
213
+ }))
214
+ ].sort((a, b) => a.start - b.start || a.end - b.end || a.key.localeCompare(b.key))
215
+ const groups: (typeof items)[] = []
216
+ for (const item of items) {
217
+ const group = groups.at(-1)
218
+ const groupEnd = group ? Math.max(...group.map((candidate) => candidate.end)) : -1
219
+ if (!group || item.start >= groupEnd) {
220
+ groups.push([item])
221
+ } else {
222
+ group.push(item)
223
+ }
224
+ }
225
+ const laidOut = groups.flatMap((group) => {
226
+ const laneEnds: number[] = []
227
+ const entries = group.map((item) => {
228
+ let lane = laneEnds.findIndex((laneEnd) => laneEnd <= item.start)
229
+ if (lane === -1) {
230
+ lane = laneEnds.length
231
+ }
232
+ laneEnds[lane] = item.end
233
+ return { ...item, lane }
234
+ })
235
+ return entries.map((item) => ({ ...item, laneCount: Math.max(1, laneEnds.length) }))
236
+ })
237
+ const layout = laidOut.find((item) => item.key === key)
238
+ const lane = layout?.lane || 0
239
+ const laneCount = layout?.laneCount || 1
240
+ return {
241
+ top: `calc(${verticalPosition.top} + 5px)`,
242
+ height: `calc(${verticalPosition.height} - 10px)`,
243
+ minHeight: '20px',
244
+ left: `calc(${(lane / laneCount) * 100}% + 10px)`,
245
+ width: `calc(${100 / laneCount}% - 20px)`
246
+ }
247
+ }
248
+ function windowStyle(window: AvailabilityWindow) {
249
+ return {
250
+ ...position(minutes(window.startTime), minutes(window.endTime)),
251
+ left: '4px',
252
+ width: 'calc(100% - 8px)'
253
+ }
254
+ }
255
+ function windowHasBusyOverlap(date: string, window: AvailabilityWindow) {
256
+ const start = minutes(window.startTime)
257
+ const end = minutes(window.endTime)
258
+ return [...appointmentsFor(date), ...externalBusyFor(date)].some(
259
+ (item) => item.startMinute < end && item.endMinute > start
260
+ )
261
+ }
262
+ function point(event: PointerEvent) {
263
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
264
+ return Math.max(
265
+ 0,
266
+ Math.min(
267
+ visibleRange.value.end - 15,
268
+ visibleRange.value.start +
269
+ Math.floor((((event.clientY - rect.top) / rect.height) * visibleRange.value.duration) / 15) * 15
270
+ )
271
+ )
272
+ }
273
+ function startDrag(event: PointerEvent, date: string) {
274
+ if (props.disabled || event.button !== 0) {
275
+ return
276
+ }
277
+ const target = event.currentTarget as HTMLElement
278
+ target.setPointerCapture(event.pointerId)
279
+ drag.value = {
280
+ date,
281
+ start: point(event),
282
+ current: point(event),
283
+ pointerId: event.pointerId,
284
+ y: event.clientY,
285
+ moved: false
286
+ }
287
+ }
288
+ function moveDrag(event: PointerEvent) {
289
+ if (drag.value?.pointerId === event.pointerId) {
290
+ if (Math.abs(event.clientY - drag.value.y) >= 4) {
291
+ drag.value.moved = true
292
+ }
293
+ drag.value.current = point(event)
294
+ }
295
+ }
296
+ function finishDrag(event: PointerEvent) {
297
+ if (!drag.value || drag.value.pointerId !== event.pointerId) {
298
+ return
299
+ }
300
+ if (!selection.value) {
301
+ drag.value = undefined
302
+ return
303
+ }
304
+ const date = drag.value.date,
305
+ { start, end } = selection.value
306
+ drag.value = undefined
307
+ emit('select', date, time(start), time(end))
308
+ }
309
+ </script>
310
+
311
+ <template>
312
+ <div class="flex min-h-0 flex-col gap-3">
313
+ <p class="flex shrink-0 items-center gap-2 text-sm text-muted">
314
+ <UIcon name="i-lucide-mouse-pointer-2" class="size-4 shrink-0" />{{
315
+ t(disabled ? 'planning.calendarReadOnly' : 'planning.dragAvailabilityHelp')
316
+ }}
317
+ </p>
318
+ <div ref="scroller" class="min-h-0 flex-1 overflow-auto rounded-xl border border-default">
319
+ <div class="min-w-[840px]">
320
+ <div class="sticky top-0 z-20 grid grid-cols-[72px_repeat(7,minmax(0,1fr))] border-b border-default bg-default">
321
+ <div class="flex items-center justify-center text-xs text-muted">
322
+ <UIcon name="i-lucide-clock" class="size-4" />
323
+ </div>
324
+ <div
325
+ v-for="date in days"
326
+ :key="date"
327
+ class="border-l border-default px-2 py-3 text-center text-sm font-medium"
328
+ >
329
+ {{
330
+ new Intl.DateTimeFormat(locale, {
331
+ weekday: 'short',
332
+ day: 'numeric',
333
+ month: 'short',
334
+ timeZone: 'UTC'
335
+ }).format(new Date(date))
336
+ }}
337
+ </div>
338
+ </div>
339
+ <div
340
+ v-if="hasAllDayEvents"
341
+ class="grid grid-cols-[72px_repeat(7,minmax(0,1fr))] border-b border-default bg-muted/30"
342
+ >
343
+ <div class="flex items-center justify-end px-2 py-1 text-[10px] text-muted">
344
+ {{ t('planning.allDay') }}
345
+ </div>
346
+ <div v-for="date in days" :key="date" class="min-h-8 space-y-1 border-l border-default p-1">
347
+ <PlanningTimezoneReadOnlyHover
348
+ v-for="period in allDayBusyFor(date)"
349
+ :key="`${period.start}-${period.end}-${period.title}`"
350
+ :readonly="false"
351
+ :user-timezone="userTimezone || timezone"
352
+ :timezone="timezone"
353
+ :title="period.title || t('planning.externalCalendarBusy')"
354
+ icon="i-lucide-lock"
355
+ :start="period.start"
356
+ :end="period.end"
357
+ all-day
358
+ >
359
+ <span
360
+ class="flex items-center gap-1 overflow-hidden rounded border border-default bg-elevated px-1.5 py-1 text-xs text-muted"
361
+ >
362
+ <UIcon name="i-lucide-lock" class="size-3 shrink-0" />
363
+ <span class="truncate">{{ period.title || t('planning.externalCalendarBusy') }}</span>
364
+ </span>
365
+ </PlanningTimezoneReadOnlyHover>
366
+ </div>
367
+ </div>
368
+ <div class="grid grid-cols-[72px_repeat(7,minmax(0,1fr))]">
369
+ <div class="relative bg-default" :style="{ height: 'clamp(576px, calc(100dvh - 280px), 1152px)' }">
370
+ <span
371
+ v-for="hour in hours"
372
+ :key="hour"
373
+ class="absolute right-2 whitespace-nowrap text-xs tabular-nums text-muted"
374
+ :style="{ top: `${((hour * 60 - visibleRange.start) / visibleRange.duration) * 100}%` }"
375
+ >{{ displayTime(hour * 60) }}</span
376
+ >
377
+ </div>
378
+ <div
379
+ v-for="date in days"
380
+ :key="date"
381
+ :data-calendar-date="date"
382
+ :style="{ height: 'clamp(576px, calc(100dvh - 280px), 1152px)' }"
383
+ class="relative touch-none select-none border-l border-default"
384
+ :aria-label="t('planning.addWindow') + ' ' + date"
385
+ @pointerdown="startDrag($event, date)"
386
+ @pointermove="moveDrag"
387
+ @pointerup="finishDrag"
388
+ @pointercancel="drag = undefined"
389
+ >
390
+ <div
391
+ v-for="hour in hours"
392
+ :key="hour"
393
+ class="pointer-events-none absolute inset-x-0 border-t border-default"
394
+ :style="{
395
+ top: `${((hour * 60 - visibleRange.start) / visibleRange.duration) * 100}%`,
396
+ height: `${6000 / visibleRange.duration}%`
397
+ }"
398
+ >
399
+ <div class="absolute inset-x-0 top-1/2 border-t border-dashed border-default/50" />
400
+ </div>
401
+ <PlanningTimezoneReadOnlyHover
402
+ v-for="window in windowsFor(date)"
403
+ :key="window.id"
404
+ :readonly="Boolean(readonlyTimezone)"
405
+ :user-timezone="userTimezone || timezone"
406
+ :timezone="timezone"
407
+ :title="t('planning.availability')"
408
+ icon="i-lucide-calendar-clock"
409
+ :editable="!disabled && !readonlyTimezone"
410
+ :start="window.sourceStart"
411
+ :end="window.sourceEnd"
412
+ class="absolute z-[3] min-w-0"
413
+ :style="windowStyle(window)"
414
+ @switch-timezone="emit('switchTimezone')"
415
+ @edit="emit('edit', date, window)"
416
+ >
417
+ <button
418
+ type="button"
419
+ :aria-disabled="disabled"
420
+ class="relative h-full w-full flex cursor-grab touch-none flex-col justify-start overflow-hidden rounded-md border border-primary/30 bg-primary/15 px-2 py-1 text-left text-xs text-primary focus-visible:outline-2 focus-visible:outline-primary"
421
+ :aria-label="`${window.startTime}–${window.endTime} ${t('planning.availability')}`"
422
+ @pointerdown.stop="startBlock($event, date, window)"
423
+ @pointermove.stop="moveBlock"
424
+ @pointerup.stop="finishBlock"
425
+ @pointercancel.stop="moving = undefined"
426
+ @click.stop="!disabled && $event.detail === 0 && emit('edit', date, window)"
427
+ >
428
+ <span
429
+ class="absolute inset-x-0 top-0 h-2 cursor-ns-resize hover:bg-primary/30"
430
+ @pointerdown.stop="startBlock($event, date, window, 'start')"
431
+ @pointermove.stop="moveBlock"
432
+ @pointerup.stop="finishBlock"
433
+ />
434
+ <span
435
+ v-if="!windowHasBusyOverlap(date, window)"
436
+ class="block w-full truncate font-semibold tabular-nums"
437
+ >{{ window.startTime }}–{{ window.endTime }}
438
+ {{ window.recurring || window.sourceRecurring ? '↻' : '' }}</span
439
+ ><template v-if="!windowHasBusyOverlap(date, window)">
440
+ <span v-for="(name, index) in productNames(window)" :key="index" class="block w-full truncate">{{
441
+ name
442
+ }}</span>
443
+ </template>
444
+ <span
445
+ class="absolute inset-x-0 bottom-0 h-2 cursor-ns-resize hover:bg-primary/30"
446
+ @pointerdown.stop="startBlock($event, date, window, 'end')"
447
+ @pointermove.stop="moveBlock"
448
+ @pointerup.stop="finishBlock"
449
+ />
450
+ </button>
451
+ </PlanningTimezoneReadOnlyHover>
452
+ <PlanningTimezoneReadOnlyHover
453
+ v-for="appointment in appointmentsFor(date)"
454
+ :key="appointment.id"
455
+ :readonly="false"
456
+ :user-timezone="userTimezone || timezone"
457
+ :timezone="timezone"
458
+ :title="appointment.title"
459
+ :customer-name="appointment.customerName"
460
+ :email="appointment.email"
461
+ :country="appointment.country"
462
+ :customer-timezone="appointment.customerTimezone"
463
+ :start="appointment.start"
464
+ :end="appointment.end"
465
+ class="absolute z-10 min-w-0"
466
+ :style="eventStyle(date, `appointment-${appointment.id}`, appointment.startMinute, appointment.endMinute)"
467
+ >
468
+ <button
469
+ type="button"
470
+ class="flex h-full w-full items-center overflow-hidden rounded-md border border-info/50 bg-[color-mix(in_oklab,var(--ui-info)_24%,var(--ui-bg))] px-1.5 py-0 text-xs leading-none text-info shadow-sm"
471
+ @pointerdown.stop
472
+ @click.stop="emit('appointment', appointment)"
473
+ >
474
+ <span class="flex min-w-0 items-center gap-1 font-semibold">
475
+ <UIcon name="i-lucide-calendar-check-2" class="size-3 shrink-0" />
476
+ <span class="truncate">{{ appointment.title }}</span>
477
+ </span>
478
+ <span v-if="appointment.conflict" class="text-error">{{ t('planning.conflict') }}</span>
479
+ </button>
480
+ </PlanningTimezoneReadOnlyHover>
481
+ <PlanningTimezoneReadOnlyHover
482
+ v-for="period in externalBusyFor(date)"
483
+ :key="period.id"
484
+ :readonly="false"
485
+ :user-timezone="userTimezone || timezone"
486
+ :timezone="timezone"
487
+ :title="period.title || t('planning.externalCalendarBusy')"
488
+ icon="i-lucide-lock"
489
+ :start="period.start"
490
+ :end="period.end"
491
+ class="absolute z-[5] min-w-0"
492
+ :style="eventStyle(date, `external-${period.id}`, period.startMinute, period.endMinute)"
493
+ >
494
+ <span
495
+ class="flex h-full w-full items-center overflow-hidden rounded-md border border-default bg-elevated/80 px-1.5 py-0 text-xs leading-none text-muted shadow-sm"
496
+ :aria-label="period.title || t('planning.externalCalendarBusy')"
497
+ >
498
+ <span class="flex min-w-0 items-center gap-1 font-medium">
499
+ <UIcon name="i-lucide-lock" class="size-3 shrink-0" />
500
+ <span v-if="period.endMinute - period.startMinute >= 30" class="truncate">{{
501
+ period.title || t('planning.externalCalendarBusy')
502
+ }}</span>
503
+ </span>
504
+ </span>
505
+ </PlanningTimezoneReadOnlyHover>
506
+ <div
507
+ v-if="moving?.targetDate === date"
508
+ class="pointer-events-none absolute inset-x-1 z-10 rounded-md border-2 border-primary bg-default px-2 py-1 text-xs font-semibold text-primary"
509
+ :style="position(moving.start, moving.end)"
510
+ >
511
+ {{ displayTime(moving.start) }}–{{ displayTime(moving.end) }}
512
+ </div>
513
+ <div
514
+ v-if="drag?.date === date && selection"
515
+ class="pointer-events-none absolute inset-x-1 z-10 rounded-md border border-primary bg-primary/25 px-2 py-1 text-xs font-semibold text-primary"
516
+ :style="position(selection.start, selection.end)"
517
+ >
518
+ {{ displayTime(selection.start) }}–{{ displayTime(selection.end) }}
519
+ </div>
520
+ </div>
521
+ </div>
522
+ </div>
523
+ </div>
524
+ </div>
525
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n()
3
+ const { data, pending } = await usePlanningDashboard()
4
+ const staff = computed(() => data.value?.access.staff ?? false)
5
+ </script>
6
+
7
+ <template>
8
+ <UCard class="h-full">
9
+ <template #header>
10
+ <div class="flex items-center justify-between gap-3">
11
+ <div>
12
+ <h2 class="font-semibold">{{ t('planning.dashboard.overview') }}</h2>
13
+ <p class="mt-1 text-sm text-muted">
14
+ {{
15
+ t(staff ? 'planning.dashboard.hostOverviewDescription' : 'planning.dashboard.clientOverviewDescription')
16
+ }}
17
+ </p>
18
+ </div>
19
+ <UIcon name="i-lucide-calendar-range" class="size-5 shrink-0 text-primary" />
20
+ </div>
21
+ </template>
22
+
23
+ <div class="grid grid-cols-2 gap-3" :class="staff ? 'sm:grid-cols-3' : ''">
24
+ <div class="rounded-lg border border-default p-4">
25
+ <p class="text-2xl font-semibold tabular-nums">{{ pending ? '…' : (data?.todayCount ?? 0) }}</p>
26
+ <p class="mt-1 text-sm text-muted">{{ t('planning.dashboard.today') }}</p>
27
+ </div>
28
+ <div class="rounded-lg border border-default p-4">
29
+ <p class="text-2xl font-semibold tabular-nums">{{ pending ? '…' : (data?.upcomingCount ?? 0) }}</p>
30
+ <p class="mt-1 text-sm text-muted">{{ t('planning.dashboard.upcoming') }}</p>
31
+ </div>
32
+ <NuxtLink
33
+ v-if="staff"
34
+ :to="{ path: '/appointments', query: { conflicts: 'conflicts' } }"
35
+ class="rounded-lg border border-default p-4 transition hover:border-primary hover:bg-elevated focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
36
+ :aria-label="t('planning.dashboard.viewConflicts', { count: data?.conflictCount ?? 0 })"
37
+ >
38
+ <p class="text-2xl font-semibold tabular-nums" :class="data?.conflictCount ? 'text-warning' : ''">
39
+ {{ pending ? '…' : (data?.conflictCount ?? 0) }}
40
+ </p>
41
+ <p class="mt-1 flex items-center gap-1 text-sm text-muted">
42
+ {{ t('planning.dashboard.conflicts') }}
43
+ <UIcon name="i-lucide-arrow-right" class="size-3.5" />
44
+ </p>
45
+ </NuxtLink>
46
+ </div>
47
+
48
+ <template #footer>
49
+ <div class="flex flex-wrap gap-2">
50
+ <UButton v-if="staff" to="/availability" variant="outline" icon="i-lucide-calendar-days">
51
+ {{ t('planning.dashboard.manageAvailability') }}
52
+ </UButton>
53
+ <UButton v-if="staff" to="/planning/settings" color="neutral" variant="outline" icon="i-lucide-calendar-cog">
54
+ {{ t('planning.myAppointmentSettings') }}
55
+ </UButton>
56
+ <UButton v-else to="/appointments" variant="outline" icon="i-lucide-calendar-check">
57
+ {{ t('planning.dashboard.manageBookings') }}
58
+ </UButton>
59
+ </div>
60
+ </template>
61
+ </UCard>
62
+ </template>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n()
3
+ const { appointmentRange } = usePlanningTimeDisplay()
4
+ const { data, pending, error, refresh } = await usePlanningDashboard()
5
+ const staff = computed(() => data.value?.access.staff ?? false)
6
+ const timezone = (appointment: NonNullable<typeof data.value>['appointments'][number]) =>
7
+ (staff.value ? appointment.providerTimezone : appointment.customerTimezone) || 'Europe/Amsterdam'
8
+ </script>
9
+
10
+ <template>
11
+ <UCard class="h-full">
12
+ <template #header>
13
+ <div class="flex items-center justify-between gap-3">
14
+ <div>
15
+ <h2 class="font-semibold">
16
+ {{ t(staff ? 'planning.dashboard.hostUpcoming' : 'planning.dashboard.clientUpcoming') }}
17
+ </h2>
18
+ <p class="mt-1 text-sm text-muted">{{ t('planning.dashboard.upcomingDescription') }}</p>
19
+ </div>
20
+ <UIcon name="i-lucide-calendar-clock" class="size-5 shrink-0 text-primary" />
21
+ </div>
22
+ </template>
23
+
24
+ <div v-if="pending" class="space-y-3" aria-busy="true">
25
+ <USkeleton v-for="item in 2" :key="item" class="h-16 w-full" />
26
+ </div>
27
+ <div v-else-if="error" class="flex items-center justify-between gap-3">
28
+ <p class="text-sm text-muted">{{ t('planning.loadError') }}</p>
29
+ <UButton color="neutral" variant="outline" icon="i-lucide-refresh-cw" @click="refresh()">
30
+ {{ t('dashboard.error.retry') }}
31
+ </UButton>
32
+ </div>
33
+ <div v-else-if="data?.appointments.length" class="divide-y divide-default">
34
+ <article v-for="appointment in data.appointments" :key="appointment.id" class="py-3 first:pt-0 last:pb-0">
35
+ <div class="flex items-start justify-between gap-3">
36
+ <div class="min-w-0">
37
+ <div class="flex items-center gap-2">
38
+ <p class="truncate font-medium">{{ appointment.title }}</p>
39
+ <UBadge v-if="appointment.conflict" color="warning" variant="subtle" size="sm">
40
+ {{ t('planning.conflict') }}
41
+ </UBadge>
42
+ </div>
43
+ <p class="mt-1 text-sm text-muted">
44
+ {{ appointmentRange(appointment.start, appointment.end, timezone(appointment)) }}
45
+ </p>
46
+ <p class="mt-1 truncate text-sm text-toned">
47
+ {{ staff ? appointment.customerName : appointment.providerName }}
48
+ </p>
49
+ </div>
50
+ <div class="flex shrink-0 gap-2">
51
+ <UButton
52
+ v-if="appointment.meetingProvider === 'zoom' && appointment.meetingUrl"
53
+ :to="appointment.meetingUrl"
54
+ target="_blank"
55
+ rel="noopener noreferrer"
56
+ size="xs"
57
+ variant="outline"
58
+ icon="i-lucide-video"
59
+ :aria-label="t('planning.joinZoom')"
60
+ />
61
+ <UButton
62
+ :to="`/appointments/${encodeURIComponent(appointment.id)}`"
63
+ size="xs"
64
+ color="neutral"
65
+ variant="ghost"
66
+ icon="i-lucide-arrow-right"
67
+ :aria-label="t('planning.manage')"
68
+ />
69
+ </div>
70
+ </div>
71
+ </article>
72
+ </div>
73
+ <p v-else class="text-sm text-muted">{{ t('planning.dashboard.noUpcoming') }}</p>
74
+
75
+ <template #footer>
76
+ <UButton to="/appointments" color="neutral" variant="outline" icon="i-lucide-calendar-check">
77
+ {{ t('planning.dashboard.viewAppointments') }}
78
+ </UButton>
79
+ </template>
80
+ </UCard>
81
+ </template>