@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,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>
|