@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,433 @@
1
+ <script setup lang="ts">
2
+ import { usePaginatedResource } from '@nuxt-customer-portal/core/app/composables/usePaginatedResource'
3
+ import type { PlanningJobListItem } from '../composables/usePlanning'
4
+
5
+ const api = usePlanning()
6
+ const { t, locale } = useI18n()
7
+ const route = useRoute()
8
+ const kinds = ref<string[]>([])
9
+ const error = ref('')
10
+ const retryingAll = ref(false)
11
+ const retryingId = ref('')
12
+ const list = ref<HTMLElement | null>(null)
13
+ const previousSentinel = ref<HTMLElement | null>(null)
14
+ const nextSentinel = ref<HTMLElement | null>(null)
15
+ const searchDraft = ref(typeof route.query.syncSearch === 'string' ? route.query.syncSearch : '')
16
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
17
+ let previousScrollHeight = 0
18
+
19
+ const filters = computed<{
20
+ search: string
21
+ status: string
22
+ kind: string
23
+ sort: string
24
+ direction: 'asc' | 'desc'
25
+ page: number
26
+ }>(() => ({
27
+ search: typeof route.query.syncSearch === 'string' ? route.query.syncSearch : '',
28
+ status: ['all', 'failed', 'queued', 'succeeded'].includes(String(route.query.syncStatus))
29
+ ? String(route.query.syncStatus)
30
+ : 'failed',
31
+ kind: typeof route.query.syncKind === 'string' ? route.query.syncKind : 'all',
32
+ sort: ['attempts', 'kind'].includes(String(route.query.syncSort)) ? String(route.query.syncSort) : 'lastEvent',
33
+ direction: route.query.syncDirection === 'asc' ? 'asc' : 'desc',
34
+ page: Math.max(1, Number(route.query.syncPage) || 1)
35
+ }))
36
+
37
+ async function update(changes: Partial<Omit<typeof filters.value, 'page'>>, page = 1) {
38
+ const next = { ...filters.value, ...changes, page }
39
+ await navigateTo(
40
+ {
41
+ query: {
42
+ ...route.query,
43
+ syncSearch: next.search || undefined,
44
+ syncStatus: next.status === 'failed' ? undefined : next.status,
45
+ syncKind: next.kind === 'all' ? undefined : next.kind,
46
+ syncSort: next.sort === 'lastEvent' ? undefined : next.sort,
47
+ syncDirection: next.direction === 'desc' ? undefined : next.direction,
48
+ syncPage: next.page === 1 ? undefined : String(next.page)
49
+ }
50
+ },
51
+ { replace: true }
52
+ )
53
+ }
54
+
55
+ function updateSearch(value: string | number) {
56
+ searchDraft.value = String(value)
57
+ clearTimeout(searchTimer)
58
+ searchTimer = setTimeout(() => void update({ search: searchDraft.value }), 250)
59
+ }
60
+
61
+ function updateFilter(key: string, value: string | undefined) {
62
+ if (key === 'status') {
63
+ void update({ status: value || 'all' })
64
+ }
65
+ if (key === 'kind') {
66
+ void update({ kind: value || 'all' })
67
+ }
68
+ }
69
+
70
+ const query = computed(() => ({
71
+ search: filters.value.search,
72
+ status: filters.value.status,
73
+ kind: filters.value.kind,
74
+ sort: filters.value.sort,
75
+ direction: filters.value.direction
76
+ }))
77
+ const resource = usePaginatedResource<PlanningJobListItem, typeof query.value>({
78
+ pageSize: 20,
79
+ getKey: (job) => job.id,
80
+ fetchPage: async ({ filters: requestFilters, page, pageSize, signal }) => {
81
+ const result = await api.jobs({ ...requestFilters, page, pageSize }, signal)
82
+ kinds.value = result.kinds
83
+ return result
84
+ }
85
+ })
86
+ const jobs = resource.items
87
+ const pagination = resource.pagination
88
+ const pending = resource.pending
89
+
90
+ async function loadPage(page = filters.value.page) {
91
+ error.value = ''
92
+ try {
93
+ await resource.loadPage(query.value, { page })
94
+ } catch {
95
+ error.value = t('planning.loadError')
96
+ }
97
+ }
98
+
99
+ watch(
100
+ () => JSON.stringify(query.value),
101
+ () => void loadPage(filters.value.page),
102
+ { immediate: true }
103
+ )
104
+ watch(
105
+ () => filters.value.page,
106
+ (page) => {
107
+ if (page >= resource.firstLoadedPage.value && page <= resource.lastLoadedPage.value) {
108
+ return
109
+ }
110
+ void loadPage(page)
111
+ }
112
+ )
113
+ watch(
114
+ () => filters.value.search,
115
+ (value) => (searchDraft.value = value)
116
+ )
117
+ onBeforeUnmount(() => clearTimeout(searchTimer))
118
+
119
+ async function retryAll() {
120
+ retryingAll.value = true
121
+ error.value = ''
122
+ try {
123
+ await api.retry()
124
+ await loadPage()
125
+ } catch {
126
+ error.value = t('planning.actionError')
127
+ } finally {
128
+ retryingAll.value = false
129
+ }
130
+ }
131
+
132
+ async function retryJob(id: string) {
133
+ retryingId.value = id
134
+ error.value = ''
135
+ try {
136
+ await api.retryJob(id)
137
+ await loadPage()
138
+ } catch {
139
+ error.value = t('planning.actionError')
140
+ } finally {
141
+ retryingId.value = ''
142
+ }
143
+ }
144
+
145
+ const dateTime = (value: string) =>
146
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
147
+ const kindLabel = (kind: string) => t(`planning.jobKinds.${kind}`, kind)
148
+ const productSummary = (job: PlanningJobListItem) =>
149
+ job.entity?.type === 'availability' && job.entity.allProducts
150
+ ? t('planning.jobAllProducts')
151
+ : job.entity?.type === 'availability'
152
+ ? job.entity.productTitles.join(', ') || t('planning.noProducts')
153
+ : t('planning.noProducts')
154
+ const appointmentTime = (job: PlanningJobListItem) => {
155
+ if (job.entity?.type !== 'appointment') {
156
+ return ''
157
+ }
158
+ const format = new Intl.DateTimeFormat(locale.value, {
159
+ dateStyle: 'medium',
160
+ timeStyle: 'short',
161
+ timeZone: job.entity.timezone
162
+ })
163
+ return `${format.format(new Date(job.entity.startAt))} – ${new Intl.DateTimeFormat(locale.value, {
164
+ timeStyle: 'short',
165
+ timeZone: job.entity.timezone
166
+ }).format(new Date(job.entity.endAt))} · ${job.entity.timezone}`
167
+ }
168
+
169
+ async function loadNext() {
170
+ const result = await resource.loadNextPage(query.value)
171
+ if (result) {
172
+ await update({}, result.pagination.page)
173
+ }
174
+ }
175
+
176
+ async function loadPrevious() {
177
+ previousScrollHeight = list.value?.scrollHeight ?? 0
178
+ const result = await resource.loadPreviousPage(query.value)
179
+ if (!result) {
180
+ return
181
+ }
182
+ await nextTick()
183
+ if (list.value) {
184
+ list.value.scrollTop += list.value.scrollHeight - previousScrollHeight
185
+ }
186
+ await update({}, result.pagination.page)
187
+ }
188
+
189
+ useAutoPagination({
190
+ sentinel: previousSentinel,
191
+ scrollContainer: list,
192
+ canLoadMore: resource.hasPreviousPage,
193
+ loading: resource.pending,
194
+ loadMore: loadPrevious,
195
+ rootMargin: '240px 0px 0px 0px'
196
+ })
197
+ useAutoPagination({
198
+ sentinel: nextSentinel,
199
+ scrollContainer: list,
200
+ canLoadMore: resource.hasNextPage,
201
+ loading: resource.pending,
202
+ loadMore: loadNext
203
+ })
204
+ </script>
205
+
206
+ <template>
207
+ <section class="flex h-[calc(100dvh-250px)] min-h-[460px] flex-col overflow-hidden rounded-xl border border-default">
208
+ <header class="shrink-0 bg-default">
209
+ <div class="flex flex-wrap items-start justify-between gap-3 p-4">
210
+ <div class="flex items-start gap-3">
211
+ <span class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
212
+ <UIcon name="i-lucide-list-checks" class="size-5" />
213
+ </span>
214
+ <div>
215
+ <h2 class="font-semibold text-highlighted">{{ t('planning.pendingTasks') }}</h2>
216
+ <p class="mt-1 text-sm text-muted">{{ t('planning.pendingTasksDescription') }}</p>
217
+ </div>
218
+ </div>
219
+ <div class="flex shrink-0 items-center gap-1">
220
+ <UButton
221
+ v-if="pagination.total && filters.status === 'failed'"
222
+ icon="i-lucide-refresh-cw"
223
+ variant="outline"
224
+ :loading="retryingAll"
225
+ @click="retryAll"
226
+ >
227
+ {{ t('planning.retryAll') }}
228
+ </UButton>
229
+ <UButton
230
+ icon="i-lucide-refresh-cw"
231
+ color="neutral"
232
+ variant="ghost"
233
+ size="sm"
234
+ :loading="pending"
235
+ :aria-label="t('common.refresh')"
236
+ @click="loadPage()"
237
+ />
238
+ </div>
239
+ </div>
240
+ <PortalListToolbar
241
+ :search="searchDraft"
242
+ :search-placeholder="t('planning.searchSynchronization')"
243
+ :filters="[
244
+ {
245
+ key: 'status',
246
+ placeholder: t('planning.status'),
247
+ items: [
248
+ { value: 'all', label: t('planning.allSyncStatuses') },
249
+ { value: 'failed', label: t('planning.failed') },
250
+ { value: 'queued', label: t('planning.queued') },
251
+ { value: 'succeeded', label: t('planning.succeeded') }
252
+ ]
253
+ },
254
+ {
255
+ key: 'kind',
256
+ placeholder: t('planning.taskType'),
257
+ items: [
258
+ { value: 'all', label: t('planning.allTaskTypes') },
259
+ ...kinds.map((kind) => ({ value: kind, label: kindLabel(kind) }))
260
+ ]
261
+ }
262
+ ]"
263
+ :filter-values="{ status: filters.status, kind: filters.kind }"
264
+ :sort-options="[
265
+ { value: 'lastEvent', label: t('planning.lastEvent') },
266
+ { value: 'attempts', label: t('planning.attemptCount') },
267
+ { value: 'kind', label: t('planning.taskType') }
268
+ ]"
269
+ :sort-by="filters.sort"
270
+ :sort-dir="filters.direction"
271
+ class="px-4"
272
+ @update:search="updateSearch"
273
+ @filter="updateFilter"
274
+ @sort="update({ sort: $event })"
275
+ @toggle-direction="update({ direction: filters.direction === 'desc' ? 'asc' : 'desc' })"
276
+ />
277
+ </header>
278
+
279
+ <div ref="list" class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden bg-muted/10 p-4">
280
+ <UAlert v-if="error" class="mb-4" variant="outline" color="error" :title="error" />
281
+ <div v-if="pending && !jobs.length" class="flex justify-center py-12" role="status">
282
+ <UIcon name="i-lucide-loader-circle" class="size-6 animate-spin text-primary" />
283
+ </div>
284
+ <div v-else-if="!error && !jobs.length" class="py-12 text-center">
285
+ <UIcon name="i-lucide-circle-check-big" class="mx-auto size-10 text-success" />
286
+ <h3 class="mt-3 font-semibold">
287
+ {{ t(filters.status === 'failed' ? 'planning.noPendingTasks' : 'planning.noMatchingTasks') }}
288
+ </h3>
289
+ <p class="mt-1 text-sm text-muted">
290
+ {{
291
+ t(
292
+ filters.status === 'failed' ? 'planning.noPendingTasksDescription' : 'planning.noMatchingTasksDescription'
293
+ )
294
+ }}
295
+ </p>
296
+ </div>
297
+ <div v-else class="grid gap-3">
298
+ <div v-if="resource.hasPreviousPage.value" ref="previousSentinel" class="h-px" aria-hidden="true" />
299
+ <div v-if="resource.loadingPreviousPage.value" class="grid gap-3 pb-1" role="status">
300
+ <USkeleton v-for="index in 2" :key="index" class="h-32 w-full" />
301
+ </div>
302
+ <article v-for="job in jobs" :key="job.id" class="rounded-lg border border-default bg-default p-4 shadow-xs">
303
+ <div class="flex flex-wrap items-start justify-between gap-3">
304
+ <div class="flex min-w-0 items-start gap-3">
305
+ <span
306
+ class="flex size-9 shrink-0 items-center justify-center rounded-md"
307
+ :class="
308
+ job.completedAt
309
+ ? 'bg-success/10 text-success'
310
+ : job.error
311
+ ? 'bg-error/10 text-error'
312
+ : 'bg-warning/10 text-warning'
313
+ "
314
+ >
315
+ <UIcon
316
+ :name="
317
+ job.completedAt
318
+ ? 'i-lucide-circle-check'
319
+ : job.error
320
+ ? 'i-lucide-triangle-alert'
321
+ : 'i-lucide-clock-3'
322
+ "
323
+ class="size-5"
324
+ />
325
+ </span>
326
+ <div class="min-w-0">
327
+ <div class="flex flex-wrap items-center gap-2">
328
+ <h3 class="font-semibold text-highlighted">{{ kindLabel(job.kind) }}</h3>
329
+ <UBadge :color="job.completedAt ? 'success' : job.error ? 'error' : 'warning'" variant="subtle">{{
330
+ t(job.completedAt ? 'planning.succeeded' : job.error ? 'planning.failed' : 'planning.queued')
331
+ }}</UBadge>
332
+ <UBadge v-if="job.trigger === 'reconciliation'" color="neutral" variant="subtle">{{
333
+ t('planning.scheduledCheck')
334
+ }}</UBadge>
335
+ </div>
336
+ <p v-if="job.subject" class="mt-1 truncate text-sm text-muted">{{ job.subject }}</p>
337
+ <p class="mt-1 text-xs text-dimmed">
338
+ {{ t('planning.lastEventValue', { date: dateTime(job.lastEventAt) }) }} ·
339
+ {{ t('planning.attempts', { count: job.attempts }) }}
340
+ </p>
341
+ </div>
342
+ </div>
343
+ <UButton
344
+ v-if="!job.completedAt"
345
+ icon="i-lucide-rotate-cw"
346
+ size="sm"
347
+ variant="outline"
348
+ :loading="retryingId === job.id"
349
+ :disabled="Boolean(retryingId) || retryingAll"
350
+ @click="retryJob(job.id)"
351
+ >
352
+ {{ t('planning.retryTask') }}
353
+ </UButton>
354
+ </div>
355
+ <div
356
+ v-if="job.entity?.type === 'availability'"
357
+ class="mt-3 grid gap-x-6 gap-y-2 text-sm text-muted sm:grid-cols-2"
358
+ >
359
+ <p class="flex min-w-0 items-center gap-2">
360
+ <UIcon name="i-lucide-user-round" class="size-4 shrink-0" />
361
+ <span class="truncate">{{ job.entity.providerEmail }}</span>
362
+ </p>
363
+ <p class="flex min-w-0 items-center gap-2">
364
+ <UIcon name="i-lucide-clock-3" class="size-4 shrink-0" />
365
+ <span
366
+ >{{ job.entity.date }}<template v-if="job.entity.endDate"> – {{ job.entity.endDate }}</template
367
+ >, {{ job.entity.startTime }}–{{ job.entity.endTime }} · {{ job.entity.timezone }}</span
368
+ >
369
+ </p>
370
+ <p class="flex min-w-0 items-center gap-2">
371
+ <UIcon name="i-lucide-package" class="size-4 shrink-0" />
372
+ <span class="truncate">{{ productSummary(job) }}</span>
373
+ </p>
374
+ <p class="flex min-w-0 items-center gap-2">
375
+ <UIcon name="i-lucide-calendar" class="size-4 shrink-0" />
376
+ <span class="truncate">{{ job.entity.calendarId || t('planning.noDestinationCalendar') }}</span>
377
+ </p>
378
+ </div>
379
+ <div
380
+ v-else-if="job.entity?.type === 'appointment'"
381
+ class="mt-3 grid gap-x-6 gap-y-2 rounded-md border border-default bg-muted/20 p-3 text-sm text-muted sm:grid-cols-2"
382
+ >
383
+ <p class="flex min-w-0 items-center gap-2">
384
+ <UIcon name="i-lucide-calendar-clock" class="size-4 shrink-0" />
385
+ <span>{{ appointmentTime(job) }}</span>
386
+ </p>
387
+ <p class="flex min-w-0 items-center gap-2">
388
+ <UIcon name="i-lucide-user-round" class="size-4 shrink-0" />
389
+ <span class="truncate"
390
+ >{{ t('planning.host') }}: {{ job.entity.hostName }} · {{ job.entity.hostEmail }}</span
391
+ >
392
+ </p>
393
+ <p class="flex min-w-0 items-center gap-2">
394
+ <UIcon name="i-lucide-contact-round" class="size-4 shrink-0" />
395
+ <span class="truncate"
396
+ >{{ t('planning.customer') }}: {{ job.entity.customerName }} · {{ job.entity.customerEmail }}</span
397
+ >
398
+ </p>
399
+ <p class="flex min-w-0 items-center gap-2">
400
+ <UIcon name="i-lucide-receipt-text" class="size-4 shrink-0" />
401
+ <span>{{ t('planning.bookingReference') }}: {{ job.entity.bookingReference }}</span>
402
+ </p>
403
+ <p class="flex min-w-0 items-center gap-2 sm:col-span-2">
404
+ <UIcon name="i-lucide-video" class="size-4 shrink-0" />
405
+ <span>
406
+ {{ t('planning.zoomMeeting') }}:
407
+ {{ job.entity.meetingUrl ? t('planning.zoomLinkReady') : t('planning.zoomLinkPending') }}
408
+ <template v-if="job.entity.meetingId"> · {{ job.entity.meetingId }}</template>
409
+ </span>
410
+ </p>
411
+ </div>
412
+ <div v-if="job.error" class="mt-3 rounded-md bg-error/5 px-3 py-2 text-sm text-error">
413
+ <p class="break-words">{{ job.error }}</p>
414
+ <p class="mt-1 text-xs opacity-75">
415
+ {{ t('planning.nextRetryValue', { date: dateTime(job.availableAt) }) }}
416
+ </p>
417
+ </div>
418
+ </article>
419
+ <div v-if="resource.hasNextPage.value" ref="nextSentinel" class="h-px" aria-hidden="true" />
420
+ <div v-if="resource.loadingNextPage.value" class="grid gap-3 pt-1" role="status">
421
+ <USkeleton v-for="index in 2" :key="index" class="h-32 w-full" />
422
+ </div>
423
+ </div>
424
+ </div>
425
+
426
+ <footer
427
+ v-if="!error"
428
+ class="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t border-default bg-default px-4 py-3 text-sm text-muted"
429
+ >
430
+ <span>{{ t('planning.taskCount', { count: pagination.total }) }}</span>
431
+ </footer>
432
+ </section>
433
+ </template>