@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @nuxt-customer-portal/service-requests
2
+
3
+ ## 0.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @nuxt-customer-portal/clients@0.3.0
8
+ - @nuxt-customer-portal/core@0.3.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nuxt Customer Portal contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # Service Requests feature layer
2
+
3
+ This Nuxt layer owns the service-request UI, API, policy, translations, types, validation, Drizzle schema, and immutable baseline migration. It depends on the public adapters exported by `@nuxt-customer-portal/core`.
4
+
5
+ ## Enable or disable
6
+
7
+ The local `layers/` directory is auto-registered by Nuxt. To disable this feature without deleting data, move the layer outside `layers/` or add it to `.nuxtignore`. Its routes, navigation, dashboard widget, auto-imports, and translations will then disappear.
8
+
9
+ Disabling a layer never removes database objects. Removing its data requires a separate, reviewed migration that drops the `service_request` table and its enums.
10
+
11
+ ## Database
12
+
13
+ The schema is exported from `server/db/schema/service-requests.ts`. Its tables and
14
+ enums live in the `service_requests` PostgreSQL schema; portal core and authentication
15
+ remain in `public`. The host Drizzle configuration discovers feature schemas and owns
16
+ the single ordered migration history.
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ AdminServiceRequestUpdateInput,
4
+ ServiceRequestFilters,
5
+ ServiceRequestPagination,
6
+ ServiceRequest
7
+ } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
8
+
9
+ defineProps<{
10
+ requests: readonly ServiceRequest[]
11
+ loading: boolean
12
+ pagination: ServiceRequestPagination
13
+ stats: Record<string, number>
14
+ }>()
15
+
16
+ defineEmits<{
17
+ select: [id: string]
18
+ filter: [filters: ServiceRequestFilters]
19
+ update: [data: { id: string; updates: AdminServiceRequestUpdateInput }]
20
+ }>()
21
+ const { t } = useI18n()
22
+ </script>
23
+
24
+ <template>
25
+ <div class="space-y-6">
26
+ <!-- Stats Overview -->
27
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
28
+ <UCard>
29
+ <div class="text-center">
30
+ <div class="text-3xl font-bold text-blue-600">{{ stats.OPEN || 0 }}</div>
31
+ <div class="text-sm text-gray-600">{{ t('features.serviceRequests.status.open') }}</div>
32
+ </div>
33
+ </UCard>
34
+ <UCard>
35
+ <div class="text-center">
36
+ <div class="text-3xl font-bold text-yellow-600">{{ stats.IN_PROGRESS || 0 }}</div>
37
+ <div class="text-sm text-gray-600">{{ t('features.serviceRequests.status.in_progress') }}</div>
38
+ </div>
39
+ </UCard>
40
+ <UCard>
41
+ <div class="text-center">
42
+ <div class="text-3xl font-bold text-green-600">{{ stats.RESOLVED || 0 }}</div>
43
+ <div class="text-sm text-gray-600">{{ t('features.serviceRequests.status.resolved') }}</div>
44
+ </div>
45
+ </UCard>
46
+ <UCard>
47
+ <div class="text-center">
48
+ <div class="text-3xl font-bold text-gray-600">{{ stats.CLOSED || 0 }}</div>
49
+ <div class="text-sm text-gray-600">{{ t('features.serviceRequests.status.closed') }}</div>
50
+ </div>
51
+ </UCard>
52
+ </div>
53
+
54
+ <!-- Filters and Table -->
55
+ <AdminRequestTable
56
+ :requests="requests"
57
+ :loading="loading"
58
+ :pagination="pagination"
59
+ @select="$emit('select', $event)"
60
+ @filter="$emit('filter', $event)"
61
+ @update="$emit('update', $event)"
62
+ />
63
+ </div>
64
+ </template>
@@ -0,0 +1,158 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ AdminServiceRequestUpdateInput,
4
+ ServiceRequestFilters,
5
+ ServiceRequestPagination,
6
+ ServiceRequestPriority,
7
+ ServiceRequest
8
+ } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
9
+
10
+ defineProps<{
11
+ requests: readonly ServiceRequest[]
12
+ loading: boolean
13
+ pagination: ServiceRequestPagination
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ select: [id: string]
18
+ filter: [filters: ServiceRequestFilters]
19
+ assign: [id: string]
20
+ update: [data: { id: string; updates: AdminServiceRequestUpdateInput }]
21
+ }>()
22
+ const { t } = useI18n()
23
+
24
+ const currentPage = ref(1)
25
+ const filters = reactive({
26
+ status: undefined,
27
+ priority: undefined,
28
+ search: ''
29
+ })
30
+
31
+ watch(filters, () => {
32
+ emit('filter', filters)
33
+ })
34
+
35
+ watch(currentPage, (page) => {
36
+ emit('filter', { ...filters, page })
37
+ })
38
+
39
+ const getActions = (request: ServiceRequest) => {
40
+ return [
41
+ [
42
+ { label: t('features.serviceRequests.actions.view'), click: () => emit('select', request.id) },
43
+ { label: t('features.serviceRequests.actions.assign'), click: () => emit('assign', request.id) },
44
+ {
45
+ label: t('features.serviceRequests.actions.resolve'),
46
+ click: () => emit('update', { id: request.id, updates: { status: 'RESOLVED' } })
47
+ },
48
+ {
49
+ label: t('features.serviceRequests.actions.close'),
50
+ click: () => emit('update', { id: request.id, updates: { status: 'CLOSED' } })
51
+ }
52
+ ]
53
+ ]
54
+ }
55
+
56
+ const statusOptions = computed(() => [
57
+ { label: t('features.serviceRequests.status.open'), value: 'OPEN' },
58
+ { label: t('features.serviceRequests.status.in_progress'), value: 'IN_PROGRESS' },
59
+ { label: t('features.serviceRequests.status.resolved'), value: 'RESOLVED' },
60
+ { label: t('features.serviceRequests.status.closed'), value: 'CLOSED' }
61
+ ])
62
+
63
+ const priorityOptions = computed(() => [
64
+ { label: t('features.serviceRequests.priority.low'), value: 'LOW' },
65
+ { label: t('features.serviceRequests.priority.medium'), value: 'MEDIUM' },
66
+ { label: t('features.serviceRequests.priority.high'), value: 'HIGH' },
67
+ { label: t('features.serviceRequests.priority.urgent'), value: 'URGENT' }
68
+ ])
69
+
70
+ const getPriorityColor = (priority: ServiceRequestPriority) => {
71
+ switch (priority) {
72
+ case 'LOW':
73
+ return 'success'
74
+ case 'MEDIUM':
75
+ return 'info'
76
+ case 'HIGH':
77
+ return 'warning'
78
+ case 'URGENT':
79
+ return 'error'
80
+ default:
81
+ return 'neutral'
82
+ }
83
+ }
84
+ </script>
85
+
86
+ <template>
87
+ <UCard>
88
+ <!-- Filters -->
89
+ <div class="flex gap-2 mb-4">
90
+ <USelect
91
+ v-model="filters.status"
92
+ :options="statusOptions"
93
+ :placeholder="t('features.serviceRequests.fields.status')"
94
+ />
95
+ <USelect
96
+ v-model="filters.priority"
97
+ :options="priorityOptions"
98
+ :placeholder="t('features.serviceRequests.fields.priority')"
99
+ />
100
+ <UInput v-model="filters.search" :placeholder="t('common.searchPlaceholder')" icon="i-lucide-search" />
101
+ </div>
102
+
103
+ <!-- Table -->
104
+ <div v-if="loading">
105
+ <USkeleton v-for="i in 5" :key="i" class="h-12 w-full mb-2" />
106
+ </div>
107
+
108
+ <UEmpty
109
+ v-else-if="requests.length === 0"
110
+ icon="i-lucide-ticket"
111
+ :description="t('features.serviceRequests.messages.empty')"
112
+ />
113
+
114
+ <div v-else class="overflow-x-auto">
115
+ <table class="w-full text-sm">
116
+ <thead>
117
+ <tr class="border-b text-left">
118
+ <th class="p-3">{{ t('features.serviceRequests.fields.title') }}</th>
119
+ <th class="p-3">{{ t('features.serviceRequests.fields.client') }}</th>
120
+ <th class="p-3">{{ t('features.serviceRequests.fields.status') }}</th>
121
+ <th class="p-3">{{ t('features.serviceRequests.fields.priority') }}</th>
122
+ <th class="p-3">{{ t('features.serviceRequests.fields.assignedTo') }}</th>
123
+ <th class="p-3" />
124
+ </tr>
125
+ </thead>
126
+ <tbody>
127
+ <tr v-for="request in requests" :key="request.id" class="border-b">
128
+ <td class="p-3">
129
+ <button class="hover:underline" @click="$emit('select', request.id)">{{ request.title }}</button>
130
+ </td>
131
+ <td class="p-3">{{ request.clientName || request.clientOrganizationId }}</td>
132
+ <td class="p-3"><StatusBadge :status="request.status" /></td>
133
+ <td class="p-3">
134
+ <UBadge :color="getPriorityColor(request.priority)">{{ request.priority }}</UBadge>
135
+ </td>
136
+ <td class="p-3">{{ request.assignedToId || '—' }}</td>
137
+ <td class="p-3">
138
+ <UDropdownMenu :items="getActions(request)">
139
+ <UButton variant="ghost" icon="i-lucide-more-vertical" />
140
+ </UDropdownMenu>
141
+ </td>
142
+ </tr>
143
+ </tbody>
144
+ </table>
145
+ </div>
146
+
147
+ <!-- Result count and pagination -->
148
+ <div class="flex items-center justify-between gap-3 border-t border-default pt-3 text-sm text-muted">
149
+ <span>{{ t('features.serviceRequests.resultCount', { count: pagination.total }) }}</span>
150
+ <UPagination
151
+ v-if="pagination.pageCount > 1"
152
+ v-model="currentPage"
153
+ :total="pagination.total"
154
+ :page-size="pagination.pageSize"
155
+ />
156
+ </div>
157
+ </UCard>
158
+ </template>
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import type { ServiceRequest } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
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
+ }
21
+ </script>
22
+
23
+ <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>
35
+ </div>
36
+ </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> -->
62
+ <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) }}
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </template>
@@ -0,0 +1,143 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type {
4
+ ServiceRequest,
5
+ ServiceRequestCreateInput
6
+ } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
7
+
8
+ const props = defineProps<{
9
+ initialData?: Partial<ServiceRequest>
10
+ loading?: boolean
11
+ }>()
12
+
13
+ const { t } = useI18n()
14
+ const { activeOrganizationType } = usePortalSession()
15
+
16
+ const emit = defineEmits<{
17
+ submit: [data: ServiceRequestCreateInput]
18
+ cancel: []
19
+ }>()
20
+
21
+ const editMode = computed(() => !!props.initialData?.id)
22
+
23
+ const state = reactive({
24
+ clientOrganizationId: props.initialData?.clientOrganizationId || '',
25
+ title: props.initialData?.title || '',
26
+ description: props.initialData?.description || '',
27
+ priority: props.initialData?.priority || 'MEDIUM',
28
+ category: props.initialData?.category || ''
29
+ })
30
+
31
+ watch(
32
+ () => props.initialData,
33
+ (data) => {
34
+ state.title = data?.title || ''
35
+ state.clientOrganizationId = data?.clientOrganizationId || ''
36
+ state.description = data?.description || ''
37
+ state.priority = data?.priority || 'MEDIUM'
38
+ state.category = data?.category || ''
39
+ }
40
+ )
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
+ })
49
+
50
+ const { priorityOptions, getStatusBadgeText, getStatusColor } = useServiceRequests()
51
+
52
+ const handleSubmit = () => {
53
+ emit('submit', state)
54
+ }
55
+ </script>
56
+
57
+ <template>
58
+ <UForm :state="state" :schema="schema" class="w-full" @submit="handleSubmit">
59
+ <!-- Wrap in real DOM nodes so spacing is guaranteed -->
60
+ <div class="space-y-6">
61
+ <ClientsClientPicker
62
+ v-if="activeOrganizationType === 'PROVIDER'"
63
+ v-model="state.clientOrganizationId"
64
+ module-id="service-requests"
65
+ :label="t('features.serviceRequests.fields.client')"
66
+ required
67
+ />
68
+ <div>
69
+ <UFormField :label="t('features.serviceRequests.fields.title')" name="title" required>
70
+ <UInput
71
+ v-model="state.title"
72
+ :placeholder="t('features.serviceRequests.fields.title')"
73
+ class="w-full"
74
+ size="lg"
75
+ />
76
+ </UFormField>
77
+ </div>
78
+
79
+ <div>
80
+ <UFormField
81
+ :label="t('features.serviceRequests.fields.description')"
82
+ name="description"
83
+ required
84
+ class="w-full"
85
+ >
86
+ <UTextarea
87
+ v-model="state.description"
88
+ class="w-full"
89
+ :placeholder="t('features.serviceRequests.fields.description')"
90
+ :rows="7"
91
+ size="lg"
92
+ />
93
+ </UFormField>
94
+ </div>
95
+
96
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
97
+ <div>
98
+ <UFormField :label="t('features.serviceRequests.fields.priority')" name="priority" class="w-full">
99
+ <USelect
100
+ v-model="state.priority"
101
+ class="w-full"
102
+ :items="priorityOptions.filter((i) => i.value !== undefined)"
103
+ size="lg"
104
+ />
105
+ </UFormField>
106
+ </div>
107
+
108
+ <div>
109
+ <UFormField :label="t('features.serviceRequests.fields.category')" name="category" class="w-full">
110
+ <UInput
111
+ v-model="state.category"
112
+ class="w-full"
113
+ :placeholder="t('features.serviceRequests.placeholders.category')"
114
+ size="lg"
115
+ />
116
+ </UFormField>
117
+ </div>
118
+ </div>
119
+
120
+ <div v-if="editMode && props.initialData?.status" class="rounded-lg border border-default bg-elevated/20 p-4">
121
+ <div class="flex items-center justify-between gap-3">
122
+ <div class="text-sm font-medium text-muted">
123
+ {{ t('features.serviceRequests.fields.status') }}
124
+ </div>
125
+ <UBadge :color="getStatusColor(props.initialData.status)" variant="soft">
126
+ {{ getStatusBadgeText(props.initialData.status) }}
127
+ </UBadge>
128
+ </div>
129
+ </div>
130
+
131
+ <div class="pt-4 border-t border-default">
132
+ <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')">
134
+ {{ t('common.cancel') }}
135
+ </UButton>
136
+ <UButton type="submit" color="primary" size="lg" :loading="loading">
137
+ {{ editMode ? t('common.save') : t('common.create') }}
138
+ </UButton>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </UForm>
143
+ </template>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n()
3
+ const { data, pending, error, refresh } = await useServiceRequestsDashboard()
4
+ </script>
5
+
6
+ <template>
7
+ <UCard class="h-full">
8
+ <template #header>
9
+ <div class="flex items-center justify-between gap-3">
10
+ <h2 class="font-semibold">{{ t('features.serviceRequests.dashboard.attention.title') }}</h2>
11
+ <UIcon name="i-lucide-triangle-alert" class="size-5 text-warning" />
12
+ </div>
13
+ </template>
14
+ <div v-if="pending" class="space-y-3"><USkeleton class="h-8 w-24" /><USkeleton class="h-4 w-full" /></div>
15
+ <div v-else-if="error" class="py-3 text-center">
16
+ <p class="text-sm text-muted">{{ t('features.serviceRequests.dashboard.error') }}</p>
17
+ <UButton class="mt-3" color="neutral" variant="outline" size="sm" icon="i-lucide-refresh-cw" @click="refresh()">
18
+ {{ t('features.serviceRequests.dashboard.retry') }}
19
+ </UButton>
20
+ </div>
21
+ <template v-else-if="data?.attention">
22
+ <div class="flex flex-wrap gap-2">
23
+ <UBadge v-if="data.attention.urgentCount" color="error" variant="subtle">
24
+ {{ t('features.serviceRequests.dashboard.urgent', { count: data.attention.urgentCount }) }} </UBadge
25
+ ><UBadge v-if="data.attention.unassignedCount" color="warning" variant="subtle">
26
+ {{ t('features.serviceRequests.dashboard.unassigned', { count: data.attention.unassignedCount }) }} </UBadge
27
+ ><UBadge v-if="data.attention.longOpenCount" color="neutral" variant="subtle">
28
+ {{ t('features.serviceRequests.dashboard.longOpen', { count: data.attention.longOpenCount }) }}
29
+ </UBadge>
30
+ </div>
31
+ <p v-if="!data.attention.items.length" class="py-6 text-center text-sm text-muted">
32
+ {{ t('features.serviceRequests.dashboard.attention.empty') }}
33
+ </p>
34
+ <ul v-else class="mt-4 divide-y divide-default">
35
+ <li v-for="request in data.attention.items.slice(0, 3)" :key="request.id">
36
+ <NuxtLink
37
+ :to="`/admin/requests/${request.id}`"
38
+ class="flex items-center justify-between gap-3 rounded py-3 focus-visible:outline-2 focus-visible:outline-primary"
39
+ ><span class="truncate text-sm font-medium">{{ request.title }}</span
40
+ ><UBadge
41
+ :color="request.priority === 'URGENT' ? 'error' : request.priority === 'HIGH' ? 'warning' : 'neutral'"
42
+ variant="subtle"
43
+ >{{ t(`features.serviceRequests.priority.${request.priority.toLowerCase()}`) }}</UBadge
44
+ ></NuxtLink
45
+ >
46
+ </li>
47
+ </ul>
48
+ <div class="mt-3 text-right">
49
+ <UButton to="/admin/requests" color="neutral" variant="ghost" trailing-icon="i-lucide-arrow-right">
50
+ {{ t('features.serviceRequests.dashboard.manage') }}
51
+ </UButton>
52
+ </div>
53
+ </template>
54
+ </UCard>
55
+ </template>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ const { t, locale } = useI18n()
3
+ const { data, pending, error, refresh } = await useServiceRequestsDashboard()
4
+ const formatDate = (value: string) =>
5
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(value))
6
+ </script>
7
+
8
+ <template>
9
+ <UCard class="h-full">
10
+ <template #header>
11
+ <div class="flex items-center justify-between gap-3">
12
+ <h2 class="font-semibold">{{ t('features.serviceRequests.dashboard.overview.title') }}</h2>
13
+ <UButton to="/requests/new" size="sm" icon="i-lucide-plus">
14
+ {{ t('features.serviceRequests.dashboard.new') }}
15
+ </UButton>
16
+ </div>
17
+ </template>
18
+ <div v-if="pending" class="space-y-3"><USkeleton class="h-8 w-24" /><USkeleton class="h-4 w-full" /></div>
19
+ <div v-else-if="error" class="py-3 text-center">
20
+ <p class="text-sm text-muted">{{ t('features.serviceRequests.dashboard.error') }}</p>
21
+ <UButton class="mt-3" color="neutral" variant="outline" size="sm" icon="i-lucide-refresh-cw" @click="refresh()">
22
+ {{ t('features.serviceRequests.dashboard.retry') }}
23
+ </UButton>
24
+ </div>
25
+ <template v-else-if="data">
26
+ <div class="flex flex-wrap gap-3">
27
+ <UBadge color="primary" variant="subtle">
28
+ {{ t('features.serviceRequests.dashboard.active', { count: data.overview.activeCount }) }} </UBadge
29
+ ><UBadge color="success" variant="subtle">
30
+ {{ t('features.serviceRequests.dashboard.resolved', { count: data.overview.resolvedCount }) }}
31
+ </UBadge>
32
+ </div>
33
+ <p v-if="!data.overview.recent.length" class="py-6 text-center text-sm text-muted">
34
+ {{ t('features.serviceRequests.dashboard.overview.empty') }}
35
+ </p>
36
+ <ul v-else class="mt-4 divide-y divide-default">
37
+ <li v-for="request in data.overview.recent" :key="request.id">
38
+ <NuxtLink
39
+ :to="`/requests/${request.id}`"
40
+ class="flex items-center justify-between gap-3 rounded py-3 focus-visible:outline-2 focus-visible:outline-primary"
41
+ ><span class="min-w-0"
42
+ ><span class="block truncate text-sm font-medium">{{ request.title }}</span
43
+ ><span class="text-xs text-muted">{{ formatDate(request.updatedAt) }}</span></span
44
+ ><StatusBadge :status="request.status"
45
+ /></NuxtLink>
46
+ </li>
47
+ </ul>
48
+ <div class="mt-3 text-right">
49
+ <UButton to="/requests" color="neutral" variant="ghost" trailing-icon="i-lucide-arrow-right">
50
+ {{ t('features.serviceRequests.dashboard.viewAll') }}
51
+ </UButton>
52
+ </div>
53
+ </template>
54
+ </UCard>
55
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import type { ServiceRequestStatus } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
3
+
4
+ const props = defineProps<{
5
+ status: ServiceRequestStatus
6
+ variant?: 'solid' | 'outline' | 'soft'
7
+ }>()
8
+
9
+ const { getStatusColor, getStatusBadgeText } = useServiceRequests()
10
+ </script>
11
+
12
+ <template>
13
+ <UBadge :color="getStatusColor(props.status)" :variant="variant" size="md">
14
+ {{ getStatusBadgeText(props.status) }}
15
+ </UBadge>
16
+ </template>