@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,384 @@
1
+ <script setup lang="ts">
2
+ import type { AppointmentListItem } from '../../composables/usePlanning'
3
+
4
+ const { appointmentRange } = usePlanningTimeDisplay()
5
+ const api = usePlanning(),
6
+ { t } = useI18n(),
7
+ route = useRoute(),
8
+ scroller = ref<HTMLElement | null>(null),
9
+ nextSentinel = ref<HTMLElement | null>(null),
10
+ previousSentinel = ref<HTMLElement | null>(null),
11
+ createOpen = ref(false),
12
+ drawerOpen = ref(false),
13
+ drawerAppointment = ref<AppointmentListItem>(),
14
+ access = ref({ staff: false, canManage: false }),
15
+ hosts = ref<Array<{ id: string; name: string }>>([]),
16
+ searchDraft = ref(typeof route.query.search === 'string' ? route.query.search : '')
17
+ const { currentUser } = usePortalSession()
18
+ const userTimezone = computed(
19
+ () => (currentUser.value as typeof currentUser.value & { timezone?: string })?.timezone || 'Europe/Amsterdam'
20
+ )
21
+ const displayTimezone = computed({
22
+ get: () =>
23
+ typeof route.query.timezone === 'string' && Intl.supportedValuesOf('timeZone').includes(route.query.timezone)
24
+ ? route.query.timezone
25
+ : userTimezone.value,
26
+ set: (timezone: string) => {
27
+ void navigateTo(
28
+ { query: { ...route.query, timezone: timezone === userTimezone.value ? undefined : timezone } },
29
+ { replace: true }
30
+ )
31
+ }
32
+ })
33
+ const filters = computed(() => ({
34
+ search: typeof route.query.search === 'string' ? route.query.search : '',
35
+ status: ['confirmed', 'cancelled'].includes(String(route.query.status)) ? String(route.query.status) : 'all',
36
+ conflicts: route.query.conflicts === 'conflicts' ? 'conflicts' : 'all',
37
+ host: typeof route.query.host === 'string' ? route.query.host : 'all',
38
+ sortBy: route.query.sortBy === 'title' ? 'title' : 'start',
39
+ sortOrder: route.query.sortOrder === 'asc' ? ('asc' as const) : ('desc' as const)
40
+ }))
41
+ const page = computed(() => Math.max(1, Number(route.query.page) || 1))
42
+ const resource = usePaginatedResource<AppointmentListItem, typeof filters.value>({
43
+ getKey: (item) => item.id,
44
+ fetchPage: async ({ filters, page, signal }) => {
45
+ const result = await api.appointments({ ...filters, page }, signal)
46
+ access.value = result.access
47
+ hosts.value = result.hosts
48
+ return result
49
+ }
50
+ })
51
+ const { items, pagination, pending, loadingPreviousPage, hasNextPage, hasPreviousPage, error } = resource
52
+ let skipQuery = ''
53
+ async function load() {
54
+ await resource.loadPage(filters.value, { page: page.value })
55
+ }
56
+ await load()
57
+ watch(
58
+ () => JSON.stringify(route.query),
59
+ async (value) => {
60
+ if (value === skipQuery) {
61
+ skipQuery = ''
62
+ return
63
+ }
64
+ createOpen.value = false
65
+ drawerOpen.value = false
66
+ drawerAppointment.value = undefined
67
+ searchDraft.value = filters.value.search
68
+ await load()
69
+ }
70
+ )
71
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
72
+ function updateSearch(value: string) {
73
+ searchDraft.value = value
74
+ clearTimeout(searchTimer)
75
+ searchTimer = setTimeout(() => void update({ search: value }), 250)
76
+ }
77
+ onBeforeUnmount(() => clearTimeout(searchTimer))
78
+ function updateFilter(key: string, value: string | undefined) {
79
+ void update({ [key]: value || 'all' })
80
+ }
81
+ async function update(patch: Record<string, string | number>, target = 1) {
82
+ const query: Record<string, string | number | undefined> = {
83
+ timezone: typeof route.query.timezone === 'string' ? route.query.timezone : undefined,
84
+ ...filters.value,
85
+ ...patch,
86
+ page: target
87
+ }
88
+ for (const key of Object.keys(query)) {
89
+ if (
90
+ query[key] === '' ||
91
+ query[key] === 'all' ||
92
+ (key === 'sortBy' && query[key] === 'start') ||
93
+ (key === 'sortOrder' && query[key] === 'desc') ||
94
+ (key === 'page' && query[key] === 1)
95
+ ) {
96
+ query[key] = undefined
97
+ }
98
+ }
99
+ await navigateTo({ query })
100
+ }
101
+ async function boundary(direction: 'next' | 'previous') {
102
+ if (pending.value) {
103
+ return
104
+ }
105
+ const height = scroller.value?.scrollHeight || 0,
106
+ top = scroller.value?.scrollTop || 0
107
+ const result = direction === 'next' ? await resource.loadNextPage() : await resource.loadPreviousPage()
108
+ if (!result) {
109
+ return
110
+ }
111
+ await nextTick()
112
+ if (direction === 'previous' && scroller.value) {
113
+ scroller.value.scrollTop = top + scroller.value.scrollHeight - height
114
+ }
115
+ const query = { ...route.query, page: String(result.pagination.page) }
116
+ skipQuery = JSON.stringify(query)
117
+ await navigateTo({ query }, { replace: true })
118
+ }
119
+ useAutoPagination({
120
+ sentinel: nextSentinel,
121
+ scrollContainer: scroller,
122
+ canLoadMore: hasNextPage,
123
+ loading: pending,
124
+ loadMore: () => boundary('next')
125
+ })
126
+ useAutoPagination({
127
+ sentinel: previousSentinel,
128
+ scrollContainer: scroller,
129
+ canLoadMore: hasPreviousPage,
130
+ loading: pending,
131
+ loadMore: () => boundary('previous'),
132
+ rootMargin: '120px 0px 0px 0px'
133
+ })
134
+ function toggleCreate() {
135
+ createOpen.value = !createOpen.value
136
+ }
137
+ function manage(item: AppointmentListItem) {
138
+ drawerAppointment.value = item
139
+ drawerOpen.value = true
140
+ }
141
+ function visibleTimezone(timezone: string | undefined) {
142
+ return timezone || userTimezone.value
143
+ }
144
+ </script>
145
+
146
+ <template>
147
+ <div class="flex h-full min-h-0 flex-col">
148
+ <div ref="scroller" class="min-h-0 flex-1 overflow-y-auto">
149
+ <UContainer class="space-y-5 py-8">
150
+ <header class="flex flex-wrap items-center justify-between gap-3">
151
+ <div class="flex items-start gap-3">
152
+ <UIcon name="i-lucide-calendar-check" class="mt-1 size-6 text-primary" />
153
+ <div>
154
+ <h1 class="text-2xl font-semibold">{{ t('planning.appointments') }}</h1>
155
+ <p class="mt-1 text-sm text-muted">
156
+ {{ t(access.staff ? 'planning.staffAppointmentsSubtitle' : 'planning.customerAppointmentsSubtitle') }}
157
+ </p>
158
+ </div>
159
+ </div>
160
+ <div class="flex flex-wrap items-center gap-3">
161
+ <PlanningTimezoneSelect v-model="displayTimezone" :user-timezone="userTimezone" />
162
+ <UButton
163
+ v-if="access.canManage && pagination.total"
164
+ icon="i-lucide-plus"
165
+ variant="outline"
166
+ @click="toggleCreate"
167
+ >{{ t('planning.newAppointment') }}</UButton
168
+ >
169
+ <UButton
170
+ icon="i-lucide-refresh-cw"
171
+ color="neutral"
172
+ variant="ghost"
173
+ size="sm"
174
+ :loading="pending"
175
+ :aria-label="t('common.refresh')"
176
+ @click="load"
177
+ />
178
+ </div>
179
+ </header>
180
+ <PortalListToolbar
181
+ :search="searchDraft"
182
+ :search-placeholder="t('planning.searchAppointments')"
183
+ :filters="[
184
+ {
185
+ key: 'status',
186
+ placeholder: t('planning.status'),
187
+ items: [
188
+ { value: 'all', label: t('planning.allStatuses') },
189
+ { value: 'confirmed', label: t('planning.confirmed') },
190
+ { value: 'cancelled', label: t('planning.cancelled') }
191
+ ]
192
+ },
193
+ ...(access.staff
194
+ ? [
195
+ {
196
+ key: 'host',
197
+ placeholder: t('planning.host'),
198
+ items: [
199
+ { value: 'all', label: t('planning.allHosts') },
200
+ ...hosts.map((host) => ({ value: host.id, label: host.name }))
201
+ ]
202
+ },
203
+ {
204
+ key: 'conflicts',
205
+ placeholder: t('planning.conflict'),
206
+ items: [
207
+ { value: 'all', label: t('planning.allAppointments') },
208
+ { value: 'conflicts', label: t('planning.conflictsOnly') }
209
+ ]
210
+ }
211
+ ]
212
+ : [])
213
+ ]"
214
+ :filter-values="{ status: filters.status, host: filters.host, conflicts: filters.conflicts }"
215
+ :sort-options="[
216
+ { value: 'start', label: t('planning.sortDate') },
217
+ { value: 'title', label: t('planning.sortTitle') }
218
+ ]"
219
+ :sort-by="filters.sortBy"
220
+ :sort-dir="filters.sortOrder"
221
+ @update:search="updateSearch"
222
+ @filter="updateFilter"
223
+ @sort="update({ sortBy: $event })"
224
+ @toggle-direction="update({ sortOrder: filters.sortOrder === 'desc' ? 'asc' : 'desc' })"
225
+ />
226
+ <UAlert v-if="error" color="error" variant="outline" :title="t('planning.loadError')" />
227
+ <PlanningAppointmentCreateForm v-if="createOpen" @cancel="createOpen = false" />
228
+ <div v-if="hasPreviousPage" ref="previousSentinel" class="h-px" aria-hidden="true" />
229
+ <p v-if="loadingPreviousPage" role="status">{{ t('planning.loading') }}</p>
230
+ <div v-if="!pending && !items.length" class="rounded-lg border border-default px-6 py-10 text-center">
231
+ <UIcon name="i-lucide-calendar-days" class="size-10 text-dimmed" />
232
+ <h2 class="mt-3 font-semibold">
233
+ {{
234
+ t(
235
+ filters.search || filters.status !== 'all' || filters.host !== 'all' || filters.conflicts !== 'all'
236
+ ? 'planning.noMatchingAppointments'
237
+ : 'planning.noAppointments'
238
+ )
239
+ }}
240
+ </h2>
241
+ <p class="mt-2 text-sm text-muted">{{ t('planning.emptyAppointmentsDescription') }}</p>
242
+ <UButton
243
+ v-if="access.canManage && !createOpen"
244
+ icon="i-lucide-plus"
245
+ class="mt-5"
246
+ @click="createOpen = true"
247
+ >{{ t('planning.newAppointment') }}</UButton
248
+ >
249
+ </div>
250
+ <div class="flex flex-col gap-3">
251
+ <UCard v-for="item in items" :key="item.id">
252
+ <div class="flex flex-wrap items-start justify-between gap-3">
253
+ <div class="min-w-0 flex-1">
254
+ <div class="flex flex-wrap items-center gap-2">
255
+ <h2 class="font-semibold">{{ item.title }}</h2>
256
+ <UBadge :color="item.status === 'confirmed' ? 'success' : 'neutral'" variant="subtle">{{
257
+ t(`planning.${item.status}`)
258
+ }}</UBadge>
259
+ <UBadge v-if="item.conflict" color="error" variant="subtle">{{ t('planning.conflict') }}</UBadge>
260
+ </div>
261
+ <div class="mt-3 grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
262
+ <p class="flex min-w-0 items-start gap-2">
263
+ <UIcon name="i-lucide-calendar-clock" class="mt-0.5 size-4 shrink-0 text-muted" />
264
+ <span>
265
+ <span class="font-medium text-highlighted">{{
266
+ appointmentRange(item.start, item.end, displayTimezone)
267
+ }}</span>
268
+ <span class="block text-muted">{{ displayTimezone }}</span>
269
+ </span>
270
+ </p>
271
+ <p class="flex min-w-0 items-center gap-2">
272
+ <UIcon name="i-lucide-user-round" class="size-4 shrink-0 text-muted" />
273
+ <span class="min-w-0">
274
+ <span class="block truncate">{{ item.providerName || t('planning.unknownHost') }}</span>
275
+ <span v-if="access.staff && item.providerEmail" class="block truncate text-muted">{{
276
+ item.providerEmail
277
+ }}</span>
278
+ </span>
279
+ </p>
280
+ <p v-if="access.staff" class="flex min-w-0 items-center gap-2">
281
+ <UIcon name="i-lucide-contact-round" class="size-4 shrink-0 text-muted" />
282
+ <span class="truncate">{{ item.customerName || item.email }}</span>
283
+ </p>
284
+ <p v-if="access.staff" class="flex min-w-0 items-center gap-2">
285
+ <UIcon name="i-lucide-mail" class="size-4 shrink-0 text-muted" />
286
+ <span class="truncate">{{ item.email }}</span>
287
+ </p>
288
+ <p v-if="access.staff && item.country" class="flex min-w-0 items-center gap-2">
289
+ <UIcon name="i-lucide-map-pin" class="size-4 shrink-0 text-muted" />
290
+ <span class="truncate">{{ item.country }}</span>
291
+ </p>
292
+ <div v-if="item.meetingProvider === 'zoom'" class="flex min-w-0 items-center gap-2">
293
+ <UButton
294
+ v-if="item.status === 'confirmed' && item.meetingUrl"
295
+ :to="item.meetingUrl"
296
+ target="_blank"
297
+ icon="i-lucide-video"
298
+ size="xs"
299
+ variant="outline"
300
+ >
301
+ {{ t('planning.joinZoom') }}
302
+ </UButton>
303
+ <template v-else>
304
+ <UIcon name="i-lucide-video" class="size-4 shrink-0 text-primary" />
305
+ <span>
306
+ <span class="font-medium text-highlighted">{{ t('planning.zoomMeeting') }}</span>
307
+ <span v-if="item.status === 'confirmed'" class="block text-muted">{{
308
+ t('planning.zoomLinkPending')
309
+ }}</span>
310
+ </span>
311
+ </template>
312
+ </div>
313
+ <p
314
+ v-if="visibleTimezone(item.providerTimezone) !== displayTimezone"
315
+ class="flex min-w-0 items-start gap-2"
316
+ >
317
+ <UIcon name="i-lucide-clock-3" class="mt-0.5 size-4 shrink-0 text-muted" />
318
+ <span>
319
+ <span class="text-muted">{{ t('planning.hostTimezone') }}</span>
320
+ <span class="block">{{
321
+ appointmentRange(item.start, item.end, visibleTimezone(item.providerTimezone))
322
+ }}</span>
323
+ <span class="block text-muted">{{ visibleTimezone(item.providerTimezone) }}</span>
324
+ </span>
325
+ </p>
326
+ <p
327
+ v-if="visibleTimezone(item.customerTimezone) !== displayTimezone"
328
+ class="flex min-w-0 items-start gap-2"
329
+ >
330
+ <UIcon name="i-lucide-globe-2" class="mt-0.5 size-4 shrink-0 text-muted" />
331
+ <span>
332
+ <span class="text-muted">{{ t('planning.customerTimezone') }}</span>
333
+ <span class="block">{{
334
+ appointmentRange(item.start, item.end, visibleTimezone(item.customerTimezone))
335
+ }}</span>
336
+ <span class="block text-muted">{{ visibleTimezone(item.customerTimezone) }}</span>
337
+ </span>
338
+ </p>
339
+ </div>
340
+ </div>
341
+ <div class="flex flex-wrap items-center justify-end gap-2">
342
+ <UButton
343
+ :icon="access.canManage ? 'i-lucide-settings-2' : 'i-lucide-eye'"
344
+ color="neutral"
345
+ variant="outline"
346
+ @click="manage(item)"
347
+ >
348
+ {{ t(access.canManage ? 'planning.manageAppointment' : 'planning.viewAppointment') }}
349
+ </UButton>
350
+ </div>
351
+ </div>
352
+ <p v-if="item.effectsError && access.staff" class="mt-2 break-words text-sm text-error">
353
+ {{ t('planning.syncError') }}
354
+ </p>
355
+ </UCard>
356
+ </div>
357
+ <p v-if="pending" role="status">{{ t('planning.loading') }}</p>
358
+ <div v-if="hasNextPage" ref="nextSentinel" class="h-px" aria-hidden="true" />
359
+ </UContainer>
360
+ </div>
361
+ <footer
362
+ class="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t border-default px-6 py-3 text-sm text-muted"
363
+ >
364
+ <span>{{ t('planning.appointmentCount', { count: pagination.total }) }}</span
365
+ ><UPagination
366
+ v-if="pagination.pageCount > 1"
367
+ :page="pagination.page"
368
+ :total="pagination.total"
369
+ :items-per-page="20"
370
+ :disabled="pending"
371
+ :aria-label="t('planning.pagination')"
372
+ @update:page="update({}, $event)"
373
+ />
374
+ </footer>
375
+ <PlanningAppointmentDrawer
376
+ v-if="drawerOpen && drawerAppointment"
377
+ v-model:open="drawerOpen"
378
+ :item="drawerAppointment"
379
+ :timezone="displayTimezone"
380
+ :staff="access.staff"
381
+ @changed="load"
382
+ />
383
+ </div>
384
+ </template>