@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.
@@ -0,0 +1,184 @@
1
+ import type {
2
+ ServiceRequest,
3
+ ServiceRequestListResponse
4
+ } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
5
+ import { usePaginatedResource } from '@nuxt-customer-portal/core/app/composables/usePaginatedResource'
6
+
7
+ export const useServiceRequestList = (options: {
8
+ endpoint: string
9
+ filterKeys?: string[]
10
+ defaultFilters?: Record<string, string>
11
+ defaultSort: string
12
+ defaultSortDir?: 'asc' | 'desc'
13
+ }) => {
14
+ const route = useRoute()
15
+ const router = useRouter()
16
+ const pageSize = 20
17
+ const defaultSortDir = options.defaultSortDir ?? 'asc'
18
+ const search = ref(String(route.query.search ?? ''))
19
+ const sortBy = ref(String(route.query.sortBy ?? options.defaultSort))
20
+ const sortDir = ref<'asc' | 'desc'>(
21
+ route.query.sortDir === 'asc' || route.query.sortDir === 'desc' ? route.query.sortDir : defaultSortDir
22
+ )
23
+ const currentPage = ref(Math.max(1, Number(route.query.page) || 1))
24
+ const filters = reactive<Record<string, string | undefined>>(
25
+ Object.fromEntries(
26
+ (options.filterKeys ?? []).map((key) => [
27
+ key,
28
+ route.query[key] ? String(route.query[key]) : options.defaultFilters?.[key]
29
+ ])
30
+ )
31
+ )
32
+ const categories = ref<string[]>([])
33
+ const resource = usePaginatedResource<ServiceRequest, Record<string, string | undefined>>({
34
+ pageSize,
35
+ getKey: (item) => (item as { id: string }).id,
36
+ fetchPage: async ({ filters: query, page, pageSize, signal }) => {
37
+ const result = await $fetch<ServiceRequestListResponse>(options.endpoint, {
38
+ query: { ...query, page, pageSize },
39
+ signal
40
+ })
41
+ if (!signal.aborted) {
42
+ categories.value = result.categories ?? []
43
+ }
44
+ return result
45
+ }
46
+ })
47
+ const query = () => ({
48
+ ...(search.value.trim() ? { search: search.value.trim() } : {}),
49
+ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)),
50
+ ...(sortBy.value !== options.defaultSort ? { sortBy: sortBy.value } : {}),
51
+ ...(sortDir.value !== defaultSortDir ? { sortDir: sortDir.value } : {})
52
+ })
53
+ const routeQuery = () => ({
54
+ ...(search.value.trim() ? { search: search.value.trim() } : {}),
55
+ ...Object.fromEntries(
56
+ Object.entries(filters).filter(([key, value]) => value && value !== options.defaultFilters?.[key])
57
+ ),
58
+ ...(sortBy.value !== options.defaultSort ? { sortBy: sortBy.value } : {}),
59
+ ...(sortDir.value !== defaultSortDir ? { sortDir: sortDir.value } : {})
60
+ })
61
+ const syncRoute = (page = currentPage.value) =>
62
+ router.replace({ path: route.path, query: { ...routeQuery(), ...(page > 1 ? { page: String(page) } : {}) } })
63
+ const load = async (page = currentPage.value) => {
64
+ const result = await resource.loadPage(query(), { page, pageSize }).catch(() => undefined)
65
+ if (result) {
66
+ const lastPage = Math.max(1, result.pagination.pageCount)
67
+ if (page > lastPage) {
68
+ currentPage.value = lastPage
69
+ await syncRoute(lastPage)
70
+ return resource.loadPage(query(), { page: lastPage, pageSize }).catch(() => undefined)
71
+ }
72
+ currentPage.value = result.pagination.page
73
+ }
74
+ return result
75
+ }
76
+ const resetAndLoad = async () => {
77
+ currentPage.value = 1
78
+ await syncRoute(1)
79
+ await load(1)
80
+ }
81
+ const loadNext = async () => {
82
+ const result = await resource.loadNextPage(query())?.catch(() => undefined)
83
+ if (result) {
84
+ currentPage.value = result.pagination.page
85
+ await syncRoute(currentPage.value)
86
+ }
87
+ }
88
+ const loadPrevious = async () => {
89
+ const result = await resource.loadPreviousPage(query())?.catch(() => undefined)
90
+ if (result) {
91
+ currentPage.value = result.pagination.page
92
+ await syncRoute(currentPage.value)
93
+ }
94
+ return result
95
+ }
96
+ const goToPage = async (page: number) => {
97
+ currentPage.value = page
98
+ await syncRoute(page)
99
+ await load(page)
100
+ }
101
+ const setFilter = (key: string, value: string | undefined) => {
102
+ filters[key] = value
103
+ }
104
+ const toggleSortDir = () => {
105
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
106
+ }
107
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
108
+ let applyingRoute = false
109
+ watch(
110
+ search,
111
+ () => {
112
+ if (applyingRoute) {
113
+ return
114
+ }
115
+ clearTimeout(searchTimer)
116
+ searchTimer = setTimeout(() => {
117
+ void resetAndLoad()
118
+ }, 300)
119
+ },
120
+ { flush: 'sync' }
121
+ )
122
+ watch(
123
+ [sortBy, sortDir, ...Object.keys(filters).map((key) => () => filters[key])],
124
+ () => {
125
+ if (applyingRoute) {
126
+ return
127
+ }
128
+ clearTimeout(searchTimer)
129
+ void resetAndLoad()
130
+ },
131
+ { flush: 'sync' }
132
+ )
133
+ watch(
134
+ () => route.query,
135
+ async (routeQuery) => {
136
+ const nextSearch = String(routeQuery.search ?? '')
137
+ const nextSort = String(routeQuery.sortBy ?? options.defaultSort)
138
+ const nextDir =
139
+ routeQuery.sortDir === 'asc' || routeQuery.sortDir === 'desc' ? routeQuery.sortDir : defaultSortDir
140
+ const nextPage = Math.max(1, Number(routeQuery.page) || 1)
141
+ const nextFilters = Object.fromEntries(
142
+ (options.filterKeys ?? []).map((key) => [
143
+ key,
144
+ routeQuery[key] ? String(routeQuery[key]) : options.defaultFilters?.[key]
145
+ ])
146
+ )
147
+ if (
148
+ nextSearch === search.value.trim() &&
149
+ nextSort === sortBy.value &&
150
+ nextDir === sortDir.value &&
151
+ nextPage === currentPage.value &&
152
+ Object.keys(nextFilters).every((key) => nextFilters[key] === filters[key])
153
+ ) {
154
+ return
155
+ }
156
+ clearTimeout(searchTimer)
157
+ applyingRoute = true
158
+ search.value = nextSearch
159
+ sortBy.value = nextSort
160
+ sortDir.value = nextDir
161
+ currentPage.value = nextPage
162
+ Object.assign(filters, nextFilters)
163
+ applyingRoute = false
164
+ await load(nextPage)
165
+ }
166
+ )
167
+ onScopeDispose(() => clearTimeout(searchTimer))
168
+ return {
169
+ ...resource,
170
+ categories,
171
+ search,
172
+ filters,
173
+ sortBy,
174
+ sortDir,
175
+ currentPage,
176
+ load,
177
+ refresh: load,
178
+ loadNext,
179
+ loadPrevious,
180
+ goToPage,
181
+ setFilter,
182
+ toggleSortDir
183
+ }
184
+ }
@@ -16,14 +16,6 @@ export const useServiceRequestMenu = () => {
16
16
  }
17
17
  ]
18
18
 
19
- if (isOrganizationAdmin.value) {
20
- items.push({
21
- label: t('features.serviceRequests.navigation.manageRequests'),
22
- to: '/admin/requests',
23
- icon: 'i-lucide-settings'
24
- })
25
- }
26
-
27
19
  return items
28
20
  })
29
21
 
@@ -1,140 +1,11 @@
1
1
  <script setup lang="ts">
2
- import type {
3
- AdminServiceRequestUpdateInput,
4
- ServiceRequestWithRelations
5
- } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
6
-
7
2
  const route = useRoute()
8
- const { t } = useI18n()
9
- const requestId = route.params.id as string
10
-
11
- const { adminUpdateRequest } = useAdminServiceRequests()
12
- const toast = useToast()
13
-
14
- const request = ref<ServiceRequestWithRelations | null>(null)
15
- const loading = ref(true)
16
- const updating = ref(false)
17
-
18
- useSeoMeta({
19
- title: () => request.value?.title || t('features.serviceRequests.navigation.manageRequests')
20
- })
21
-
22
- const adminUpdates = reactive<AdminServiceRequestUpdateInput>({
23
- status: undefined,
24
- priority: undefined,
25
- assignedToId: '',
26
- internalNotes: ''
27
- })
28
-
29
- const statusOptions = computed(() => [
30
- { label: t('features.serviceRequests.status.open'), value: 'OPEN' },
31
- { label: t('features.serviceRequests.status.in_progress'), value: 'IN_PROGRESS' },
32
- { label: t('features.serviceRequests.status.resolved'), value: 'RESOLVED' },
33
- { label: t('features.serviceRequests.status.closed'), value: 'CLOSED' }
34
- ])
35
-
36
- const priorityOptions = computed(() => [
37
- { label: t('features.serviceRequests.priority.low'), value: 'LOW' },
38
- { label: t('features.serviceRequests.priority.medium'), value: 'MEDIUM' },
39
- { label: t('features.serviceRequests.priority.high'), value: 'HIGH' },
40
- { label: t('features.serviceRequests.priority.urgent'), value: 'URGENT' }
41
- ])
42
-
43
- const userOptions = [
44
- // This would be populated with users from the organization
45
- { label: t('features.serviceRequests.placeholders.selectUser'), value: '' }
46
- ]
47
-
48
- onMounted(async () => {
49
- try {
50
- request.value = await $fetch(`/api/service-requests/${requestId}`)
51
- if (!request.value) {
52
- return
53
- }
54
- adminUpdates.status = request.value.status
55
- adminUpdates.priority = request.value.priority
56
- adminUpdates.assignedToId = request.value.assignedToId || ''
57
- adminUpdates.internalNotes = request.value.internalNotes || ''
58
- } catch {
59
- toast.add({
60
- title: t('common.error'),
61
- description: t('features.serviceRequests.messages.fetchError'),
62
- color: 'error'
63
- })
64
- } finally {
65
- loading.value = false
66
- }
67
- })
68
-
69
- const handleUpdate = async () => {
70
- updating.value = true
71
- try {
72
- request.value = await adminUpdateRequest(requestId, adminUpdates)
73
- toast.add({
74
- title: t('common.success'),
75
- description: t('features.serviceRequests.messages.updateSuccess')
76
- })
77
- } catch {
78
- toast.add({
79
- title: t('common.error'),
80
- description: t('features.serviceRequests.messages.updateError'),
81
- color: 'error'
82
- })
83
- } finally {
84
- updating.value = false
85
- }
86
- }
87
-
88
- const handleQuickUpdate = () => {
89
- handleUpdate()
90
- }
3
+ await navigateTo(
4
+ { path: `/requests/${encodeURIComponent(String(route.params.id))}`, query: route.query },
5
+ { replace: true }
6
+ )
91
7
  </script>
92
8
 
93
9
  <template>
94
- <div class="container mx-auto py-8 max-w-4xl">
95
- <div v-if="loading">
96
- <USkeleton class="h-32 w-full mb-4" />
97
- <USkeleton class="h-64 w-full" />
98
- </div>
99
-
100
- <div v-else-if="request" class="space-y-6">
101
- <!-- Full request details with admin controls -->
102
- <CustomerRequestDetail :request-id="request.id" />
103
-
104
- <USeparator />
105
-
106
- <!-- Admin Actions -->
107
- <UCard>
108
- <template #header>
109
- <h3 class="text-lg font-semibold">{{ t('features.serviceRequests.actions.adminActions') }}</h3>
110
- </template>
111
-
112
- <div class="space-y-4">
113
- <UFormField :label="t('features.serviceRequests.fields.status')">
114
- <USelect v-model="adminUpdates.status" :options="statusOptions" @change="handleQuickUpdate" />
115
- </UFormField>
116
-
117
- <UFormField :label="t('features.serviceRequests.fields.priority')">
118
- <USelect v-model="adminUpdates.priority" :options="priorityOptions" @change="handleQuickUpdate" />
119
- </UFormField>
120
-
121
- <UFormField :label="t('features.serviceRequests.fields.assignedTo')">
122
- <USelect v-model="adminUpdates.assignedToId" :options="userOptions" @change="handleQuickUpdate" />
123
- </UFormField>
124
-
125
- <UFormField :label="t('features.serviceRequests.fields.internalNotes')">
126
- <UTextarea
127
- v-model="adminUpdates.internalNotes"
128
- :rows="4"
129
- :placeholder="t('features.serviceRequests.placeholders.internalNotes')"
130
- />
131
- </UFormField>
132
-
133
- <UButton :loading="updating" @click="handleUpdate">
134
- {{ t('features.serviceRequests.actions.saveChanges') }}
135
- </UButton>
136
- </div>
137
- </UCard>
138
- </div>
139
- </div>
10
+ <div />
140
11
  </template>
@@ -1,55 +1,8 @@
1
1
  <script setup lang="ts">
2
- import type {
3
- AdminServiceRequestUpdateInput,
4
- ServiceRequestFilters
5
- } from '@nuxt-customer-portal/service-requests/shared/types/service-request'
6
-
7
- const { requests, loading, pagination, stats, fetchAllRequests, adminUpdateRequest } = useAdminServiceRequests()
8
-
9
- const toast = useToast()
10
- const { t } = useI18n()
11
-
12
- useSeoMeta({
13
- title: () => t('features.serviceRequests.navigation.manageRequests')
14
- })
15
-
16
- onMounted(() => {
17
- fetchAllRequests()
18
- })
19
-
20
- const handleFilter = (filters: ServiceRequestFilters) => {
21
- fetchAllRequests(filters)
22
- }
23
-
24
- const handleUpdate = async ({ id, updates }: { id: string; updates: AdminServiceRequestUpdateInput }) => {
25
- try {
26
- await adminUpdateRequest(id, updates)
27
- toast.add({
28
- title: t('common.success'),
29
- description: t('features.serviceRequests.messages.updateSuccess')
30
- })
31
- } catch {
32
- toast.add({
33
- title: t('common.error'),
34
- description: t('features.serviceRequests.messages.updateError'),
35
- color: 'error'
36
- })
37
- }
38
- }
2
+ const route = useRoute()
3
+ await navigateTo({ path: '/requests', query: route.query }, { replace: true })
39
4
  </script>
40
5
 
41
6
  <template>
42
- <div class="container mx-auto py-8">
43
- <h1 class="text-3xl font-bold mb-6">{{ t('features.serviceRequests.navigation.manageRequests') }}</h1>
44
-
45
- <AdminRequestDashboard
46
- :requests="requests"
47
- :loading="loading"
48
- :pagination="pagination"
49
- :stats="stats"
50
- @select="navigateTo(`/admin/requests/${$event}`)"
51
- @filter="handleFilter"
52
- @update="handleUpdate"
53
- />
54
- </div>
7
+ <div />
55
8
  </template>
@@ -16,17 +16,19 @@ const request = ref<ServiceRequestWithRelations | null>(null)
16
16
  const loading = ref(true)
17
17
  const updating = ref(false)
18
18
  const showEditModal = ref(false)
19
+ const showDeleteConfirm = ref(false)
20
+ const { can } = useServiceRequestAccess()
19
21
 
20
22
  useSeoMeta({
21
23
  title: () => request.value?.title || t('features.serviceRequests.title')
22
24
  })
23
25
 
24
26
  const canEdit = computed(() => {
25
- return request.value?.createdById === currentUser.value?.id
27
+ return request.value?.createdById === currentUser.value?.id || can('update')
26
28
  })
27
29
 
28
30
  const canDelete = computed(() => {
29
- return request.value?.createdById === currentUser.value?.id
31
+ return request.value?.createdById === currentUser.value?.id || can('delete')
30
32
  })
31
33
 
32
34
  const backRoute = computed(() => {
@@ -93,10 +95,6 @@ const handleUpdate = async (data: ServiceRequestUpdateInput) => {
93
95
  }
94
96
 
95
97
  const handleDelete = async () => {
96
- if (!confirm('Are you sure you want to delete this request?')) {
97
- return
98
- }
99
-
100
98
  try {
101
99
  await deleteRequest(requestId)
102
100
  toast.add({
@@ -115,7 +113,7 @@ const handleDelete = async () => {
115
113
  </script>
116
114
 
117
115
  <template>
118
- <div class="container mx-auto py-8 max-w-4xl">
116
+ <div class="mx-auto h-full min-h-0 w-full max-w-5xl overflow-y-auto px-4 py-5 sm:px-6">
119
117
  <div class="mb-4">
120
118
  <UButton icon="i-lucide-arrow-left" variant="ghost" size="sm" :to="backRoute">
121
119
  {{ t('features.serviceRequests.actions.back') }}
@@ -132,30 +130,34 @@ const handleDelete = async () => {
132
130
  <UButton :to="backRoute">{{ t('features.serviceRequests.actions.back') }}</UButton>
133
131
  </div>
134
132
 
135
- <div v-else>
133
+ <div v-else class="space-y-5">
136
134
  <CustomerRequestDetail
137
- :request-id="request.id"
135
+ :request="request"
138
136
  :can-edit="canEdit"
139
137
  :can-delete="canDelete"
140
138
  @edit="showEditModal = true"
141
- @delete="handleDelete"
139
+ @delete="showDeleteConfirm = true"
142
140
  />
143
141
 
144
- <!-- Edit Modal -->
145
- <UModal v-model="showEditModal">
146
- <UCard>
147
- <template #header>
148
- <h2 class="text-xl font-bold">{{ t('features.serviceRequests.edit') }}</h2>
149
- </template>
150
-
142
+ <ServiceRequestManagement v-if="can('manage')" :request="request" @updated="request = $event" />
143
+ <UModal v-model:open="showEditModal" :title="t('features.serviceRequests.edit')">
144
+ <template #body>
151
145
  <CustomerRequestForm
152
146
  :initial-data="request"
153
147
  :loading="updating"
154
148
  @submit="handleUpdate"
155
149
  @cancel="showEditModal = false"
156
150
  />
157
- </UCard>
151
+ </template>
158
152
  </UModal>
153
+ <ConfirmationModal
154
+ v-model:open="showDeleteConfirm"
155
+ title="features.serviceRequests.delete"
156
+ message="features.serviceRequests.confirmDelete"
157
+ confirm-color="error"
158
+ confirm-text="common.delete"
159
+ @confirm="handleDelete"
160
+ />
159
161
  </div>
160
162
  </div>
161
163
  </template>