@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,172 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ readonly: boolean
4
+ editable?: boolean
5
+ userTimezone: string
6
+ timezone?: string
7
+ title?: string
8
+ icon?: string
9
+ allDay?: boolean
10
+ customerName?: string
11
+ email?: string
12
+ country?: string
13
+ customerTimezone?: string
14
+ start?: string
15
+ end?: string
16
+ }>()
17
+ const emit = defineEmits<{ switchTimezone: []; edit: [] }>()
18
+ const { t, locale } = useI18n()
19
+ const { appointmentRange } = usePlanningTimeDisplay()
20
+ const popupId = useId()
21
+ const activePopup = useState<string | null>('planning.activeTimezoneHover', () => null)
22
+ const isOpen = computed({
23
+ get: () => activePopup.value === popupId,
24
+ set: (open: boolean) => {
25
+ if (open) {
26
+ activePopup.value = popupId
27
+ } else if (activePopup.value === popupId) {
28
+ activePopup.value = null
29
+ }
30
+ }
31
+ })
32
+ watch(
33
+ () => props.readonly,
34
+ (readonly) => {
35
+ if (!readonly) {
36
+ isOpen.value = false
37
+ }
38
+ }
39
+ )
40
+ onBeforeUnmount(() => {
41
+ isOpen.value = false
42
+ })
43
+ const ownTimezoneRange = computed(() =>
44
+ props.start && props.end ? appointmentRange(props.start, props.end, props.userTimezone) : ''
45
+ )
46
+ const viewedTimezoneRange = computed(() =>
47
+ props.allDay
48
+ ? t('planning.allDay')
49
+ : props.start && props.end && props.timezone
50
+ ? appointmentRange(props.start, props.end, props.timezone)
51
+ : ''
52
+ )
53
+ const customerTimezoneRange = computed(() =>
54
+ props.start && props.end && props.customerTimezone && props.customerTimezone !== props.timezone
55
+ ? appointmentRange(props.start, props.end, props.customerTimezone)
56
+ : ''
57
+ )
58
+ const hasPopover = computed(() => props.readonly || Boolean(props.title))
59
+ const timezoneName = (timezone: string) => timezone.replaceAll('_', ' ')
60
+ const countryName = computed(() => {
61
+ if (!props.country) {
62
+ return ''
63
+ }
64
+ try {
65
+ return new Intl.DisplayNames([locale.value], { type: 'region' }).of(props.country.toUpperCase()) || props.country
66
+ } catch {
67
+ return props.country
68
+ }
69
+ })
70
+ function edit() {
71
+ isOpen.value = false
72
+ emit('edit')
73
+ }
74
+ </script>
75
+
76
+ <template>
77
+ <span class="block">
78
+ <UPopover
79
+ v-if="hasPopover"
80
+ v-model:open="isOpen"
81
+ mode="hover"
82
+ :open-delay="300"
83
+ :close-delay="500"
84
+ :content="{ side: 'top', sideOffset: 14, collisionPadding: 16 }"
85
+ :ui="{
86
+ content:
87
+ 'planning-calendar-tooltip w-72 space-y-3 rounded-md border border-accented bg-default p-4 text-default shadow-xl ring-0'
88
+ }"
89
+ >
90
+ <span class="block h-full w-full" tabindex="0"><slot /></span>
91
+ <template #content>
92
+ <div class="space-y-3">
93
+ <div v-if="title || viewedTimezoneRange" class="space-y-2 border-b border-accented pb-3">
94
+ <p v-if="title" class="flex items-center gap-2 text-sm font-semibold">
95
+ <UIcon :name="icon || 'i-lucide-calendar-check-2'" class="size-4 shrink-0 text-info" />
96
+ {{ title }}
97
+ </p>
98
+ <div v-if="viewedTimezoneRange">
99
+ <p class="text-xs text-muted">{{ timezoneName(timezone || userTimezone) }}</p>
100
+ <p class="text-sm font-semibold">{{ viewedTimezoneRange }}</p>
101
+ </div>
102
+ </div>
103
+ <div
104
+ v-if="customerName || email || countryName || customerTimezoneRange"
105
+ class="space-y-2 border-b border-accented pb-3 text-sm"
106
+ >
107
+ <p v-if="customerName" class="flex items-center gap-2">
108
+ <UIcon name="i-lucide-user" class="size-4 shrink-0 text-muted" />
109
+ <span>{{ customerName }}</span>
110
+ </p>
111
+ <p v-if="email" class="flex items-center gap-2">
112
+ <UIcon name="i-lucide-mail" class="size-4 shrink-0 text-muted" />
113
+ <span class="break-all">{{ email }}</span>
114
+ </p>
115
+ <p v-if="countryName" class="flex items-center gap-2">
116
+ <UIcon name="i-lucide-map-pin" class="size-4 shrink-0 text-muted" />
117
+ <span>{{ countryName }}</span>
118
+ </p>
119
+ <div v-if="customerTimezoneRange" class="flex items-start gap-2">
120
+ <UIcon name="i-lucide-clock-3" class="mt-0.5 size-4 shrink-0 text-muted" />
121
+ <div>
122
+ <p class="text-xs text-muted">{{ timezoneName(customerTimezone || '') }}</p>
123
+ <p class="font-medium">{{ customerTimezoneRange }}</p>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ <div
128
+ v-if="readonly && ownTimezoneRange && userTimezone !== timezone"
129
+ class="space-y-1 border-b border-accented pb-3"
130
+ >
131
+ <p class="text-xs text-muted">{{ timezoneName(userTimezone) }}</p>
132
+ <p class="text-sm font-semibold">{{ ownTimezoneRange }}</p>
133
+ </div>
134
+ <template v-if="readonly">
135
+ <p class="text-sm">{{ t('planning.timezoneReadOnly', { timezone: timezoneName(userTimezone) }) }}</p>
136
+ <UButton size="sm" variant="soft" @click="emit('switchTimezone')">{{
137
+ t('planning.switchToUserTimezone')
138
+ }}</UButton>
139
+ </template>
140
+ <UButton v-else-if="editable" icon="i-lucide-pencil" size="sm" variant="soft" @click="edit">
141
+ {{ t('planning.editAvailability') }}
142
+ </UButton>
143
+ </div>
144
+ </template>
145
+ </UPopover>
146
+ <slot v-else />
147
+ </span>
148
+ </template>
149
+
150
+ <style>
151
+ .planning-calendar-tooltip::after {
152
+ position: absolute;
153
+ left: 50%;
154
+ width: 12px;
155
+ height: 12px;
156
+ content: '';
157
+ background: var(--ui-bg);
158
+ transform: translateX(-50%) rotate(45deg);
159
+ }
160
+
161
+ .planning-calendar-tooltip[data-side='top']::after {
162
+ bottom: -7px;
163
+ border-right: 1px solid var(--ui-border-accented);
164
+ border-bottom: 1px solid var(--ui-border-accented);
165
+ }
166
+
167
+ .planning-calendar-tooltip[data-side='bottom']::after {
168
+ top: -7px;
169
+ border-top: 1px solid var(--ui-border-accented);
170
+ border-left: 1px solid var(--ui-border-accented);
171
+ }
172
+ </style>
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{ userTimezone: string; disabled?: boolean }>()
3
+ const model = defineModel<string>({ required: true })
4
+ const { t } = useI18n()
5
+ const timezoneIds = Intl.supportedValuesOf('timeZone')
6
+ const recentTimezones = useState<string[]>('planning.recentTimezones', () => [])
7
+ const timezoneStorageKey = 'planning.recentTimezones'
8
+ type TimezoneMenuItem = {
9
+ type?: 'label' | 'separator'
10
+ label?: string
11
+ value?: string
12
+ favorite?: boolean
13
+ }
14
+ function normalizeTimezones(values: unknown): string[] {
15
+ return Array.isArray(values)
16
+ ? [
17
+ ...new Set(
18
+ values.filter(
19
+ (value): value is string =>
20
+ typeof value === 'string' && timezoneIds.includes(value) && value !== props.userTimezone
21
+ )
22
+ )
23
+ ]
24
+ : []
25
+ }
26
+ function removeRecentTimezone(timezone: string) {
27
+ if (timezone === model.value || timezone === props.userTimezone) {
28
+ return
29
+ }
30
+ recentTimezones.value = recentTimezones.value.filter((value) => value !== timezone)
31
+ persistRecentTimezones()
32
+ }
33
+ function persistRecentTimezones() {
34
+ try {
35
+ localStorage.setItem(timezoneStorageKey, JSON.stringify(recentTimezones.value))
36
+ } catch {
37
+ /* Preferences remain available when storage is blocked. */
38
+ }
39
+ }
40
+ function rememberTimezone(timezone: string) {
41
+ recentTimezones.value = normalizeTimezones(
42
+ timezone === props.userTimezone ? recentTimezones.value : [timezone, ...recentTimezones.value]
43
+ )
44
+ persistRecentTimezones()
45
+ }
46
+ const timezoneOptions = computed<TimezoneMenuItem[]>(() => {
47
+ const favorites = [props.userTimezone, ...normalizeTimezones(recentTimezones.value).slice(0, 5)]
48
+ const item = (value: string, favorite = false) => ({ favorite, value, label: value.replaceAll('_', ' ') })
49
+ return [
50
+ { type: 'label' as const, label: t('planning.timezoneFavorites') },
51
+ ...favorites.map((value) => item(value, true)),
52
+ { type: 'separator' as const },
53
+ { type: 'label' as const, label: t('planning.allTimezones') },
54
+ ...timezoneIds.filter((value) => !favorites.includes(value)).map((value) => item(value))
55
+ ]
56
+ })
57
+ onMounted(() => {
58
+ try {
59
+ recentTimezones.value = normalizeTimezones(JSON.parse(localStorage.getItem(timezoneStorageKey) || '[]'))
60
+ } catch {
61
+ recentTimezones.value = []
62
+ }
63
+ })
64
+ watch(
65
+ () => props.userTimezone,
66
+ () => rememberTimezone(props.userTimezone)
67
+ )
68
+ const selectedTimezone = computed({
69
+ get: () => model.value,
70
+ set: (timezone: string) => {
71
+ rememberTimezone(timezone)
72
+ model.value = timezone
73
+ }
74
+ })
75
+ </script>
76
+
77
+ <template>
78
+ <USelectMenu
79
+ v-model="selectedTimezone"
80
+ :items="timezoneOptions"
81
+ value-key="value"
82
+ :aria-label="t('planning.calendarTimezone')"
83
+ class="w-56"
84
+ :disabled="disabled"
85
+ :ui="{ item: 'group' }"
86
+ >
87
+ <template #item-trailing="{ item }"
88
+ ><UButton
89
+ v-if="item.value && item.favorite && item.value !== userTimezone && item.value !== model"
90
+ icon="i-lucide-x"
91
+ color="neutral"
92
+ variant="ghost"
93
+ size="xs"
94
+ class="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100"
95
+ :aria-label="t('planning.removeTimezoneFavorite', { timezone: item.label })"
96
+ @pointerdown.stop.prevent
97
+ @click.stop.prevent="removeRecentTimezone(item.value)"
98
+ @keydown.stop
99
+ /></template>
100
+ </USelectMenu>
101
+ </template>
@@ -0,0 +1,180 @@
1
+ import type { Slot, AvailabilityWindow, PlanningPolicy, ProviderSettings } from '../../shared/types'
2
+
3
+ export interface HoldResult {
4
+ holdToken: string
5
+ expiresAt: string
6
+ start: string
7
+ end: string
8
+ providerName: string
9
+ amount: number
10
+ currency: string
11
+ }
12
+ export interface AppointmentDetails {
13
+ id: string
14
+ productId: string
15
+ productSlug: string
16
+ title: string
17
+ providerName: string
18
+ start: string
19
+ end: string
20
+ status: 'confirmed' | 'cancelled'
21
+ customerTimezone: string
22
+ currency: string
23
+ meetingProvider: 'none' | 'zoom'
24
+ meetingUrl: string | null
25
+ changes: number
26
+ freeChanges: number
27
+ changeFee: number | null
28
+ pendingChangeExpiresAt: string | null
29
+ staff: boolean
30
+ canManage: boolean
31
+ revision: number
32
+ canReschedule: boolean
33
+ canCancel: boolean
34
+ refundAmount: number
35
+ conflict: boolean
36
+ }
37
+ export interface ProviderConfiguration {
38
+ enabled: boolean
39
+ timezone: string
40
+ graceMinutes: number
41
+ availabilityCalendarTitle: string
42
+ busyCalendarIds: string[]
43
+ writeCalendarId: string | null
44
+ connections: Array<{
45
+ provider: string
46
+ healthy: boolean
47
+ error: string | null
48
+ externalUserLabel: string | null
49
+ }>
50
+ products: Array<{ id: string; title: string; thumbnailImageId: string | null }>
51
+ }
52
+ export interface AppointmentListItem {
53
+ id: string
54
+ title: string
55
+ start: string
56
+ end: string
57
+ status: string
58
+ meetingProvider?: 'none' | 'zoom'
59
+ meetingUrl?: string | null
60
+ providerUserId?: string
61
+ providerName?: string
62
+ providerEmail?: string
63
+ providerTimezone?: string
64
+ customerTimezone?: string
65
+ conflict?: boolean
66
+ effectsError?: string
67
+ email?: string
68
+ customerName?: string
69
+ country?: string
70
+ }
71
+ export interface CalendarBusyPeriod {
72
+ start: string
73
+ end: string
74
+ title?: string
75
+ allDay?: boolean
76
+ startDate?: string
77
+ endDate?: string
78
+ }
79
+ export interface AppointmentFile {
80
+ id: string
81
+ name: string
82
+ content_type: string
83
+ size: number
84
+ }
85
+ export interface PlanningJobListItem {
86
+ id: string
87
+ kind: string
88
+ attempts: number
89
+ error: string | null
90
+ subject: string | null
91
+ availableAt: string
92
+ lastEventAt: string
93
+ completedAt: string | null
94
+ trigger: string | null
95
+ entity?:
96
+ | {
97
+ type: 'availability'
98
+ id: string
99
+ providerName: string
100
+ providerEmail: string
101
+ calendarId: string | null
102
+ date: string
103
+ endDate: string | null
104
+ startTime: string
105
+ endTime: string
106
+ timezone: string
107
+ recurring: boolean
108
+ allProducts: boolean
109
+ productTitles: readonly string[]
110
+ }
111
+ | {
112
+ type: 'appointment'
113
+ id: string
114
+ status: string
115
+ title: string
116
+ startAt: string
117
+ endAt: string
118
+ timezone: string
119
+ hostName: string
120
+ hostEmail: string
121
+ customerName: string
122
+ customerEmail: string
123
+ bookingReference: string
124
+ meetingId: string | null
125
+ meetingUrl: string | null
126
+ }
127
+ }
128
+ export const usePlanning = () => ({
129
+ available: (id: string, query: Record<string, unknown>, replacesId?: string) =>
130
+ $fetch<Slot[]>(
131
+ replacesId ? `/api/planning/appointments/${replacesId}/availability` : `/api/store/planning/${id}/availability`,
132
+ { query }
133
+ ),
134
+ reserve: (body: Record<string, unknown>) => $fetch<HoldResult>('/api/store/planning/holds', { method: 'POST', body }),
135
+ bookableProducts: () => $fetch<Array<{ slug: string; title: string }>>('/api/planning/products'),
136
+ provider: () => $fetch<ProviderConfiguration>('/api/planning/provider'),
137
+ saveProvider: (body: Record<string, unknown>) =>
138
+ $fetch<ProviderConfiguration>('/api/planning/provider', { method: 'PUT', body }),
139
+ calendars: () => $fetch<Array<{ id: string; summary: string; accessRole: string }>>('/api/planning/calendars'),
140
+ calendarBusy: (query: { from: string; to: string }) =>
141
+ $fetch<CalendarBusyPeriod[]>('/api/planning/calendar-busy', { query }),
142
+ connect: (provider: string) => $fetch<{ url: string }>(`/api/planning/oauth/${provider}/start`, { method: 'POST' }),
143
+ disconnect: (provider: string) => $fetch(`/api/planning/oauth/${provider}`, { method: 'DELETE' }),
144
+ windows: () => $fetch<AvailabilityWindow[]>('/api/planning/availability'),
145
+ saveWindow: (body: Record<string, unknown>, id?: string) =>
146
+ $fetch<AvailabilityWindow>(id ? `/api/planning/availability/${id}` : '/api/planning/availability', {
147
+ method: id ? 'PUT' : 'POST',
148
+ body
149
+ }),
150
+ deleteWindow: (id: string, occurrence?: string) =>
151
+ $fetch(`/api/planning/availability/${id}`, { method: 'DELETE', query: { occurrence } }),
152
+ providerAppointments: () => $fetch<AppointmentListItem[]>('/api/planning/provider-appointments'),
153
+ providers: () => $fetch<ProviderSettings[]>('/api/planning/admin/providers'),
154
+ setEnabled: (id: string, enabled: boolean) =>
155
+ $fetch(`/api/planning/admin/providers/${id}`, { method: 'PATCH', body: { enabled } }),
156
+ policy: () => $fetch<PlanningPolicy>('/api/planning/admin/policy'),
157
+ savePolicy: (body: PlanningPolicy) => $fetch<PlanningPolicy>('/api/planning/admin/policy', { method: 'PUT', body }),
158
+ appointments: (query: Record<string, unknown> = {}, signal?: AbortSignal) =>
159
+ $fetch<{
160
+ items: AppointmentListItem[]
161
+ pagination: { total: number; page: number; pageSize: number; pageCount: number }
162
+ access: { staff: boolean; canManage: boolean }
163
+ hosts: Array<{ id: string; name: string }>
164
+ }>('/api/planning/appointments', { query, signal }),
165
+ staffReschedule: (id: string, body: Record<string, unknown>) =>
166
+ $fetch(`/api/planning/appointments/${id}/reschedule`, { method: 'PUT', body }),
167
+ appointment: (id: string) => $fetch<AppointmentDetails>(`/api/planning/appointments/${id}`),
168
+ appointmentFiles: (id: string) => $fetch<AppointmentFile[]>(`/api/planning/appointments/${id}/files`),
169
+ abandonChange: (id: string) => $fetch(`/api/planning/appointments/${id}/pending`, { method: 'DELETE' }),
170
+ cancel: (id: string) => $fetch(`/api/planning/appointments/${id}/cancel`, { method: 'POST' }),
171
+ adminAppointments: () => $fetch<AppointmentListItem[]>('/api/planning/admin/appointments'),
172
+ jobs: (query: Record<string, unknown> = {}, signal?: AbortSignal) =>
173
+ $fetch<{
174
+ items: PlanningJobListItem[]
175
+ kinds: string[]
176
+ pagination: { total: number; page: number; pageSize: number; pageCount: number }
177
+ }>('/api/planning/admin/jobs', { query, signal }),
178
+ retry: () => $fetch('/api/planning/admin/retry', { method: 'POST' }),
179
+ retryJob: (id: string) => $fetch(`/api/planning/admin/jobs/${encodeURIComponent(id)}/retry`, { method: 'POST' })
180
+ })
@@ -0,0 +1,4 @@
1
+ import type { PlanningDashboardDto } from '../../shared/types'
2
+
3
+ export const usePlanningDashboard = () =>
4
+ useAsyncData('planning-dashboard', () => $fetch<PlanningDashboardDto>('/api/planning/dashboard'))
@@ -0,0 +1,15 @@
1
+ import { formatAppointmentRange, formatAppointmentTime } from '@nuxt-customer-portal/core/shared/appointment-time'
2
+
3
+ export function usePlanningTimeDisplay() {
4
+ const { t } = useI18n()
5
+ const browserLocale = ref<string[]>()
6
+ onMounted(() => {
7
+ browserLocale.value = [...navigator.languages]
8
+ })
9
+ const options = (timeZone: string) => ({ locale: browserLocale.value, timeZone, midnight: t('planning.midnight') })
10
+ return {
11
+ appointmentTime: (value: string | Date, timeZone: string) => formatAppointmentTime(value, options(timeZone)),
12
+ appointmentRange: (start: string | Date, end: string | Date, timeZone: string) =>
13
+ formatAppointmentRange(start, end, options(timeZone))
14
+ }
15
+ }
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n(),
3
+ route = useRoute()
4
+ const items = computed(() => [
5
+ { label: t('planning.providers'), value: 'members', slot: 'members' },
6
+ { label: t('planning.bookingPolicyTab'), value: 'policy', slot: 'policy' },
7
+ { label: t('planning.synchronizationTab'), value: 'synchronization', slot: 'synchronization' }
8
+ ])
9
+ const tab = computed({
10
+ get: () =>
11
+ ['members', 'policy', 'synchronization'].includes(String(route.query.tab)) ? String(route.query.tab) : 'members',
12
+ set: (value: string) => {
13
+ void navigateTo({ query: { ...route.query, tab: value === 'members' ? undefined : value } }, { replace: true })
14
+ }
15
+ })
16
+ </script>
17
+
18
+ <template>
19
+ <div class="h-full min-h-0 overflow-y-auto">
20
+ <UContainer class="space-y-6 py-8"
21
+ ><div class="flex flex-wrap items-center justify-between gap-3">
22
+ <h1 class="text-2xl font-bold">{{ t('planning.settings') }}</h1>
23
+ </div>
24
+ <UTabs
25
+ v-model="tab"
26
+ :items="items"
27
+ variant="link"
28
+ :unmount-on-hide="true"
29
+ :ui="{ list: 'justify-start', trigger: 'grow-0' }"
30
+ class="w-full"
31
+ ><template #members><PlanningSettingsMembers class="pt-4" /></template
32
+ ><template #policy><PlanningSettingsPolicy class="pt-4" /></template
33
+ ><template #synchronization><PlanningSettingsSynchronization class="pt-4" /></template></UTabs
34
+ ></UContainer>
35
+ </div>
36
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute(),
3
+ { t } = useI18n()
4
+ </script>
5
+
6
+ <template>
7
+ <div class="h-full min-h-0 overflow-y-auto">
8
+ <UContainer class="max-w-3xl space-y-5 py-8"
9
+ ><UButton to="/appointments" variant="link">{{ t('planning.back') }}</UButton
10
+ ><PlanningAppointmentDetails :id="String(route.params.id)" :key="String(route.params.id)"
11
+ /></UContainer>
12
+ </div>
13
+ </template>