@nuxt-customer-portal/service-requests 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/app/components/Admin/RequestDashboard.vue +64 -0
- package/app/components/Admin/RequestTable.vue +158 -0
- package/app/components/CustomerRequestDetail.vue +76 -0
- package/app/components/CustomerRequestForm.vue +143 -0
- package/app/components/ServiceRequestsDashboardAttention.vue +55 -0
- package/app/components/ServiceRequestsDashboardOverview.vue +55 -0
- package/app/components/StatusBadge.vue +16 -0
- package/app/composables/useAdminServiceRequests.ts +60 -0
- package/app/composables/usePaginatedServiceRequests.ts +21 -0
- package/app/composables/useServiceRequestAccess.ts +18 -0
- package/app/composables/useServiceRequestMenu.ts +34 -0
- package/app/composables/useServiceRequests.ts +134 -0
- package/app/composables/useServiceRequestsDashboard.ts +13 -0
- package/app/pages/admin/requests/[id].vue +140 -0
- package/app/pages/admin/requests/index.vue +55 -0
- package/app/pages/requests/[id].vue +161 -0
- package/app/pages/requests/index.vue +873 -0
- package/app/pages/requests/new.vue +37 -0
- package/app/plugins/service-requests-feature.ts +18 -0
- package/i18n/locales/en.json +97 -0
- package/i18n/locales/nl.json +97 -0
- package/migrations/0000_baseline.sql +30 -0
- package/migrations/0001_required_client.sql +6 -0
- package/migrations/meta/0000_snapshot.json +245 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +29 -0
- package/package.json +53 -0
- package/portal.manifest.mjs +9 -0
- package/server/api/service-requests/[id].delete.ts +42 -0
- package/server/api/service-requests/[id].get.ts +39 -0
- package/server/api/service-requests/[id].patch.ts +44 -0
- package/server/api/service-requests/admin/[id].patch.ts +43 -0
- package/server/api/service-requests/admin/index.get.ts +19 -0
- package/server/api/service-requests/dashboard.get.ts +11 -0
- package/server/api/service-requests/index.get.ts +22 -0
- package/server/api/service-requests/index.post.ts +39 -0
- package/server/db/schema/service-requests.ts +78 -0
- package/server/plugins/openapi-contracts.ts +22 -0
- package/server/utils/service-request-repository.ts +185 -0
- package/server/utils/service-request-scope.ts +41 -0
- package/server/utils/service-request-validation.ts +54 -0
- package/shared/feature.ts +90 -0
- package/shared/types/service-request.ts +111 -0
package/CHANGELOG.md
ADDED
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>
|