@nuxt-customer-portal/service-requests 0.3.1 → 0.3.3
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 +23 -0
- package/README.md +8 -0
- package/app/components/CustomerRequestDetail.vue +52 -64
- package/app/components/CustomerRequestForm.vue +29 -9
- package/app/components/ServiceRequestManagement.vue +109 -0
- package/app/components/ServiceRequestsList.vue +168 -0
- package/app/components/ServiceRequestsPaginatedList.vue +115 -0
- package/app/composables/useAdminServiceRequests.ts +2 -0
- package/app/composables/useServiceRequestList.ts +184 -0
- package/app/composables/useServiceRequestMenu.ts +0 -8
- package/app/pages/admin/requests/[id].vue +5 -134
- package/app/pages/admin/requests/index.vue +3 -50
- package/app/pages/requests/[id].vue +20 -18
- package/app/pages/requests/index.vue +1 -871
- package/app/pages/requests/new.vue +9 -3
- package/i18n/locales/en.json +22 -1
- package/i18n/locales/nl.json +22 -1
- package/package.json +3 -3
- package/portal.manifest.mjs +1 -1
- package/server/api/service-requests/admin/[id].patch.ts +8 -0
- package/server/api/service-requests/admin/assignees.get.ts +7 -0
- package/server/utils/service-request-repository.ts +25 -3
- package/server/utils/service-request-validation.ts +6 -6
- package/shared/feature.ts +4 -27
- package/shared/types/service-request.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @nuxt-customer-portal/service-requests
|
|
2
2
|
|
|
3
|
+
## 0.3.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @nuxt-customer-portal/clients@0.3.3
|
|
8
|
+
- @nuxt-customer-portal/core@0.3.3
|
|
9
|
+
|
|
10
|
+
## 0.3.2
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 903e0ce: Show service-request dashboard widgets after time and invoice widgets so daily work and billing take priority.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
Repair request editing and deletion confirmation, add localized form validation, and provide working admin status, priority, provider-team assignment, and internal-note controls.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
- 1b55b42: Place Service Requests after Invoices and before Administration in portal navigation.
|
|
22
|
+
- Updated dependencies [f23f664]
|
|
23
|
+
- @nuxt-customer-portal/core@0.3.2
|
|
24
|
+
- @nuxt-customer-portal/clients@0.3.2
|
|
25
|
+
|
|
3
26
|
## 0.3.1
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
<
|
|
25
|
-
<
|
|
26
|
-
<div>
|
|
27
|
-
<div class="
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
64
|
-
{{
|
|
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
|
-
</
|
|
63
|
+
</UCard>
|
|
76
64
|
</template>
|
|
@@ -39,13 +39,26 @@ watch(
|
|
|
39
39
|
}
|
|
40
40
|
)
|
|
41
41
|
|
|
42
|
-
const schema =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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),
|