@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/app/components/Admin/RequestDashboard.vue +64 -0
  5. package/app/components/Admin/RequestTable.vue +158 -0
  6. package/app/components/CustomerRequestDetail.vue +76 -0
  7. package/app/components/CustomerRequestForm.vue +143 -0
  8. package/app/components/ServiceRequestsDashboardAttention.vue +55 -0
  9. package/app/components/ServiceRequestsDashboardOverview.vue +55 -0
  10. package/app/components/StatusBadge.vue +16 -0
  11. package/app/composables/useAdminServiceRequests.ts +60 -0
  12. package/app/composables/usePaginatedServiceRequests.ts +21 -0
  13. package/app/composables/useServiceRequestAccess.ts +18 -0
  14. package/app/composables/useServiceRequestMenu.ts +34 -0
  15. package/app/composables/useServiceRequests.ts +134 -0
  16. package/app/composables/useServiceRequestsDashboard.ts +13 -0
  17. package/app/pages/admin/requests/[id].vue +140 -0
  18. package/app/pages/admin/requests/index.vue +55 -0
  19. package/app/pages/requests/[id].vue +161 -0
  20. package/app/pages/requests/index.vue +873 -0
  21. package/app/pages/requests/new.vue +37 -0
  22. package/app/plugins/service-requests-feature.ts +18 -0
  23. package/i18n/locales/en.json +97 -0
  24. package/i18n/locales/nl.json +97 -0
  25. package/migrations/0000_baseline.sql +30 -0
  26. package/migrations/0001_required_client.sql +6 -0
  27. package/migrations/meta/0000_snapshot.json +245 -0
  28. package/migrations/meta/_journal.json +13 -0
  29. package/nuxt.config.ts +29 -0
  30. package/package.json +53 -0
  31. package/portal.manifest.mjs +9 -0
  32. package/server/api/service-requests/[id].delete.ts +42 -0
  33. package/server/api/service-requests/[id].get.ts +39 -0
  34. package/server/api/service-requests/[id].patch.ts +44 -0
  35. package/server/api/service-requests/admin/[id].patch.ts +43 -0
  36. package/server/api/service-requests/admin/index.get.ts +19 -0
  37. package/server/api/service-requests/dashboard.get.ts +11 -0
  38. package/server/api/service-requests/index.get.ts +22 -0
  39. package/server/api/service-requests/index.post.ts +39 -0
  40. package/server/db/schema/service-requests.ts +78 -0
  41. package/server/plugins/openapi-contracts.ts +22 -0
  42. package/server/utils/service-request-repository.ts +185 -0
  43. package/server/utils/service-request-scope.ts +41 -0
  44. package/server/utils/service-request-validation.ts +54 -0
  45. package/shared/feature.ts +90 -0
  46. 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>