@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/app/components/PlanningAppointmentCreateForm.vue +49 -0
- package/app/components/PlanningAppointmentDetails.vue +163 -0
- package/app/components/PlanningAppointmentDrawer.vue +109 -0
- package/app/components/PlanningAvailabilityCalendar.vue +525 -0
- package/app/components/PlanningDashboardOverview.vue +62 -0
- package/app/components/PlanningDashboardUpcomingAppointments.vue +81 -0
- package/app/components/PlanningPersonalCalendarSettings.vue +218 -0
- package/app/components/PlanningPersonalConnection.vue +68 -0
- package/app/components/PlanningPersonalMeetingSettings.vue +1 -0
- package/app/components/PlanningPolicyFields.vue +196 -0
- package/app/components/PlanningSettingsMembers.vue +50 -0
- package/app/components/PlanningSettingsPolicy.vue +46 -0
- package/app/components/PlanningSettingsSynchronization.vue +433 -0
- package/app/components/PlanningSlotPicker.vue +588 -0
- package/app/components/PlanningTimezoneReadOnlyHover.vue +172 -0
- package/app/components/PlanningTimezoneSelect.vue +101 -0
- package/app/composables/usePlanning.ts +180 -0
- package/app/composables/usePlanningDashboard.ts +4 -0
- package/app/composables/usePlanningTimeDisplay.ts +15 -0
- package/app/pages/admin/planning.vue +36 -0
- package/app/pages/appointments/[id].vue +13 -0
- package/app/pages/appointments/index.vue +384 -0
- package/app/pages/availability.vue +585 -0
- package/app/pages/booking/[slug].vue +298 -0
- package/app/pages/my-appointment-settings.vue +33 -0
- package/app/plugins/planning.ts +5 -0
- package/i18n/locales/en.json +260 -0
- package/i18n/locales/nl.json +260 -0
- package/migrations/0000_baseline.sql +58 -0
- package/migrations/0001_availability_calendar_title.sql +2 -0
- package/migrations/0002_job_activity.sql +11 -0
- package/migrations/0003_deduplicate_availability_jobs.sql +19 -0
- package/migrations/0004_connection_label.sql +2 -0
- package/nuxt.config.ts +15 -0
- package/package.json +55 -0
- package/portal.manifest.mjs +8 -0
- package/server/api/planning/admin/appointments/[id]/audit.get.ts +11 -0
- package/server/api/planning/admin/appointments.get.ts +11 -0
- package/server/api/planning/admin/jobs/[id]/retry.post.ts +26 -0
- package/server/api/planning/admin/jobs.get.ts +199 -0
- package/server/api/planning/admin/policy.get.ts +6 -0
- package/server/api/planning/admin/policy.put.ts +8 -0
- package/server/api/planning/admin/products/[id].put.ts +8 -0
- package/server/api/planning/admin/providers/[userId].patch.ts +8 -0
- package/server/api/planning/admin/providers.get.ts +6 -0
- package/server/api/planning/admin/retry.post.ts +31 -0
- package/server/api/planning/appointments/[id]/availability.get.ts +19 -0
- package/server/api/planning/appointments/[id]/cancel.post.ts +8 -0
- package/server/api/planning/appointments/[id]/files/[assetId].get.ts +17 -0
- package/server/api/planning/appointments/[id]/files.get.ts +28 -0
- package/server/api/planning/appointments/[id]/pending.delete.ts +8 -0
- package/server/api/planning/appointments/[id]/reschedule.put.ts +8 -0
- package/server/api/planning/appointments/[id].get.ts +6 -0
- package/server/api/planning/appointments.get.ts +4 -0
- package/server/api/planning/availability/[id].delete.ts +12 -0
- package/server/api/planning/availability/[id].put.ts +8 -0
- package/server/api/planning/availability.get.ts +6 -0
- package/server/api/planning/availability.post.ts +8 -0
- package/server/api/planning/calendar-busy.get.ts +29 -0
- package/server/api/planning/calendars.get.ts +8 -0
- package/server/api/planning/dashboard.get.ts +36 -0
- package/server/api/planning/google/notifications.post.ts +24 -0
- package/server/api/planning/oauth/[provider]/callback.get.ts +18 -0
- package/server/api/planning/oauth/[provider]/start.post.ts +8 -0
- package/server/api/planning/oauth/[provider].delete.ts +15 -0
- package/server/api/planning/products.get.ts +11 -0
- package/server/api/planning/provider-appointments.get.ts +11 -0
- package/server/api/planning/provider.get.ts +6 -0
- package/server/api/planning/provider.put.ts +8 -0
- package/server/api/store/planning/[productId]/availability.get.ts +8 -0
- package/server/api/store/planning/hold.delete.ts +11 -0
- package/server/api/store/planning/hold.get.ts +36 -0
- package/server/api/store/planning/holds.post.ts +10 -0
- package/server/db/schema/planning.ts +213 -0
- package/server/plugins/openapi.ts +36 -0
- package/server/plugins/planning.ts +6 -0
- package/server/tasks/planning/reconcile.ts +30 -0
- package/server/utils/access.ts +80 -0
- package/server/utils/adapters.ts +468 -0
- package/server/utils/appointments.ts +37 -0
- package/server/utils/booking.ts +619 -0
- package/server/utils/crypto.ts +23 -0
- package/server/utils/jobs.ts +671 -0
- package/server/utils/management.ts +427 -0
- package/server/utils/oauth.ts +104 -0
- package/server/utils/staff-appointments.ts +158 -0
- package/shared/appointment-calendar.ts +82 -0
- package/shared/availability.ts +140 -0
- package/shared/emails.ts +99 -0
- package/shared/feature.ts +78 -0
- package/shared/invitation.ts +58 -0
- package/shared/types.ts +110 -0
- 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>
|