@nuxt-customer-portal/service-requests 0.3.0 → 0.3.2

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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # @nuxt-customer-portal/service-requests
2
2
 
3
+ ## 0.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 903e0ce: Show service-request dashboard widgets after time and invoice widgets so daily work and billing take priority.
8
+ - 6994c36: Align customer and admin request lists with the portal collection toolbar, card layout, and fixed pagination footer. Preserve filters and sorting through detail navigation, support adjacent-page loading, and show scoped category choices independently of the current page.
9
+
10
+ Repair request editing and deletion confirmation, add localized form validation, and provide working admin status, priority, provider-team assignment, and internal-note controls.
11
+
12
+ Provide one request workspace with permission-aware management controls. Redirect legacy admin links while preserving collection state, and keep creation in the list header rather than the sidebar. Allow the dashboard body to shrink beneath the top bar so mobile pagination remains visible.
13
+
14
+ - 1b55b42: Place Service Requests after Invoices and before Administration in portal navigation.
15
+ - Updated dependencies [f23f664]
16
+ - @nuxt-customer-portal/core@0.3.2
17
+ - @nuxt-customer-portal/clients@0.3.2
18
+
19
+ ## 0.3.1
20
+
21
+ ### Patch Changes
22
+
23
+ - @nuxt-customer-portal/clients@0.3.1
24
+ - @nuxt-customer-portal/core@0.3.1
25
+
3
26
  ## 0.3.0
4
27
 
5
28
  ### Patch Changes
package/README.md CHANGED
@@ -14,3 +14,11 @@ The schema is exported from `server/db/schema/service-requests.ts`. Its tables a
14
14
  enums live in the `service_requests` PostgreSQL schema; portal core and authentication
15
15
  remain in `public`. The host Drizzle configuration discovers feature schemas and owns
16
16
  the single ordered migration history.
17
+
18
+ ## Request workspace
19
+
20
+ Everyone uses the same `/requests` workspace, with a responsive card list and the portal search/filter/sort toolbar. Lists use 20-item server pages, numbered navigation, adjacent-page loading, and URL-persisted collection state. Detail-page back links retain that state. Category choices come from the authorized collection, rather than only the currently loaded page. Old `/admin/requests` links redirect to the equivalent workspace route, preserving query parameters.
21
+
22
+ Administrators see management controls on the request detail page and can save status, priority, assignment, and internal notes together. New assignees must belong to the provider organization. Customer edits use the shared request form; deletion requires confirmation.
23
+
24
+ Run `node --import tsx --test packages/service-requests/test/service-request.test.ts` for validation and locale checks. Against a freshly seeded local demo, run `DEMO_TEST_URL=http://localhost:3051 node --import tsx packages/service-requests/test/demo-browser.integration.ts` for browser checks. The browser check creates and cleans up one temporary request and expects the standard 36-request demo dataset.
@@ -1,76 +1,64 @@
1
1
  <script setup lang="ts">
2
2
  import type { ServiceRequest } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
3
3
 
4
- const props = defineProps<{
5
- requestId: string
6
- canEdit?: boolean
7
- canDelete?: boolean
8
- }>()
9
- const { t } = useI18n()
10
- const { getPriorityColor } = useServiceRequests()
11
- defineEmits<{
12
- edit: []
13
- delete: []
14
- }>()
15
- const { getRequest } = useServiceRequests()
16
- const request = ref<ServiceRequest | null>(await getRequest(props.requestId))
17
-
18
- const formatDate = (date: string | Date) => {
19
- return new Date(date).toLocaleDateString()
20
- }
4
+ defineProps<{ request: ServiceRequest; canEdit?: boolean; canDelete?: boolean }>()
5
+ defineEmits<{ edit: []; delete: [] }>()
6
+ const { t, locale } = useI18n()
7
+ const { getPriorityColor, getPriorityBadgeText, getStatusColor, getStatusBadgeText } = useServiceRequests()
8
+ const formatDate = (date: string) =>
9
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(date))
21
10
  </script>
22
11
 
23
12
  <template>
24
- <div v-if="request" class="space-y-6">
25
- <div class="flex justify-between items-start">
26
- <div>
27
- <div class="flex gap-2 mt-2">
28
- <UBadge :color="getPriorityColor(request.priority)">
29
- {{ request.priority }}
30
- </UBadge>
31
- <StatusBadge :status="request.status" />
32
- <UBadge v-if="request.category" variant="soft">
33
- {{ request.category }}
34
- </UBadge>
13
+ <UCard>
14
+ <template #header>
15
+ <div class="flex flex-wrap items-start justify-between gap-3">
16
+ <div class="min-w-0 flex-1">
17
+ <h1 class="break-words text-xl font-semibold text-highlighted">{{ request.title }}</h1>
18
+ <div class="mt-3 flex flex-wrap gap-2">
19
+ <UBadge :color="getStatusColor(request.status)" variant="subtle">{{
20
+ getStatusBadgeText(request.status)
21
+ }}</UBadge>
22
+ <UBadge :color="getPriorityColor(request.priority)" variant="subtle">{{
23
+ getPriorityBadgeText(request.priority)
24
+ }}</UBadge>
25
+ <UBadge v-if="request.category" color="neutral" variant="subtle">{{ request.category }}</UBadge>
26
+ </div>
27
+ </div>
28
+ <div v-if="canEdit || canDelete" class="flex gap-2">
29
+ <UButton v-if="canEdit" icon="i-lucide-pencil" color="neutral" variant="outline" @click="$emit('edit')">{{
30
+ t('common.edit')
31
+ }}</UButton>
32
+ <UButton
33
+ v-if="canDelete"
34
+ icon="i-lucide-trash-2"
35
+ color="error"
36
+ variant="ghost"
37
+ :aria-label="t('features.serviceRequests.delete')"
38
+ @click="$emit('delete')"
39
+ />
35
40
  </div>
36
41
  </div>
37
-
38
- <div class="flex gap-2">
39
- <UButton v-if="canEdit" variant="ghost" @click="$emit('edit')">
40
- {{ t('features.serviceRequests.edit') }}
41
- </UButton>
42
- <UButton v-if="canDelete" variant="ghost" color="error" @click="$emit('delete')">
43
- {{ t('features.serviceRequests.delete') }}
44
- </UButton>
45
- </div>
46
- </div>
47
-
48
- <USeparator />
49
-
50
- <div class="prose dark:prose-invert max-w-none">
51
- <h3>{{ t('features.serviceRequests.fields.description') }}</h3>
52
- <p>{{ request.description }}</p>
53
- </div>
54
-
55
- <USeparator />
56
-
57
- <div class="grid grid-cols-2 gap-4 text-sm">
58
- <!-- <div>
59
- <span class="font-semibold">{{ t('features.serviceRequests.fields.createdBy') }}:</span>
60
- {{ request.createdBy?.name || request.createdBy?.email }}
61
- </div> -->
42
+ </template>
43
+ <div class="space-y-6">
62
44
  <div>
63
- <span class="font-semibold">{{ t('features.serviceRequests.fields.createdAt') }}:</span>
64
- {{ formatDate(request.createdAt) }}
65
- </div>
66
- <!-- <div v-if="request.assignedTo">
67
- <span class="font-semibold">{{ t('features.serviceRequests.fields.assignedTo') }}:</span>
68
- {{ request.assignedTo.name || request.assignedTo.email }}
69
- </div> -->
70
- <div v-if="request.resolvedAt">
71
- <span class="font-semibold">{{ t('features.serviceRequests.fields.resolvedAt') }}:</span>
72
- {{ formatDate(request.resolvedAt) }}
45
+ <h2 class="text-sm font-semibold text-highlighted">{{ t('features.serviceRequests.fields.description') }}</h2>
46
+ <p class="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-muted">{{ request.description }}</p>
73
47
  </div>
48
+ <dl class="grid grid-cols-1 gap-4 border-t border-default pt-4 text-sm sm:grid-cols-3">
49
+ <div v-if="request.clientName">
50
+ <dt class="text-muted">{{ t('features.serviceRequests.fields.client') }}</dt>
51
+ <dd class="mt-1 font-medium">{{ request.clientName }}</dd>
52
+ </div>
53
+ <div>
54
+ <dt class="text-muted">{{ t('features.serviceRequests.fields.createdAt') }}</dt>
55
+ <dd class="mt-1 font-medium">{{ formatDate(request.createdAt) }}</dd>
56
+ </div>
57
+ <div v-if="request.resolvedAt">
58
+ <dt class="text-muted">{{ t('features.serviceRequests.fields.resolvedAt') }}</dt>
59
+ <dd class="mt-1 font-medium">{{ formatDate(request.resolvedAt) }}</dd>
60
+ </div>
61
+ </dl>
74
62
  </div>
75
- </div>
63
+ </UCard>
76
64
  </template>
@@ -39,13 +39,26 @@ watch(
39
39
  }
40
40
  )
41
41
 
42
- const schema = z.object({
43
- clientOrganizationId: activeOrganizationType.value === 'PROVIDER' ? z.string().min(1) : z.string().optional(),
44
- title: z.string().min(3).max(200),
45
- description: z.string().min(10).max(5000),
46
- priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']),
47
- category: z.string().max(100).optional()
48
- })
42
+ const schema = computed(() =>
43
+ z.object({
44
+ clientOrganizationId:
45
+ activeOrganizationType.value === 'PROVIDER'
46
+ ? z.string().min(1, t('features.serviceRequests.validation.client'))
47
+ : z.string().optional(),
48
+ title: z
49
+ .string()
50
+ .trim()
51
+ .min(3, t('features.serviceRequests.validation.title'))
52
+ .max(200, t('features.serviceRequests.validation.title')),
53
+ description: z
54
+ .string()
55
+ .trim()
56
+ .min(10, t('features.serviceRequests.validation.description'))
57
+ .max(5000, t('features.serviceRequests.validation.description')),
58
+ priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']),
59
+ category: z.string().trim().max(100, t('features.serviceRequests.validation.category')).optional()
60
+ })
61
+ )
49
62
 
50
63
  const { priorityOptions, getStatusBadgeText, getStatusColor } = useServiceRequests()
51
64
 
@@ -55,7 +68,7 @@ const handleSubmit = () => {
55
68
  </script>
56
69
 
57
70
  <template>
58
- <UForm :state="state" :schema="schema" class="w-full" @submit="handleSubmit">
71
+ <UForm :state="state" :schema="schema" novalidate class="w-full" @submit="handleSubmit">
59
72
  <!-- Wrap in real DOM nodes so spacing is guaranteed -->
60
73
  <div class="space-y-6">
61
74
  <ClientsClientPicker
@@ -130,7 +143,14 @@ const handleSubmit = () => {
130
143
 
131
144
  <div class="pt-4 border-t border-default">
132
145
  <div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-3">
133
- <UButton type="button" variant="outline" size="lg" :disabled="loading" @click="$emit('cancel')">
146
+ <UButton
147
+ type="button"
148
+ color="neutral"
149
+ variant="outline"
150
+ size="lg"
151
+ :disabled="loading"
152
+ @click="$emit('cancel')"
153
+ >
134
154
  {{ t('common.cancel') }}
135
155
  </UButton>
136
156
  <UButton type="submit" color="primary" size="lg" :loading="loading">
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { ServiceRequest } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
4
+
5
+ const props = defineProps<{ request: ServiceRequest }>()
6
+ const emit = defineEmits<{ updated: [request: ServiceRequest] }>()
7
+ const request = toRef(props, 'request')
8
+ const { t } = useI18n()
9
+ const { statusOptions, priorityOptions } = useServiceRequests()
10
+ const { adminUpdateRequest, getAssignees } = useAdminServiceRequests()
11
+ const toast = useToast()
12
+ const loading = ref(true)
13
+ const updating = ref(false)
14
+ const assignees = ref<Array<{ id: string; name: string; image: string | null }>>([])
15
+ const state = reactive({
16
+ status: 'OPEN' as ServiceRequest['status'],
17
+ priority: 'MEDIUM' as ServiceRequest['priority'],
18
+ assignedToId: '__unassigned',
19
+ internalNotes: ''
20
+ })
21
+ const schema = computed(() =>
22
+ z.object({
23
+ status: z.enum(['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']),
24
+ priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']),
25
+ assignedToId: z.string(),
26
+ internalNotes: z.string().max(5000, t('features.serviceRequests.validation.notes'))
27
+ })
28
+ )
29
+ const userOptions = computed(() => [
30
+ { label: t('features.serviceRequests.list.unassigned'), value: '__unassigned' },
31
+ ...assignees.value.map((user) => ({
32
+ label: user.name,
33
+ value: user.id,
34
+ avatar: { src: user.image || undefined, alt: user.name }
35
+ })),
36
+ ...(request.value?.assignedToId && !assignees.value.some((user) => user.id === request.value?.assignedToId)
37
+ ? [{ label: t('features.serviceRequests.list.previousAssignee'), value: request.value.assignedToId }]
38
+ : [])
39
+ ])
40
+ const reset = () => {
41
+ if (request.value) {
42
+ Object.assign(state, {
43
+ status: request.value.status,
44
+ priority: request.value.priority,
45
+ assignedToId: request.value.assignedToId || '__unassigned',
46
+ internalNotes: request.value.internalNotes || ''
47
+ })
48
+ }
49
+ }
50
+ watch(request, reset, { immediate: true })
51
+ onMounted(async () => {
52
+ try {
53
+ assignees.value = await getAssignees()
54
+ reset()
55
+ } catch {
56
+ toast.add({ title: t('features.serviceRequests.messages.fetchError'), color: 'error' })
57
+ } finally {
58
+ loading.value = false
59
+ }
60
+ })
61
+ const save = async () => {
62
+ updating.value = true
63
+ try {
64
+ const updated = await adminUpdateRequest(request.value.id, {
65
+ ...state,
66
+ assignedToId: state.assignedToId === '__unassigned' ? '' : state.assignedToId
67
+ })
68
+ emit('updated', updated)
69
+ await nextTick()
70
+ reset()
71
+ toast.add({ title: t('features.serviceRequests.messages.updateSuccess'), color: 'success' })
72
+ } catch {
73
+ toast.add({ title: t('features.serviceRequests.messages.updateError'), color: 'error' })
74
+ } finally {
75
+ updating.value = false
76
+ }
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <UCard>
82
+ <template #header
83
+ ><h2 class="font-semibold">{{ t('features.serviceRequests.actions.adminActions') }}</h2></template
84
+ >
85
+ <UForm :disabled="loading || updating" :schema="schema" :state="state" novalidate class="space-y-4" @submit="save">
86
+ <div class="grid gap-4 sm:grid-cols-2">
87
+ <UFormField name="status" :label="t('features.serviceRequests.fields.status')"
88
+ ><USelect v-model="state.status" :items="statusOptions.filter((item) => item.value)" class="w-full"
89
+ /></UFormField>
90
+ <UFormField name="priority" :label="t('features.serviceRequests.fields.priority')"
91
+ ><USelect v-model="state.priority" :items="priorityOptions.filter((item) => item.value)" class="w-full"
92
+ /></UFormField>
93
+ </div>
94
+ <UFormField name="assignedToId" :label="t('features.serviceRequests.fields.assignedTo')"
95
+ ><USelect v-model="state.assignedToId" :items="userOptions" class="w-full"
96
+ /></UFormField>
97
+ <UFormField
98
+ name="internalNotes"
99
+ :label="t('features.serviceRequests.fields.internalNotes')"
100
+ :description="t('features.serviceRequests.placeholders.internalNotes')"
101
+ ><UTextarea v-model="state.internalNotes" :rows="4" class="w-full"
102
+ /></UFormField>
103
+ <div class="flex justify-end gap-2 border-t border-default pt-4">
104
+ <UButton color="neutral" variant="outline" :disabled="updating" @click="reset">{{ t('common.cancel') }}</UButton
105
+ ><UButton type="submit" :loading="updating">{{ t('features.serviceRequests.actions.saveChanges') }}</UButton>
106
+ </div>
107
+ </UForm>
108
+ </UCard>
109
+ </template>
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import type { ServiceRequest } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
3
+
4
+ const route = useRoute()
5
+ const { t, locale } = useI18n()
6
+ const { can } = useServiceRequestAccess()
7
+ const isAdmin = computed(() => can('manage'))
8
+ const { statusOptions, priorityOptions, getStatusColor, getStatusBadgeText, getPriorityColor, getPriorityBadgeText } =
9
+ useServiceRequests()
10
+ const listing = useServiceRequestList({
11
+ endpoint: '/api/service-requests',
12
+ filterKeys: ['status', 'priority', 'category'],
13
+ defaultSort: 'createdAt',
14
+ defaultSortDir: 'desc'
15
+ })
16
+ const title = computed(() => t('features.serviceRequests.title'))
17
+ useSeoMeta({ title: () => title.value })
18
+ const filters = computed(() => [
19
+ { key: 'status', placeholder: t('features.serviceRequests.filters.status'), items: statusOptions.value },
20
+ { key: 'priority', placeholder: t('features.serviceRequests.filters.priority'), items: priorityOptions.value },
21
+ {
22
+ key: 'category',
23
+ placeholder: t('features.serviceRequests.filters.category'),
24
+ items: [
25
+ { label: t('features.serviceRequests.filters.allCategories'), value: undefined },
26
+ ...Array.from(
27
+ new Set([...listing.categories.value, ...(listing.filters.category ? [listing.filters.category] : [])])
28
+ ).map((value) => ({ label: value, value }))
29
+ ]
30
+ }
31
+ ])
32
+ const sortOptions = computed(() =>
33
+ ['createdAt', 'status', 'priority'].map((value) => ({ label: t(`features.serviceRequests.fields.${value}`), value }))
34
+ )
35
+ const filtered = computed(() => Boolean(listing.search.value.trim() || Object.values(listing.filters).some(Boolean)))
36
+ const detailTo = (request: ServiceRequest) => ({
37
+ path: `/requests/${request.id}`,
38
+ query: { ...route.query, from: 'list' }
39
+ })
40
+ const formatDate = (date: string) =>
41
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(date))
42
+ await listing.load()
43
+ </script>
44
+
45
+ <template>
46
+ <section class="mx-auto flex h-full min-h-0 w-full max-w-[1440px] flex-col gap-5 px-4 py-5 sm:px-6">
47
+ <header class="flex shrink-0 flex-wrap items-end justify-between gap-3 border-b border-default pb-4">
48
+ <div class="min-w-0">
49
+ <h1 class="flex items-center gap-3 text-xl font-semibold text-highlighted">
50
+ <UIcon name="i-lucide-ticket" class="size-5 shrink-0 text-primary" />{{ title }}
51
+ </h1>
52
+ <p class="mt-1 text-sm text-muted">
53
+ {{
54
+ t(isAdmin ? 'features.serviceRequests.list.adminDescription' : 'features.serviceRequests.list.description')
55
+ }}
56
+ </p>
57
+ </div>
58
+ <div class="flex items-center gap-2">
59
+ <UButton v-if="can('create')" to="/requests/new" icon="i-lucide-plus" variant="outline" size="sm">{{
60
+ t('features.serviceRequests.navigation.newRequest')
61
+ }}</UButton>
62
+ <UButton
63
+ color="neutral"
64
+ variant="ghost"
65
+ icon="i-lucide-refresh-cw"
66
+ :aria-label="t('common.refresh')"
67
+ :loading="listing.pending.value"
68
+ @click="listing.refresh()"
69
+ />
70
+ </div>
71
+ </header>
72
+ <PortalListToolbar
73
+ v-model:search="listing.search.value"
74
+ :search-placeholder="t('features.serviceRequests.list.search')"
75
+ :filters="filters"
76
+ :filter-values="listing.filters"
77
+ :sort-options="sortOptions"
78
+ :sort-by="listing.sortBy.value"
79
+ :sort-dir="listing.sortDir.value"
80
+ @filter="listing.setFilter"
81
+ @sort="listing.sortBy.value = $event"
82
+ @toggle-direction="listing.toggleSortDir"
83
+ />
84
+ <ServiceRequestsPaginatedList
85
+ class="min-h-0 flex-1"
86
+ :pagination="listing.pagination.value"
87
+ :pending="listing.pending.value"
88
+ :loading-next="listing.loadingNextPage.value"
89
+ :loading-previous="listing.loadingPreviousPage.value"
90
+ :has-next="listing.hasNextPage.value"
91
+ :has-previous="listing.hasPreviousPage.value"
92
+ :error="Boolean(listing.error.value)"
93
+ @next="listing.loadNext"
94
+ @previous="listing.loadPrevious"
95
+ @page="listing.goToPage"
96
+ >
97
+ <UAlert
98
+ v-if="listing.error.value"
99
+ color="error"
100
+ variant="outline"
101
+ icon="i-lucide-circle-alert"
102
+ :title="t('features.serviceRequests.messages.fetchError')"
103
+ class="mb-3"
104
+ >
105
+ <template #actions
106
+ ><UButton color="error" variant="outline" @click="listing.refresh()">{{
107
+ t('features.serviceRequests.dashboard.retry')
108
+ }}</UButton></template
109
+ >
110
+ </UAlert>
111
+ <UEmpty
112
+ v-else-if="!listing.items.value.length"
113
+ icon="i-lucide-ticket"
114
+ :title="t(filtered ? 'features.serviceRequests.list.noMatches' : 'features.serviceRequests.list.emptyTitle')"
115
+ :description="
116
+ t(
117
+ filtered
118
+ ? 'features.serviceRequests.list.noMatchesDescription'
119
+ : 'features.serviceRequests.list.emptyDescription'
120
+ )
121
+ "
122
+ >
123
+ <template v-if="!filtered && can('create')" #actions
124
+ ><UButton to="/requests/new" icon="i-lucide-plus">{{
125
+ t('features.serviceRequests.list.createFirst')
126
+ }}</UButton></template
127
+ >
128
+ </UEmpty>
129
+ <div class="grid gap-3">
130
+ <UCard
131
+ v-for="request in listing.items.value"
132
+ :key="request.id"
133
+ class="transition-colors hover:ring-1 hover:ring-primary/50"
134
+ :ui="{ body: 'p-0 sm:p-0' }"
135
+ >
136
+ <NuxtLink
137
+ :to="detailTo(request)"
138
+ class="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg p-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
139
+ >
140
+ <div class="min-w-0">
141
+ <div class="flex flex-wrap items-center gap-2">
142
+ <h2 class="break-words font-semibold text-highlighted">{{ request.title }}</h2>
143
+ <UBadge :color="getStatusColor(request.status)" variant="subtle">{{
144
+ getStatusBadgeText(request.status)
145
+ }}</UBadge>
146
+ <UBadge :color="getPriorityColor(request.priority)" variant="subtle">{{
147
+ getPriorityBadgeText(request.priority)
148
+ }}</UBadge>
149
+ </div>
150
+ <p class="mt-1 line-clamp-2 text-sm text-muted">{{ request.description }}</p>
151
+ <div class="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
152
+ <template v-if="request.clientName"
153
+ ><span>{{ request.clientName }}</span
154
+ ><span aria-hidden="true">·</span></template
155
+ >
156
+ <span>{{ formatDate(request.createdAt) }}</span>
157
+ <template v-if="request.category"
158
+ ><span aria-hidden="true">·</span><span>{{ request.category }}</span></template
159
+ >
160
+ </div>
161
+ </div>
162
+ <UIcon name="i-lucide-chevron-right" class="size-5 shrink-0 text-muted group-hover:text-highlighted" />
163
+ </NuxtLink>
164
+ </UCard>
165
+ </div>
166
+ </ServiceRequestsPaginatedList>
167
+ </section>
168
+ </template>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ import type { ServiceRequestPagination } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
3
+
4
+ const props = defineProps<{
5
+ pagination: ServiceRequestPagination
6
+ pending: boolean
7
+ loadingNext: boolean
8
+ loadingPrevious: boolean
9
+ hasNext: boolean
10
+ hasPrevious: boolean
11
+ initialScrollTop?: number
12
+ error?: boolean
13
+ }>()
14
+ const emit = defineEmits<{ next: []; previous: []; page: [page: number]; scroll: [scrollTop: number] }>()
15
+ const scrollContainer = ref<HTMLElement | null>(null)
16
+ const nextSentinel = ref<HTMLElement | null>(null)
17
+ let previousScrollHeight = 0
18
+ let lastScrollTop = 0
19
+ const goToPage = (page: number) => {
20
+ lastScrollTop = 0
21
+ scrollContainer.value?.scrollTo({ top: 0 })
22
+ emit('page', page)
23
+ }
24
+ const loadPrevious = () => {
25
+ previousScrollHeight = scrollContainer.value?.scrollHeight ?? 0
26
+ emit('previous')
27
+ }
28
+ watch(
29
+ () => props.loadingPrevious,
30
+ async (loading, wasLoading) => {
31
+ if (loading || !wasLoading || !scrollContainer.value) {
32
+ return
33
+ }
34
+ await nextTick()
35
+ scrollContainer.value.scrollTop += scrollContainer.value.scrollHeight - previousScrollHeight
36
+ }
37
+ )
38
+ // Only prepend after an upward scroll. A numbered jump to page 2 must stay on page 2.
39
+ const onScroll = () => {
40
+ const top = scrollContainer.value?.scrollTop ?? 0
41
+ if (top < lastScrollTop && top < 240 && props.hasPrevious && !props.pending && !props.error) {
42
+ loadPrevious()
43
+ }
44
+ lastScrollTop = top
45
+ emit('scroll', top)
46
+ }
47
+ useAutoPagination({
48
+ sentinel: nextSentinel,
49
+ scrollContainer,
50
+ canLoadMore: () => props.hasNext && !props.error,
51
+ loading: () => props.pending,
52
+ loadMore: () => emit('next')
53
+ })
54
+ onMounted(async () => {
55
+ if (!props.initialScrollTop) {
56
+ return
57
+ }
58
+ await nextTick()
59
+ if (scrollContainer.value) {
60
+ scrollContainer.value.scrollTop = props.initialScrollTop
61
+ }
62
+ })
63
+ </script>
64
+
65
+ <template>
66
+ <div class="flex h-full min-h-0 flex-col">
67
+ <div
68
+ ref="scrollContainer"
69
+ class="service-request-list-scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
70
+ @scroll="onScroll"
71
+ >
72
+ <UButton
73
+ v-if="hasPrevious && !loadingPrevious"
74
+ color="neutral"
75
+ variant="ghost"
76
+ class="mb-3"
77
+ :disabled="pending"
78
+ @click="loadPrevious"
79
+ >{{ $t('features.serviceRequests.list.loadPrevious') }}</UButton
80
+ >
81
+ <div v-if="loadingPrevious" class="space-y-2 pb-3">
82
+ <USkeleton v-for="index in 2" :key="index" class="h-24 w-full" />
83
+ </div>
84
+ <div v-if="pending && !loadingNext && !loadingPrevious" class="space-y-3" aria-busy="true">
85
+ <USkeleton v-for="index in 4" :key="index" class="h-28 w-full" />
86
+ </div>
87
+ <div v-else><slot /></div>
88
+ <div v-if="hasNext" ref="nextSentinel" class="h-px" aria-hidden="true" />
89
+ <div v-if="loadingNext" class="space-y-2 pt-3">
90
+ <USkeleton v-for="index in 2" :key="index" class="h-24 w-full" />
91
+ </div>
92
+ </div>
93
+ <footer
94
+ class="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 border-t border-default px-1 pt-3 text-sm text-muted"
95
+ >
96
+ <span>{{ $t('features.serviceRequests.resultCount', { count: pagination.total }, pagination.total) }}</span>
97
+ <UPagination
98
+ v-if="pagination.pageCount > 1"
99
+ :page="pagination.page"
100
+ :total="pagination.total"
101
+ :items-per-page="pagination.pageSize"
102
+ :disabled="pending"
103
+ :sibling-count="1"
104
+ :show-edges="false"
105
+ @update:page="goToPage($event)"
106
+ />
107
+ </footer>
108
+ </div>
109
+ </template>
110
+
111
+ <style scoped>
112
+ .service-request-list-scroll {
113
+ padding: 2px 2px 16px;
114
+ }
115
+ </style>
@@ -45,6 +45,8 @@ export const useAdminServiceRequests = () => {
45
45
  }
46
46
 
47
47
  return {
48
+ getAssignees: () =>
49
+ $fetch<Array<{ id: string; name: string; image: string | null }>>('/api/service-requests/admin/assignees'),
48
50
  requests: readonly(requests),
49
51
  loading: readonly(loading),
50
52
  error: readonly(error),