@nuxt-customer-portal/service-requests 0.3.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 +8 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/app/components/Admin/RequestDashboard.vue +64 -0
- package/app/components/Admin/RequestTable.vue +158 -0
- package/app/components/CustomerRequestDetail.vue +76 -0
- package/app/components/CustomerRequestForm.vue +143 -0
- package/app/components/ServiceRequestsDashboardAttention.vue +55 -0
- package/app/components/ServiceRequestsDashboardOverview.vue +55 -0
- package/app/components/StatusBadge.vue +16 -0
- package/app/composables/useAdminServiceRequests.ts +60 -0
- package/app/composables/usePaginatedServiceRequests.ts +21 -0
- package/app/composables/useServiceRequestAccess.ts +18 -0
- package/app/composables/useServiceRequestMenu.ts +34 -0
- package/app/composables/useServiceRequests.ts +134 -0
- package/app/composables/useServiceRequestsDashboard.ts +13 -0
- package/app/pages/admin/requests/[id].vue +140 -0
- package/app/pages/admin/requests/index.vue +55 -0
- package/app/pages/requests/[id].vue +161 -0
- package/app/pages/requests/index.vue +873 -0
- package/app/pages/requests/new.vue +37 -0
- package/app/plugins/service-requests-feature.ts +18 -0
- package/i18n/locales/en.json +97 -0
- package/i18n/locales/nl.json +97 -0
- package/migrations/0000_baseline.sql +30 -0
- package/migrations/0001_required_client.sql +6 -0
- package/migrations/meta/0000_snapshot.json +245 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +29 -0
- package/package.json +53 -0
- package/portal.manifest.mjs +9 -0
- package/server/api/service-requests/[id].delete.ts +42 -0
- package/server/api/service-requests/[id].get.ts +39 -0
- package/server/api/service-requests/[id].patch.ts +44 -0
- package/server/api/service-requests/admin/[id].patch.ts +43 -0
- package/server/api/service-requests/admin/index.get.ts +19 -0
- package/server/api/service-requests/dashboard.get.ts +11 -0
- package/server/api/service-requests/index.get.ts +22 -0
- package/server/api/service-requests/index.post.ts +39 -0
- package/server/db/schema/service-requests.ts +78 -0
- package/server/plugins/openapi-contracts.ts +22 -0
- package/server/utils/service-request-repository.ts +185 -0
- package/server/utils/service-request-scope.ts +41 -0
- package/server/utils/service-request-validation.ts +54 -0
- package/shared/feature.ts +90 -0
- package/shared/types/service-request.ts +111 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useAutoPagination } from '@nuxt-customer-portal/core/app/composables/useAutoPagination'
|
|
3
|
+
import type {
|
|
4
|
+
ServiceRequest,
|
|
5
|
+
ServiceRequestCreateInput,
|
|
6
|
+
ServiceRequestFilters,
|
|
7
|
+
ServiceRequestPriority,
|
|
8
|
+
ServiceRequestStatus,
|
|
9
|
+
ServiceRequestUpdateInput,
|
|
10
|
+
ServiceRequestWithRelations
|
|
11
|
+
} from '@nuxt-customer-portal/service-requests/shared/types/service-request'
|
|
12
|
+
|
|
13
|
+
const route = useRoute()
|
|
14
|
+
const router = useRouter()
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
const toast = useToast()
|
|
17
|
+
const { currentUser } = usePortalSession()
|
|
18
|
+
const { can } = useServiceRequestAccess()
|
|
19
|
+
|
|
20
|
+
useSeoMeta({
|
|
21
|
+
title: () => t('features.serviceRequests.title')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const { getStatusColor, getStatusBadgeText, getPriorityBadgeText, getPriorityColor, statusOptions, priorityOptions } =
|
|
25
|
+
useServiceRequests()
|
|
26
|
+
|
|
27
|
+
// Mobile breakpoint detection
|
|
28
|
+
const toolbarElement = ref<HTMLElement | null>(null)
|
|
29
|
+
const { width: toolbarWidth } = useElementSize(toolbarElement)
|
|
30
|
+
const isMobile = computed(() => toolbarWidth.value < 1060)
|
|
31
|
+
|
|
32
|
+
// Filter modal state
|
|
33
|
+
const showFiltersModal = ref(false)
|
|
34
|
+
const showSortModal = ref(false)
|
|
35
|
+
|
|
36
|
+
const pageSize = 20
|
|
37
|
+
const {
|
|
38
|
+
items: list,
|
|
39
|
+
pagination,
|
|
40
|
+
pending,
|
|
41
|
+
loadingNextPage,
|
|
42
|
+
loadingPreviousPage,
|
|
43
|
+
hasNextPage,
|
|
44
|
+
hasPreviousPage,
|
|
45
|
+
error: loadError,
|
|
46
|
+
loadPage,
|
|
47
|
+
loadNextPage: appendNextPage,
|
|
48
|
+
loadPreviousPage: prependPreviousPage
|
|
49
|
+
} = usePaginatedServiceRequests(pageSize)
|
|
50
|
+
|
|
51
|
+
// Initialize from URL (with defaults)
|
|
52
|
+
const currentPage = ref(Math.max(1, Number(route.query.page) || 1))
|
|
53
|
+
const listContainerRef = ref<HTMLElement | null>(null)
|
|
54
|
+
const loadPreviousSentinelRef = ref<HTMLElement | null>(null)
|
|
55
|
+
const loadMoreSentinelRef = ref<HTMLElement | null>(null)
|
|
56
|
+
|
|
57
|
+
// Drawer state (create / view / edit)
|
|
58
|
+
type DrawerMode = 'create' | 'view' | 'edit'
|
|
59
|
+
const drawerOpen = ref(false)
|
|
60
|
+
const drawerMode = ref<DrawerMode>('view')
|
|
61
|
+
const selectedRequest = ref<ServiceRequest | null>(null)
|
|
62
|
+
const mutationPending = ref(false)
|
|
63
|
+
|
|
64
|
+
const drawerTitle = computed(() => {
|
|
65
|
+
if (drawerMode.value === 'create') {
|
|
66
|
+
return t('features.serviceRequests.create')
|
|
67
|
+
}
|
|
68
|
+
if (drawerMode.value === 'edit') {
|
|
69
|
+
return t('features.serviceRequests.edit')
|
|
70
|
+
}
|
|
71
|
+
return selectedRequest.value?.title || t('features.serviceRequests.title')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const canCreate = computed(() => can('create'))
|
|
75
|
+
const isOwner = computed(() => {
|
|
76
|
+
if (!selectedRequest.value?.createdById) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
return selectedRequest.value.createdById === currentUser.value?.id
|
|
80
|
+
})
|
|
81
|
+
const canEdit = computed(() => isOwner.value || can('update'))
|
|
82
|
+
const canDelete = computed(() => isOwner.value || can('delete'))
|
|
83
|
+
|
|
84
|
+
const showDeleteConfirm = ref(false)
|
|
85
|
+
|
|
86
|
+
const openCreateDrawer = () => {
|
|
87
|
+
drawerMode.value = 'create'
|
|
88
|
+
selectedRequest.value = null
|
|
89
|
+
drawerOpen.value = true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const closeDrawer = () => {
|
|
93
|
+
drawerOpen.value = false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Filter state (init from URL)
|
|
97
|
+
const statusFilter = ref<ServiceRequestStatus | undefined>((route.query.status as ServiceRequestStatus) || undefined)
|
|
98
|
+
const priorityFilter = ref<ServiceRequestPriority | undefined>(
|
|
99
|
+
(route.query.priority as ServiceRequestPriority) || undefined
|
|
100
|
+
)
|
|
101
|
+
const categoryFilter = ref<string | undefined>((route.query.category as string) || undefined)
|
|
102
|
+
const searchQuery = ref((route.query.search as string) ?? '')
|
|
103
|
+
|
|
104
|
+
// Sort state (init from URL)
|
|
105
|
+
const sortBy = ref<'createdAt' | 'status' | 'priority'>(
|
|
106
|
+
['createdAt', 'status', 'priority'].includes(route.query.sortBy as string)
|
|
107
|
+
? (route.query.sortBy as 'createdAt' | 'status' | 'priority')
|
|
108
|
+
: 'createdAt'
|
|
109
|
+
)
|
|
110
|
+
const sortDir = ref<'asc' | 'desc'>(route.query.sortDir === 'asc' ? 'asc' : 'desc')
|
|
111
|
+
|
|
112
|
+
const sortOptions = computed(() => [
|
|
113
|
+
{ label: t('features.serviceRequests.fields.createdAt'), value: 'createdAt' as const },
|
|
114
|
+
{ label: t('features.serviceRequests.fields.status'), value: 'status' as const },
|
|
115
|
+
{ label: t('features.serviceRequests.fields.priority'), value: 'priority' as const }
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
const currentSortLabel = computed(() => {
|
|
119
|
+
return sortOptions.value.find((o) => o.value === sortBy.value)?.label || ''
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const sortDropdownItems = computed(() => [
|
|
123
|
+
sortOptions.value.map((option) => ({
|
|
124
|
+
label: option.label,
|
|
125
|
+
icon: sortBy.value === option.value ? 'i-lucide-check' : undefined,
|
|
126
|
+
onSelect: () => {
|
|
127
|
+
sortBy.value = option.value
|
|
128
|
+
}
|
|
129
|
+
}))
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
const toggleSortDir = () => {
|
|
133
|
+
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildListQuery(includePage = true) {
|
|
137
|
+
const q: Record<string, string> = {}
|
|
138
|
+
if (searchQuery.value.trim()) {
|
|
139
|
+
q.search = searchQuery.value.trim()
|
|
140
|
+
}
|
|
141
|
+
if (statusFilter.value) {
|
|
142
|
+
q.status = statusFilter.value
|
|
143
|
+
}
|
|
144
|
+
if (priorityFilter.value) {
|
|
145
|
+
q.priority = priorityFilter.value
|
|
146
|
+
}
|
|
147
|
+
if (categoryFilter.value) {
|
|
148
|
+
q.category = categoryFilter.value
|
|
149
|
+
}
|
|
150
|
+
if (sortBy.value !== 'createdAt') {
|
|
151
|
+
q.sortBy = sortBy.value
|
|
152
|
+
}
|
|
153
|
+
if (sortDir.value !== 'desc') {
|
|
154
|
+
q.sortDir = sortDir.value
|
|
155
|
+
}
|
|
156
|
+
if (includePage && currentPage.value > 1) {
|
|
157
|
+
q.page = String(currentPage.value)
|
|
158
|
+
}
|
|
159
|
+
return q
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getDetailTo(request: ServiceRequestWithRelations) {
|
|
163
|
+
const query = new URLSearchParams({ from: 'list', ...buildListQuery() })
|
|
164
|
+
return `/requests/${request.id}?${query.toString()}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Extract unique categories from loaded requests
|
|
168
|
+
const categoryOptions = computed(() => {
|
|
169
|
+
const categories = new Set<string>()
|
|
170
|
+
list.value.forEach((request) => {
|
|
171
|
+
if (request.category) {
|
|
172
|
+
categories.add(request.category)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
const sortedCategories = Array.from(categories).sort()
|
|
176
|
+
return [
|
|
177
|
+
{ label: t('features.serviceRequests.filters.allCategories'), value: undefined },
|
|
178
|
+
...sortedCategories.map((cat) => ({ label: cat, value: cat }))
|
|
179
|
+
]
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const currentFilters = (): ServiceRequestFilters => ({
|
|
183
|
+
status: statusFilter.value,
|
|
184
|
+
priority: priorityFilter.value,
|
|
185
|
+
category: categoryFilter.value,
|
|
186
|
+
search: searchQuery.value.trim() || undefined,
|
|
187
|
+
sortBy: sortBy.value,
|
|
188
|
+
sortDir: sortDir.value
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const loadData = async () => {
|
|
192
|
+
try {
|
|
193
|
+
const result = await loadPage(currentFilters(), {
|
|
194
|
+
page: currentPage.value,
|
|
195
|
+
pageSize
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
if (result && result.pagination.pageCount > 0 && currentPage.value > result.pagination.pageCount) {
|
|
199
|
+
currentPage.value = result.pagination.pageCount
|
|
200
|
+
await updateRoute()
|
|
201
|
+
await loadData()
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
console.error(e)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const loadNextPage = async () => {
|
|
209
|
+
try {
|
|
210
|
+
const result = await appendNextPage(currentFilters())
|
|
211
|
+
|
|
212
|
+
if (!result) {
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
currentPage.value = result.pagination.page
|
|
216
|
+
await updateRoute()
|
|
217
|
+
} catch (e) {
|
|
218
|
+
console.error(e)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const loadPreviousPage = async () => {
|
|
223
|
+
const container = listContainerRef.value
|
|
224
|
+
const previousScrollHeight = container?.scrollHeight ?? 0
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const result = await prependPreviousPage(currentFilters())
|
|
228
|
+
if (!result) {
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await nextTick()
|
|
233
|
+
if (container) {
|
|
234
|
+
container.scrollTop += container.scrollHeight - previousScrollHeight
|
|
235
|
+
}
|
|
236
|
+
currentPage.value = result.pagination.page
|
|
237
|
+
await updateRoute()
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error(e)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const refresh = async () => {
|
|
244
|
+
currentPage.value = 1
|
|
245
|
+
await updateRoute()
|
|
246
|
+
await loadData()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
type WithOptionalCategory = { category?: string }
|
|
250
|
+
const sanitizeCreateOrUpdatePayload = <T extends WithOptionalCategory>(data: T): T => {
|
|
251
|
+
// Avoid storing empty-string category
|
|
252
|
+
const payload = { ...data } as T & WithOptionalCategory
|
|
253
|
+
if (typeof payload.category === 'string' && payload.category.trim() === '') {
|
|
254
|
+
delete (payload as WithOptionalCategory).category
|
|
255
|
+
}
|
|
256
|
+
return payload
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const handleCreate = async (data: ServiceRequestCreateInput) => {
|
|
260
|
+
mutationPending.value = true
|
|
261
|
+
try {
|
|
262
|
+
await $fetch<ServiceRequest>('/api/service-requests', {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
body: sanitizeCreateOrUpdatePayload(data)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
toast.add({
|
|
268
|
+
title: t('common.success'),
|
|
269
|
+
description: t('features.serviceRequests.messages.createSuccess')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
closeDrawer()
|
|
273
|
+
await refresh()
|
|
274
|
+
} catch (e) {
|
|
275
|
+
console.error(e)
|
|
276
|
+
toast.add({
|
|
277
|
+
title: t('common.error'),
|
|
278
|
+
description: t('features.serviceRequests.messages.createError'),
|
|
279
|
+
color: 'error'
|
|
280
|
+
})
|
|
281
|
+
} finally {
|
|
282
|
+
mutationPending.value = false
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const handleUpdate = async (data: ServiceRequestCreateInput) => {
|
|
287
|
+
if (!selectedRequest.value?.id) {
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
mutationPending.value = true
|
|
292
|
+
try {
|
|
293
|
+
const updated = await $fetch<ServiceRequest>(`/api/service-requests/${selectedRequest.value.id}`, {
|
|
294
|
+
method: 'PATCH',
|
|
295
|
+
body: sanitizeCreateOrUpdatePayload(data as ServiceRequestUpdateInput)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// Keep local selection in sync
|
|
299
|
+
selectedRequest.value = updated
|
|
300
|
+
|
|
301
|
+
toast.add({
|
|
302
|
+
title: t('common.success'),
|
|
303
|
+
description: t('features.serviceRequests.messages.updateSuccess')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
closeDrawer()
|
|
307
|
+
await refresh()
|
|
308
|
+
} catch (e) {
|
|
309
|
+
console.error(e)
|
|
310
|
+
toast.add({
|
|
311
|
+
title: t('common.error'),
|
|
312
|
+
description: t('features.serviceRequests.messages.updateError'),
|
|
313
|
+
color: 'error'
|
|
314
|
+
})
|
|
315
|
+
} finally {
|
|
316
|
+
mutationPending.value = false
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const handleDelete = async () => {
|
|
321
|
+
if (!selectedRequest.value?.id) {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
mutationPending.value = true
|
|
326
|
+
try {
|
|
327
|
+
await $fetch(`/api/service-requests/${selectedRequest.value.id}`, {
|
|
328
|
+
method: 'DELETE' as never
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
toast.add({
|
|
332
|
+
title: t('common.success'),
|
|
333
|
+
description: t('features.serviceRequests.messages.deleteSuccess')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
closeDrawer()
|
|
337
|
+
await refresh()
|
|
338
|
+
} catch (e) {
|
|
339
|
+
console.error(e)
|
|
340
|
+
toast.add({
|
|
341
|
+
title: t('common.error'),
|
|
342
|
+
description: t('features.serviceRequests.messages.deleteError'),
|
|
343
|
+
color: 'error'
|
|
344
|
+
})
|
|
345
|
+
} finally {
|
|
346
|
+
mutationPending.value = false
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const editInitialData = computed<Partial<ServiceRequest> | undefined>(() => {
|
|
351
|
+
if (drawerMode.value !== 'edit') {
|
|
352
|
+
return undefined
|
|
353
|
+
}
|
|
354
|
+
return selectedRequest.value ?? undefined
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
const updateRoute = () => router.replace({ path: '/requests', query: buildListQuery() })
|
|
358
|
+
|
|
359
|
+
const getPageTo = (page: number) => {
|
|
360
|
+
const query = buildListQuery(false)
|
|
361
|
+
if (page > 1) {
|
|
362
|
+
query.page = String(page)
|
|
363
|
+
}
|
|
364
|
+
return { path: '/requests', query }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Debounced search function
|
|
368
|
+
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
369
|
+
const handleSearch = () => {
|
|
370
|
+
if (searchTimeout) {
|
|
371
|
+
clearTimeout(searchTimeout)
|
|
372
|
+
}
|
|
373
|
+
searchTimeout = setTimeout(async () => {
|
|
374
|
+
currentPage.value = 1
|
|
375
|
+
await updateRoute()
|
|
376
|
+
await loadData()
|
|
377
|
+
}, 300) // 300ms debounce
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Watch filters and reset pagination when they change
|
|
381
|
+
watch([statusFilter, priorityFilter, categoryFilter], async () => {
|
|
382
|
+
currentPage.value = 1
|
|
383
|
+
await updateRoute()
|
|
384
|
+
await loadData()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
watch([sortBy, sortDir], async () => {
|
|
388
|
+
currentPage.value = 1
|
|
389
|
+
await updateRoute()
|
|
390
|
+
await loadData()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Watch search query with debouncing
|
|
394
|
+
watch(searchQuery, () => {
|
|
395
|
+
handleSearch()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
watch(
|
|
399
|
+
() => route.query.page,
|
|
400
|
+
async (page) => {
|
|
401
|
+
const nextPage = Math.max(1, Number(page) || 1)
|
|
402
|
+
if (nextPage === currentPage.value) {
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
currentPage.value = nextPage
|
|
406
|
+
await loadData()
|
|
407
|
+
listContainerRef.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
useAutoPagination({
|
|
412
|
+
sentinel: loadMoreSentinelRef,
|
|
413
|
+
scrollContainer: listContainerRef,
|
|
414
|
+
canLoadMore: hasNextPage,
|
|
415
|
+
loading: pending,
|
|
416
|
+
loadMore: loadNextPage
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
useAutoPagination({
|
|
420
|
+
sentinel: loadPreviousSentinelRef,
|
|
421
|
+
scrollContainer: listContainerRef,
|
|
422
|
+
canLoadMore: hasPreviousPage,
|
|
423
|
+
loading: pending,
|
|
424
|
+
loadMore: loadPreviousPage,
|
|
425
|
+
rootMargin: '240px 0px 0px 0px'
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
await loadData()
|
|
429
|
+
|
|
430
|
+
const formatDate = (date: string | Date) => {
|
|
431
|
+
return new Date(date).toLocaleDateString()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
onUnmounted(() => {
|
|
435
|
+
if (searchTimeout) {
|
|
436
|
+
clearTimeout(searchTimeout)
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
</script>
|
|
440
|
+
|
|
441
|
+
<template>
|
|
442
|
+
<UDashboardPanel
|
|
443
|
+
id="service-requests"
|
|
444
|
+
class="min-h-0 overflow-hidden"
|
|
445
|
+
style="height: calc(100dvh - var(--ui-header-height))"
|
|
446
|
+
:ui="{ body: 'flex flex-col gap-4 sm:gap-6 flex-1 min-h-0 p-4 sm:p-6 overflow-hidden' }"
|
|
447
|
+
>
|
|
448
|
+
<template #header>
|
|
449
|
+
<UDashboardNavbar :ui="{ right: 'gap-3' }" :toggle="false">
|
|
450
|
+
<template #leading>
|
|
451
|
+
<UIcon name="i-lucide-ticket" class="size-6 shrink-0" />
|
|
452
|
+
<span class="text-lg font-semibold text-gray-900 dark:text-white"> Service Requests </span>
|
|
453
|
+
</template>
|
|
454
|
+
|
|
455
|
+
<template #right>
|
|
456
|
+
<div class="flex gap-2 w-full sm:w-auto">
|
|
457
|
+
<UButton
|
|
458
|
+
icon="i-lucide-plus"
|
|
459
|
+
color="primary"
|
|
460
|
+
class="rounded-full sm:rounded-md"
|
|
461
|
+
:title="t('features.serviceRequests.create')"
|
|
462
|
+
:disabled="!canCreate"
|
|
463
|
+
@click="openCreateDrawer"
|
|
464
|
+
/>
|
|
465
|
+
<UButton
|
|
466
|
+
icon="i-lucide-refresh-cw"
|
|
467
|
+
variant="outline"
|
|
468
|
+
:loading="pending"
|
|
469
|
+
class="flex-1 sm:flex-none"
|
|
470
|
+
:title="t('common.refresh')"
|
|
471
|
+
@click="refresh"
|
|
472
|
+
/>
|
|
473
|
+
</div>
|
|
474
|
+
</template>
|
|
475
|
+
</UDashboardNavbar>
|
|
476
|
+
|
|
477
|
+
<div ref="toolbarElement" class="w-full min-w-0">
|
|
478
|
+
<UDashboardToolbar :ui="{ left: 'min-w-0 flex-1', right: 'ml-auto shrink-0' }">
|
|
479
|
+
<template #left>
|
|
480
|
+
<div class="flex items-center gap-2 w-full">
|
|
481
|
+
<UInput
|
|
482
|
+
v-model="searchQuery"
|
|
483
|
+
:placeholder="t('common.searchPlaceholder')"
|
|
484
|
+
icon="i-lucide-search"
|
|
485
|
+
:loading="pending"
|
|
486
|
+
class="min-w-0 flex-1 md:max-w-xs"
|
|
487
|
+
clearable
|
|
488
|
+
/>
|
|
489
|
+
<USelect
|
|
490
|
+
v-if="!isMobile"
|
|
491
|
+
v-model="statusFilter"
|
|
492
|
+
:items="statusOptions"
|
|
493
|
+
:placeholder="t('features.serviceRequests.filters.status')"
|
|
494
|
+
class="w-48"
|
|
495
|
+
>
|
|
496
|
+
<template #item="{ item }">
|
|
497
|
+
<div class="flex items-center justify-between w-full gap-2">
|
|
498
|
+
<span class="flex-1 truncate">{{ item.label }}</span>
|
|
499
|
+
<UBadge
|
|
500
|
+
v-if="item.badgeText"
|
|
501
|
+
:color="item.badgeColor as any"
|
|
502
|
+
size="xs"
|
|
503
|
+
class="shrink-0 min-w-[100px] justify-center"
|
|
504
|
+
>
|
|
505
|
+
{{ item.badgeText }}
|
|
506
|
+
</UBadge>
|
|
507
|
+
</div>
|
|
508
|
+
</template>
|
|
509
|
+
</USelect>
|
|
510
|
+
<USelect
|
|
511
|
+
v-if="!isMobile"
|
|
512
|
+
v-model="priorityFilter"
|
|
513
|
+
:items="priorityOptions"
|
|
514
|
+
:placeholder="t('features.serviceRequests.filters.priority')"
|
|
515
|
+
class="w-48"
|
|
516
|
+
>
|
|
517
|
+
<template #item="{ item }">
|
|
518
|
+
<div class="flex items-center justify-between w-full gap-2">
|
|
519
|
+
<span class="flex-1 truncate">{{ item.label }}</span>
|
|
520
|
+
<UBadge
|
|
521
|
+
v-if="item.badgeText"
|
|
522
|
+
:color="item.badgeColor as any"
|
|
523
|
+
size="xs"
|
|
524
|
+
class="shrink-0 min-w-[100px] justify-center"
|
|
525
|
+
>
|
|
526
|
+
{{ item.badgeText }}
|
|
527
|
+
</UBadge>
|
|
528
|
+
</div>
|
|
529
|
+
</template>
|
|
530
|
+
</USelect>
|
|
531
|
+
<USelect
|
|
532
|
+
v-if="!isMobile"
|
|
533
|
+
v-model="categoryFilter"
|
|
534
|
+
:items="categoryOptions"
|
|
535
|
+
:placeholder="t('features.serviceRequests.filters.category')"
|
|
536
|
+
class="w-48"
|
|
537
|
+
/>
|
|
538
|
+
</div>
|
|
539
|
+
</template>
|
|
540
|
+
<template #right>
|
|
541
|
+
<div class="flex items-center gap-2">
|
|
542
|
+
<UButton
|
|
543
|
+
v-if="isMobile"
|
|
544
|
+
:aria-label="t('common.filters')"
|
|
545
|
+
icon="i-lucide-filter"
|
|
546
|
+
color="neutral"
|
|
547
|
+
variant="outline"
|
|
548
|
+
@click="showFiltersModal = true"
|
|
549
|
+
/>
|
|
550
|
+
<UButton
|
|
551
|
+
v-if="isMobile"
|
|
552
|
+
icon="i-lucide-arrow-down-up"
|
|
553
|
+
color="neutral"
|
|
554
|
+
variant="outline"
|
|
555
|
+
:title="t('common.sort')"
|
|
556
|
+
@click="showSortModal = true"
|
|
557
|
+
/>
|
|
558
|
+
|
|
559
|
+
<UDropdownMenu
|
|
560
|
+
v-if="!isMobile"
|
|
561
|
+
:items="sortDropdownItems"
|
|
562
|
+
:content="{ align: 'end', collisionPadding: 12 }"
|
|
563
|
+
>
|
|
564
|
+
<UButton icon="i-lucide-arrow-down-up" color="neutral" variant="outline" class="w-48 justify-between">
|
|
565
|
+
<span class="truncate">{{ currentSortLabel }}</span>
|
|
566
|
+
<UIcon name="i-lucide-chevron-down" class="size-4 opacity-60" />
|
|
567
|
+
</UButton>
|
|
568
|
+
</UDropdownMenu>
|
|
569
|
+
<UButton
|
|
570
|
+
v-if="!isMobile"
|
|
571
|
+
:icon="sortDir === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow'"
|
|
572
|
+
color="neutral"
|
|
573
|
+
variant="outline"
|
|
574
|
+
:title="sortDir === 'asc' ? t('common.ascending') : t('common.descending')"
|
|
575
|
+
@click="toggleSortDir"
|
|
576
|
+
/>
|
|
577
|
+
</div>
|
|
578
|
+
</template>
|
|
579
|
+
</UDashboardToolbar>
|
|
580
|
+
</div>
|
|
581
|
+
|
|
582
|
+
<!-- Mobile Filters Modal -->
|
|
583
|
+
<UModal
|
|
584
|
+
v-model:open="showFiltersModal"
|
|
585
|
+
:title="t('features.serviceRequests.filters.title')"
|
|
586
|
+
:ui="{ content: 'w-full sm:max-w-md' }"
|
|
587
|
+
>
|
|
588
|
+
<template #body>
|
|
589
|
+
<div class="space-y-4">
|
|
590
|
+
<UFormField :label="t('features.serviceRequests.fields.status')">
|
|
591
|
+
<USelect
|
|
592
|
+
v-model="statusFilter"
|
|
593
|
+
class="w-full"
|
|
594
|
+
:items="statusOptions"
|
|
595
|
+
:placeholder="t('features.serviceRequests.filters.status')"
|
|
596
|
+
>
|
|
597
|
+
<template #item="{ item }">
|
|
598
|
+
<div class="flex items-center justify-between w-full gap-2">
|
|
599
|
+
<span class="flex-1 truncate">{{ item.label }}</span>
|
|
600
|
+
<UBadge
|
|
601
|
+
v-if="item.badgeText"
|
|
602
|
+
:color="item.badgeColor as any"
|
|
603
|
+
size="xs"
|
|
604
|
+
class="shrink-0 min-w-[100px] justify-center"
|
|
605
|
+
>
|
|
606
|
+
{{ item.badgeText }}
|
|
607
|
+
</UBadge>
|
|
608
|
+
</div>
|
|
609
|
+
</template>
|
|
610
|
+
</USelect>
|
|
611
|
+
</UFormField>
|
|
612
|
+
|
|
613
|
+
<UFormField :label="t('features.serviceRequests.fields.priority')">
|
|
614
|
+
<USelect
|
|
615
|
+
v-model="priorityFilter"
|
|
616
|
+
:items="priorityOptions"
|
|
617
|
+
:placeholder="t('features.serviceRequests.filters.priority')"
|
|
618
|
+
class="w-full"
|
|
619
|
+
>
|
|
620
|
+
<template #item="{ item }">
|
|
621
|
+
<div class="flex items-center justify-between w-full gap-2">
|
|
622
|
+
<span class="flex-1 truncate">{{ item.label }}</span>
|
|
623
|
+
<UBadge
|
|
624
|
+
v-if="item.badgeText"
|
|
625
|
+
:color="item.badgeColor as any"
|
|
626
|
+
size="xs"
|
|
627
|
+
class="shrink-0 min-w-[100px] justify-center"
|
|
628
|
+
>
|
|
629
|
+
{{ item.badgeText }}
|
|
630
|
+
</UBadge>
|
|
631
|
+
</div>
|
|
632
|
+
</template>
|
|
633
|
+
</USelect>
|
|
634
|
+
</UFormField>
|
|
635
|
+
|
|
636
|
+
<UFormField :label="t('features.serviceRequests.fields.category')">
|
|
637
|
+
<USelect
|
|
638
|
+
v-model="categoryFilter"
|
|
639
|
+
class="w-full"
|
|
640
|
+
:items="categoryOptions"
|
|
641
|
+
:placeholder="t('features.serviceRequests.filters.category')"
|
|
642
|
+
/>
|
|
643
|
+
</UFormField>
|
|
644
|
+
</div>
|
|
645
|
+
</template>
|
|
646
|
+
<template #footer>
|
|
647
|
+
<div class="flex justify-end gap-2">
|
|
648
|
+
<UButton color="neutral" variant="outline" @click="showFiltersModal = false"> Close </UButton>
|
|
649
|
+
</div>
|
|
650
|
+
</template>
|
|
651
|
+
</UModal>
|
|
652
|
+
|
|
653
|
+
<!-- Mobile Sort Modal -->
|
|
654
|
+
<UModal v-model:open="showSortModal" :title="t('common.sort')" :ui="{ content: 'w-full sm:max-w-md' }">
|
|
655
|
+
<template #body>
|
|
656
|
+
<div class="space-y-4">
|
|
657
|
+
<UFormField :label="t('common.sortBy')">
|
|
658
|
+
<USelect v-model="sortBy" class="w-full" :items="sortOptions" :placeholder="t('common.sortBy')" />
|
|
659
|
+
</UFormField>
|
|
660
|
+
|
|
661
|
+
<UFormField :label="t('common.direction')">
|
|
662
|
+
<div class="flex gap-2">
|
|
663
|
+
<UButton
|
|
664
|
+
class="flex-1"
|
|
665
|
+
:variant="sortDir === 'asc' ? 'solid' : 'outline'"
|
|
666
|
+
:icon="sortDir === 'asc' ? 'i-lucide-check' : undefined"
|
|
667
|
+
@click="sortDir = 'asc'"
|
|
668
|
+
>
|
|
669
|
+
{{ t('common.ascending') }}
|
|
670
|
+
</UButton>
|
|
671
|
+
<UButton
|
|
672
|
+
class="flex-1"
|
|
673
|
+
:variant="sortDir === 'desc' ? 'solid' : 'outline'"
|
|
674
|
+
:icon="sortDir === 'desc' ? 'i-lucide-check' : undefined"
|
|
675
|
+
@click="sortDir = 'desc'"
|
|
676
|
+
>
|
|
677
|
+
{{ t('common.descending') }}
|
|
678
|
+
</UButton>
|
|
679
|
+
</div>
|
|
680
|
+
</UFormField>
|
|
681
|
+
</div>
|
|
682
|
+
</template>
|
|
683
|
+
<template #footer>
|
|
684
|
+
<div class="flex justify-end gap-2">
|
|
685
|
+
<UButton color="neutral" variant="outline" @click="showSortModal = false">
|
|
686
|
+
{{ t('common.close') }}
|
|
687
|
+
</UButton>
|
|
688
|
+
</div>
|
|
689
|
+
</template>
|
|
690
|
+
</UModal>
|
|
691
|
+
</template>
|
|
692
|
+
<template #body>
|
|
693
|
+
<div ref="listContainerRef" class="-mx-1 flex-1 min-h-0 overflow-y-auto px-1 py-2">
|
|
694
|
+
<div>
|
|
695
|
+
<UAlert
|
|
696
|
+
v-if="loadError"
|
|
697
|
+
color="error"
|
|
698
|
+
icon="i-lucide-circle-alert"
|
|
699
|
+
:title="t('features.serviceRequests.messages.fetchError')"
|
|
700
|
+
class="mb-4"
|
|
701
|
+
variant="outline"
|
|
702
|
+
/>
|
|
703
|
+
|
|
704
|
+
<UEmpty
|
|
705
|
+
v-if="list.length === 0 && !pending"
|
|
706
|
+
icon="i-lucide-ticket"
|
|
707
|
+
:description="t('features.serviceRequests.messages.empty')"
|
|
708
|
+
class="ring-inset"
|
|
709
|
+
/>
|
|
710
|
+
|
|
711
|
+
<div v-if="hasPreviousPage" ref="loadPreviousSentinelRef" aria-hidden="true" class="h-px" />
|
|
712
|
+
|
|
713
|
+
<div v-if="loadingPreviousPage" class="pb-4 space-y-2">
|
|
714
|
+
<USkeleton v-for="i in 2" :key="i" class="h-20 w-full" />
|
|
715
|
+
</div>
|
|
716
|
+
|
|
717
|
+
<div class="space-y-4">
|
|
718
|
+
<UCard
|
|
719
|
+
v-for="request in list"
|
|
720
|
+
:key="request.id"
|
|
721
|
+
:ui="{ body: 'p-0 sm:p-0' }"
|
|
722
|
+
class="ring-inset transition-colors hover:ring-1 hover:ring-primary/50"
|
|
723
|
+
>
|
|
724
|
+
<NuxtLink
|
|
725
|
+
:to="getDetailTo(request)"
|
|
726
|
+
class="group grid min-h-24 grid-cols-[minmax(0,1fr)_auto] items-center gap-4 rounded-lg p-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
727
|
+
>
|
|
728
|
+
<div class="min-w-0">
|
|
729
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
730
|
+
<h3 class="truncate font-semibold text-highlighted">{{ request.title }}</h3>
|
|
731
|
+
<UBadge :color="getStatusColor(request.status)" variant="subtle">
|
|
732
|
+
{{ getStatusBadgeText(request.status) }}
|
|
733
|
+
</UBadge>
|
|
734
|
+
<UBadge :color="getPriorityColor(request.priority)" variant="subtle">
|
|
735
|
+
{{ getPriorityBadgeText(request.priority) }}
|
|
736
|
+
</UBadge>
|
|
737
|
+
</div>
|
|
738
|
+
<p class="mt-1 line-clamp-2 text-sm text-muted">{{ request.description }}</p>
|
|
739
|
+
<div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
|
|
740
|
+
<span>{{ formatDate(request.createdAt) }}</span>
|
|
741
|
+
<span v-if="request.category" aria-hidden="true">·</span>
|
|
742
|
+
<span v-if="request.category">{{ request.category }}</span>
|
|
743
|
+
</div>
|
|
744
|
+
</div>
|
|
745
|
+
<span
|
|
746
|
+
aria-hidden="true"
|
|
747
|
+
class="grid size-11 place-items-center rounded-md text-muted group-hover:text-highlighted"
|
|
748
|
+
>
|
|
749
|
+
<UIcon name="i-lucide-chevron-right" class="size-5" />
|
|
750
|
+
</span>
|
|
751
|
+
</NuxtLink>
|
|
752
|
+
</UCard>
|
|
753
|
+
</div>
|
|
754
|
+
|
|
755
|
+
<div v-if="pending && !loadingNextPage" class="py-4 space-y-2">
|
|
756
|
+
<USkeleton v-for="i in 2" :key="i" class="h-20 w-full" />
|
|
757
|
+
</div>
|
|
758
|
+
|
|
759
|
+
<div v-if="hasNextPage" ref="loadMoreSentinelRef" aria-hidden="true" class="h-px" />
|
|
760
|
+
|
|
761
|
+
<div v-if="loadingNextPage" class="py-4 space-y-2">
|
|
762
|
+
<USkeleton v-for="i in 2" :key="i" class="h-20 w-full" />
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
<div
|
|
766
|
+
v-if="pagination.total > 0 && !hasNextPage && !pending"
|
|
767
|
+
class="end-of-list-bounce flex items-center justify-center gap-2 py-6 text-sm text-muted"
|
|
768
|
+
role="status"
|
|
769
|
+
>
|
|
770
|
+
<UIcon name="i-lucide-circle-check" class="size-5 text-success" />
|
|
771
|
+
<span>{{ t('features.serviceRequests.messages.endOfList') }}</span>
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
</div>
|
|
775
|
+
|
|
776
|
+
<div
|
|
777
|
+
class="shrink-0 border-t border-default px-4 py-2 text-sm text-muted flex items-center justify-between gap-4"
|
|
778
|
+
>
|
|
779
|
+
<span>
|
|
780
|
+
{{ t('features.serviceRequests.resultCount', { count: pagination.total }) }}
|
|
781
|
+
</span>
|
|
782
|
+
<UPagination
|
|
783
|
+
v-if="pagination.pageCount > 1"
|
|
784
|
+
:page="currentPage"
|
|
785
|
+
:total="pagination.total"
|
|
786
|
+
:items-per-page="pagination.pageSize"
|
|
787
|
+
:disabled="pending"
|
|
788
|
+
:to="getPageTo"
|
|
789
|
+
/>
|
|
790
|
+
</div>
|
|
791
|
+
|
|
792
|
+
<!-- Drawer (create / view / edit) -->
|
|
793
|
+
<USlideover
|
|
794
|
+
v-if="drawerOpen"
|
|
795
|
+
v-model:open="drawerOpen"
|
|
796
|
+
:title="drawerTitle"
|
|
797
|
+
:ui="{ content: 'w-full sm:max-w-lg' }"
|
|
798
|
+
>
|
|
799
|
+
<template #body>
|
|
800
|
+
<div class="space-y-4">
|
|
801
|
+
<template v-if="drawerMode === 'view' && selectedRequest">
|
|
802
|
+
<CustomerRequestDetail
|
|
803
|
+
:request-id="selectedRequest.id"
|
|
804
|
+
:can-edit="canEdit"
|
|
805
|
+
:can-delete="canDelete"
|
|
806
|
+
@edit="drawerMode = 'edit'"
|
|
807
|
+
@delete="showDeleteConfirm = true"
|
|
808
|
+
/>
|
|
809
|
+
</template>
|
|
810
|
+
|
|
811
|
+
<template v-else>
|
|
812
|
+
<CustomerRequestForm
|
|
813
|
+
:initial-data="editInitialData"
|
|
814
|
+
:loading="mutationPending"
|
|
815
|
+
@submit="drawerMode === 'create' ? handleCreate($event) : handleUpdate($event)"
|
|
816
|
+
@cancel="closeDrawer"
|
|
817
|
+
/>
|
|
818
|
+
|
|
819
|
+
<div v-if="drawerMode === 'edit' && canDelete" class="pt-2">
|
|
820
|
+
<UButton
|
|
821
|
+
color="error"
|
|
822
|
+
variant="outline"
|
|
823
|
+
icon="i-lucide-trash-2"
|
|
824
|
+
:loading="mutationPending"
|
|
825
|
+
@click="showDeleteConfirm = true"
|
|
826
|
+
>
|
|
827
|
+
{{ t('common.delete') }}
|
|
828
|
+
</UButton>
|
|
829
|
+
</div>
|
|
830
|
+
</template>
|
|
831
|
+
</div>
|
|
832
|
+
|
|
833
|
+
<ConfirmationModal
|
|
834
|
+
v-model:open="showDeleteConfirm"
|
|
835
|
+
title="features.serviceRequests.delete"
|
|
836
|
+
message="features.serviceRequests.confirmDelete"
|
|
837
|
+
confirm-color="error"
|
|
838
|
+
confirm-text="common.delete"
|
|
839
|
+
@confirm="handleDelete"
|
|
840
|
+
/>
|
|
841
|
+
</template>
|
|
842
|
+
</USlideover>
|
|
843
|
+
</template>
|
|
844
|
+
</UDashboardPanel>
|
|
845
|
+
</template>
|
|
846
|
+
|
|
847
|
+
<style scoped>
|
|
848
|
+
@keyframes end-of-list-bounce {
|
|
849
|
+
0%,
|
|
850
|
+
100% {
|
|
851
|
+
transform: translateY(0);
|
|
852
|
+
}
|
|
853
|
+
30% {
|
|
854
|
+
transform: translateY(-10px);
|
|
855
|
+
}
|
|
856
|
+
55% {
|
|
857
|
+
transform: translateY(0);
|
|
858
|
+
}
|
|
859
|
+
75% {
|
|
860
|
+
transform: translateY(-4px);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
.end-of-list-bounce {
|
|
865
|
+
animation: end-of-list-bounce 650ms ease-out both;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
@media (prefers-reduced-motion: reduce) {
|
|
869
|
+
.end-of-list-bounce {
|
|
870
|
+
animation: none;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
</style>
|