@nuxt-customer-portal/clients 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 +3 -0
- package/app/components/ClientsClientDetail.vue +407 -0
- package/app/components/ClientsClientForm.vue +135 -0
- package/app/components/ClientsClientPicker.vue +24 -0
- package/app/composables/useClients.ts +25 -0
- package/app/pages/clients/[id].vue +47 -0
- package/app/pages/clients/index.vue +265 -0
- package/app/plugins/clients-feature.ts +3 -0
- package/i18n/locales/en.json +74 -0
- package/i18n/locales/nl.json +74 -0
- package/migrations/0000_baseline.sql +33 -0
- package/nuxt.config.ts +17 -0
- package/package.json +55 -0
- package/portal.manifest.mjs +8 -0
- package/server/api/clients/[id]/archive/index.patch.ts +11 -0
- package/server/api/clients/[id]/deletion/index.get.ts +7 -0
- package/server/api/clients/[id]/index.delete.ts +10 -0
- package/server/api/clients/[id]/index.get.ts +12 -0
- package/server/api/clients/[id]/index.patch.ts +10 -0
- package/server/api/clients/[id]/invitations/[invitationId].patch.ts +8 -0
- package/server/api/clients/[id]/invitations/index.post.ts +66 -0
- package/server/api/clients/[id]/members/[memberId].delete.ts +29 -0
- package/server/api/clients/[id]/members/[memberId].patch.ts +20 -0
- package/server/api/clients/[id]/modules/[moduleId].put.ts +12 -0
- package/server/api/clients/index.get.ts +8 -0
- package/server/api/clients/index.post.ts +10 -0
- package/server/api/clients/selectable.get.ts +11 -0
- package/server/db/schema/clients.ts +55 -0
- package/server/utils/client-access.ts +23 -0
- package/server/utils/client-email-locale.ts +18 -0
- package/server/utils/client-repository.ts +390 -0
- package/server/utils/client-validation.ts +57 -0
- package/shared/feature.ts +50 -0
- package/shared/types/client.ts +51 -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,407 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { GenericClientDto } from '@nuxt-customer-portal/clients/shared/types/client'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ client: GenericClientDto; refresh: () => Promise<unknown> }>()
|
|
6
|
+
const emit = defineEmits<{ deleted: [] }>()
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const toast = useToast()
|
|
9
|
+
const api = useClients()
|
|
10
|
+
const { clientIntegrations } = usePortalFeatures()
|
|
11
|
+
const busy = ref(false)
|
|
12
|
+
const editing = ref(false)
|
|
13
|
+
const deleting = ref(false)
|
|
14
|
+
const deleteName = ref('')
|
|
15
|
+
const deletion = ref<{ canDelete: boolean; memberCount: number; moduleCount: number; clientName: string } | null>(null)
|
|
16
|
+
const invitationForm = reactive({ email: '', role: 'member' as 'member' | 'admin' | 'owner' })
|
|
17
|
+
const invitationSchema = computed(() =>
|
|
18
|
+
z.object({
|
|
19
|
+
email: z.string().trim().email(t('features.clients.validation.email')),
|
|
20
|
+
role: z.enum(['member', 'admin', 'owner'])
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
onKeyStroke('Escape', () => {
|
|
25
|
+
editing.value = false
|
|
26
|
+
deleting.value = false
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const save = async (input: Record<string, unknown>) => {
|
|
30
|
+
busy.value = true
|
|
31
|
+
try {
|
|
32
|
+
await api.update(props.client.id, input)
|
|
33
|
+
editing.value = false
|
|
34
|
+
await props.refresh()
|
|
35
|
+
toast.add({ title: t('features.clients.saved'), color: 'success' })
|
|
36
|
+
} catch (error) {
|
|
37
|
+
toast.add({ title: t('features.clients.saveFailed'), description: String(error), color: 'error' })
|
|
38
|
+
} finally {
|
|
39
|
+
busy.value = false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const toggleModule = async (moduleId: string, enabled: boolean) => {
|
|
44
|
+
busy.value = true
|
|
45
|
+
try {
|
|
46
|
+
await api.setModule(props.client.id, moduleId, enabled)
|
|
47
|
+
await props.refresh()
|
|
48
|
+
} finally {
|
|
49
|
+
busy.value = false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const toggleArchive = async () => {
|
|
54
|
+
busy.value = true
|
|
55
|
+
try {
|
|
56
|
+
await api.archive(props.client.id, !props.client.archivedAt)
|
|
57
|
+
await props.refresh()
|
|
58
|
+
} finally {
|
|
59
|
+
busy.value = false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const requestDelete = async () => {
|
|
64
|
+
editing.value = false
|
|
65
|
+
deleting.value = !deleting.value
|
|
66
|
+
deleteName.value = ''
|
|
67
|
+
deletion.value = deleting.value ? await api.deletion(props.client.id) : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const confirmDelete = async () => {
|
|
71
|
+
busy.value = true
|
|
72
|
+
try {
|
|
73
|
+
await api.remove(props.client.id, deleteName.value)
|
|
74
|
+
toast.add({ title: t('features.clients.deleted'), color: 'success' })
|
|
75
|
+
emit('deleted')
|
|
76
|
+
} finally {
|
|
77
|
+
busy.value = false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const inviteMember = async () => {
|
|
82
|
+
busy.value = true
|
|
83
|
+
try {
|
|
84
|
+
await api.invite(props.client.id, invitationForm.email.trim(), invitationForm.role)
|
|
85
|
+
invitationForm.email = ''
|
|
86
|
+
invitationForm.role = 'member'
|
|
87
|
+
await props.refresh()
|
|
88
|
+
toast.add({ title: t('features.clients.invited'), color: 'success' })
|
|
89
|
+
} catch {
|
|
90
|
+
toast.add({ title: t('features.clients.inviteFailed'), color: 'error' })
|
|
91
|
+
} finally {
|
|
92
|
+
busy.value = false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const memberToRemove = ref<{ id: string; name: string; email: string } | null>(null)
|
|
97
|
+
const showRemoveMemberConfirmation = ref(false)
|
|
98
|
+
const requestRemoveMember = (member: { id: string; name: string; email: string }) => {
|
|
99
|
+
memberToRemove.value = member
|
|
100
|
+
showRemoveMemberConfirmation.value = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const changeMemberRole = async (memberId: string, role: string) => {
|
|
104
|
+
busy.value = true
|
|
105
|
+
try {
|
|
106
|
+
await api.updateMember(props.client.id, memberId, { role })
|
|
107
|
+
await props.refresh()
|
|
108
|
+
} finally {
|
|
109
|
+
busy.value = false
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const removeMember = async () => {
|
|
114
|
+
if (!memberToRemove.value || busy.value) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const memberId = memberToRemove.value.id
|
|
118
|
+
busy.value = true
|
|
119
|
+
try {
|
|
120
|
+
await api.removeMember(props.client.id, memberId)
|
|
121
|
+
await props.refresh()
|
|
122
|
+
toast.add({ title: t('features.clients.memberRemoved'), color: 'success' })
|
|
123
|
+
} catch {
|
|
124
|
+
toast.add({ title: t('features.clients.removeMemberFailed'), color: 'error' })
|
|
125
|
+
} finally {
|
|
126
|
+
busy.value = false
|
|
127
|
+
memberToRemove.value = null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const toggleEditing = () => {
|
|
132
|
+
editing.value = !editing.value
|
|
133
|
+
deleting.value = false
|
|
134
|
+
}
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<template>
|
|
138
|
+
<div class="space-y-6">
|
|
139
|
+
<header class="flex flex-col gap-3 border-b border-default pb-5 sm:flex-row sm:items-end sm:justify-between">
|
|
140
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
141
|
+
<UAvatar :src="client.avatarLogo || client.logo || undefined" :alt="client.name" size="lg" />
|
|
142
|
+
<div class="min-w-0">
|
|
143
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
144
|
+
<h1 class="truncate text-2xl font-semibold">{{ client.name }}</h1>
|
|
145
|
+
<UBadge :color="client.archivedAt ? 'neutral' : 'success'" variant="subtle">
|
|
146
|
+
{{ t(client.archivedAt ? 'features.clients.archived' : 'features.clients.active') }}
|
|
147
|
+
</UBadge>
|
|
148
|
+
</div>
|
|
149
|
+
<p class="text-sm text-muted">{{ client.officialName }}</p>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<UButton size="sm" variant="outline" icon="i-lucide-pencil" @click="toggleEditing">
|
|
153
|
+
{{ t('features.clients.edit') }}
|
|
154
|
+
</UButton>
|
|
155
|
+
</header>
|
|
156
|
+
|
|
157
|
+
<ClientsClientForm
|
|
158
|
+
v-if="editing"
|
|
159
|
+
:client="client"
|
|
160
|
+
:editing="true"
|
|
161
|
+
:busy="busy"
|
|
162
|
+
@submit="save"
|
|
163
|
+
@cancel="editing = false"
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
<UCard v-else>
|
|
167
|
+
<template #header>
|
|
168
|
+
<h2 class="font-semibold">{{ t('features.clients.profile') }}</h2>
|
|
169
|
+
</template>
|
|
170
|
+
<dl class="grid gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
171
|
+
<div>
|
|
172
|
+
<dt class="text-sm text-muted">{{ t('features.clients.name') }}</dt>
|
|
173
|
+
<dd class="font-medium">{{ client.name }}</dd>
|
|
174
|
+
</div>
|
|
175
|
+
<div>
|
|
176
|
+
<dt class="text-sm text-muted">{{ t('features.clients.officialName') }}</dt>
|
|
177
|
+
<dd>{{ client.officialName }}</dd>
|
|
178
|
+
</div>
|
|
179
|
+
<div>
|
|
180
|
+
<dt class="text-sm text-muted">{{ t('features.clients.slug') }}</dt>
|
|
181
|
+
<dd class="font-mono text-sm">{{ client.slug }}</dd>
|
|
182
|
+
</div>
|
|
183
|
+
<div>
|
|
184
|
+
<dt class="text-sm text-muted">{{ t('features.clients.registrationNumber') }}</dt>
|
|
185
|
+
<dd>{{ client.registrationNumber || '—' }}</dd>
|
|
186
|
+
</div>
|
|
187
|
+
<div>
|
|
188
|
+
<dt class="text-sm text-muted">{{ t('features.clients.vatNumber') }}</dt>
|
|
189
|
+
<dd>{{ client.vatNumber || '—' }}</dd>
|
|
190
|
+
</div>
|
|
191
|
+
<div>
|
|
192
|
+
<dt class="text-sm text-muted">{{ t('features.clients.invoiceEmail') }}</dt>
|
|
193
|
+
<dd>{{ client.invoiceEmail || '—' }}</dd>
|
|
194
|
+
</div>
|
|
195
|
+
<div>
|
|
196
|
+
<dt class="text-sm text-muted">{{ t('features.clients.locale') }}</dt>
|
|
197
|
+
<dd>{{ client.preferredLocale === 'nl' ? 'Nederlands' : 'English' }}</dd>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="sm:col-span-2 lg:col-span-3">
|
|
200
|
+
<dt class="text-sm text-muted">{{ t('features.clients.address') }}</dt>
|
|
201
|
+
<dd class="whitespace-pre-line">{{ client.address || '—' }}</dd>
|
|
202
|
+
</div>
|
|
203
|
+
</dl>
|
|
204
|
+
</UCard>
|
|
205
|
+
|
|
206
|
+
<UCard>
|
|
207
|
+
<template #header>
|
|
208
|
+
<h2 class="font-semibold">{{ t('features.clients.members') }}</h2>
|
|
209
|
+
</template>
|
|
210
|
+
<div class="grid gap-4">
|
|
211
|
+
<UForm
|
|
212
|
+
:state="invitationForm"
|
|
213
|
+
:schema="invitationSchema"
|
|
214
|
+
novalidate
|
|
215
|
+
class="grid items-start gap-2 sm:grid-cols-[minmax(0,1fr)_150px_auto]"
|
|
216
|
+
@submit="inviteMember"
|
|
217
|
+
>
|
|
218
|
+
<UFormField name="email">
|
|
219
|
+
<UInput
|
|
220
|
+
v-model="invitationForm.email"
|
|
221
|
+
type="email"
|
|
222
|
+
:placeholder="t('features.clients.memberEmail')"
|
|
223
|
+
class="w-full"
|
|
224
|
+
/>
|
|
225
|
+
</UFormField>
|
|
226
|
+
<UFormField name="role">
|
|
227
|
+
<USelect v-model="invitationForm.role" :items="['member', 'admin', 'owner']" class="w-full" />
|
|
228
|
+
</UFormField>
|
|
229
|
+
<UButton type="submit" :disabled="!invitationForm.email.trim()" :loading="busy">
|
|
230
|
+
{{ t('features.clients.invite') }}
|
|
231
|
+
</UButton>
|
|
232
|
+
</UForm>
|
|
233
|
+
<div v-if="client.members.length" class="grid gap-2">
|
|
234
|
+
<div
|
|
235
|
+
v-for="item in client.members"
|
|
236
|
+
:key="item.id"
|
|
237
|
+
class="flex flex-col gap-3 rounded-md border border-default p-3 sm:flex-row sm:items-center sm:justify-between"
|
|
238
|
+
>
|
|
239
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
240
|
+
<UAvatar :src="item.image ?? undefined" :alt="item.name" size="sm" />
|
|
241
|
+
<div class="min-w-0">
|
|
242
|
+
<p class="truncate font-medium">{{ item.name }}</p>
|
|
243
|
+
<p class="truncate text-sm text-muted">
|
|
244
|
+
{{ item.email }}<template v-if="item.jobTitle"> · {{ item.jobTitle }}</template>
|
|
245
|
+
</p>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="flex items-center gap-2">
|
|
249
|
+
<USelect
|
|
250
|
+
:model-value="item.role"
|
|
251
|
+
:items="['member', 'admin', 'owner']"
|
|
252
|
+
size="xs"
|
|
253
|
+
@update:model-value="changeMemberRole(item.id, String($event))"
|
|
254
|
+
/>
|
|
255
|
+
<UButton
|
|
256
|
+
icon="i-lucide-trash-2"
|
|
257
|
+
color="error"
|
|
258
|
+
variant="outline"
|
|
259
|
+
size="xs"
|
|
260
|
+
:disabled="busy"
|
|
261
|
+
:aria-label="t('features.clients.removeMember')"
|
|
262
|
+
@click.stop="requestRemoveMember(item)"
|
|
263
|
+
>{{ t('features.clients.removeMember') }}</UButton
|
|
264
|
+
>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
<p v-else class="text-sm text-muted">{{ t('features.clients.noMembers') }}</p>
|
|
269
|
+
|
|
270
|
+
<div class="border-t border-default pt-4">
|
|
271
|
+
<h3 class="mb-3 text-sm font-semibold">
|
|
272
|
+
{{ t('features.clients.pendingInvitations') }} ({{ client.invitations.length }})
|
|
273
|
+
</h3>
|
|
274
|
+
<div v-if="client.invitations.length" class="grid gap-2">
|
|
275
|
+
<div
|
|
276
|
+
v-for="invitation in client.invitations"
|
|
277
|
+
:key="invitation.id"
|
|
278
|
+
class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-default p-3"
|
|
279
|
+
>
|
|
280
|
+
<div class="min-w-0">
|
|
281
|
+
<p class="truncate font-medium">{{ invitation.email }}</p>
|
|
282
|
+
<p class="text-sm text-muted">
|
|
283
|
+
{{
|
|
284
|
+
t('features.clients.invitationExpires', {
|
|
285
|
+
date: new Date(invitation.expiresAt).toLocaleDateString()
|
|
286
|
+
})
|
|
287
|
+
}}
|
|
288
|
+
</p>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="flex items-center gap-2">
|
|
291
|
+
<UBadge color="warning" variant="soft">{{ t('features.clients.invitationPending') }}</UBadge
|
|
292
|
+
><UBadge color="neutral" variant="soft">{{ invitation.role }}</UBadge>
|
|
293
|
+
<InvitationActions
|
|
294
|
+
:endpoint="`/api/clients/${client.id}/invitations/${invitation.id}`"
|
|
295
|
+
:email="invitation.email"
|
|
296
|
+
:role="invitation.role"
|
|
297
|
+
can-edit
|
|
298
|
+
can-revoke
|
|
299
|
+
@refresh="refresh()"
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
<p v-else class="text-sm text-muted">{{ t('features.clients.noPendingInvitations') }}</p>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</UCard>
|
|
308
|
+
|
|
309
|
+
<UCard>
|
|
310
|
+
<template #header>
|
|
311
|
+
<h2 class="font-semibold">{{ t('features.clients.modules') }}</h2>
|
|
312
|
+
</template>
|
|
313
|
+
<div class="grid gap-3">
|
|
314
|
+
<label
|
|
315
|
+
v-for="integration in clientIntegrations"
|
|
316
|
+
:key="integration.moduleId"
|
|
317
|
+
class="flex items-center justify-between rounded-md border border-default p-3"
|
|
318
|
+
>
|
|
319
|
+
<span>{{ t(integration.labelKey) }}</span>
|
|
320
|
+
<USwitch
|
|
321
|
+
:model-value="client.modules.some((item) => item.moduleId === integration.moduleId && item.enabled)"
|
|
322
|
+
:disabled="busy || Boolean(client.archivedAt)"
|
|
323
|
+
@update:model-value="toggleModule(integration.moduleId, $event)"
|
|
324
|
+
/>
|
|
325
|
+
</label>
|
|
326
|
+
</div>
|
|
327
|
+
</UCard>
|
|
328
|
+
|
|
329
|
+
<component
|
|
330
|
+
:is="integration.detailComponent"
|
|
331
|
+
v-for="integration in clientIntegrations.filter(
|
|
332
|
+
(item) =>
|
|
333
|
+
item.detailComponent && client.modules.some((module) => module.moduleId === item.moduleId && module.enabled)
|
|
334
|
+
)"
|
|
335
|
+
:key="integration.moduleId"
|
|
336
|
+
:client="client"
|
|
337
|
+
/>
|
|
338
|
+
|
|
339
|
+
<UCard>
|
|
340
|
+
<template #header>
|
|
341
|
+
<h2 class="font-semibold">{{ t('features.clients.lifecycle') }}</h2>
|
|
342
|
+
</template>
|
|
343
|
+
<div class="flex flex-wrap justify-between gap-3">
|
|
344
|
+
<UButton
|
|
345
|
+
color="neutral"
|
|
346
|
+
variant="outline"
|
|
347
|
+
:icon="client.archivedAt ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
|
|
348
|
+
:loading="busy"
|
|
349
|
+
@click="toggleArchive"
|
|
350
|
+
>
|
|
351
|
+
{{ t(client.archivedAt ? 'features.clients.restore' : 'features.clients.archive') }}
|
|
352
|
+
</UButton>
|
|
353
|
+
<UButton color="error" variant="outline" icon="i-lucide-trash-2" @click="requestDelete">
|
|
354
|
+
{{ t('features.clients.delete') }}
|
|
355
|
+
</UButton>
|
|
356
|
+
</div>
|
|
357
|
+
</UCard>
|
|
358
|
+
|
|
359
|
+
<UCard v-if="deleting" class="border-error">
|
|
360
|
+
<template #header>
|
|
361
|
+
<h2 class="font-semibold text-error">
|
|
362
|
+
{{ t('features.clients.deleteTitle', { name: client.name }) }}
|
|
363
|
+
</h2>
|
|
364
|
+
</template>
|
|
365
|
+
<div class="space-y-4">
|
|
366
|
+
<p class="text-sm">
|
|
367
|
+
{{
|
|
368
|
+
deletion?.canDelete
|
|
369
|
+
? t('features.clients.deleteDescription')
|
|
370
|
+
: t('features.clients.deleteBlocked', {
|
|
371
|
+
members: deletion?.memberCount ?? 0,
|
|
372
|
+
modules: deletion?.moduleCount ?? 0
|
|
373
|
+
})
|
|
374
|
+
}}
|
|
375
|
+
</p>
|
|
376
|
+
<UFormField v-if="deletion?.canDelete" :label="t('features.clients.typeName', { name: client.name })">
|
|
377
|
+
<UInput v-model="deleteName" />
|
|
378
|
+
</UFormField>
|
|
379
|
+
<div class="flex justify-end gap-2">
|
|
380
|
+
<UButton color="neutral" variant="outline" @click="deleting = false">
|
|
381
|
+
{{ t('features.clients.cancel') }}
|
|
382
|
+
</UButton>
|
|
383
|
+
<UButton
|
|
384
|
+
v-if="deletion?.canDelete"
|
|
385
|
+
color="error"
|
|
386
|
+
icon="i-lucide-trash-2"
|
|
387
|
+
:disabled="deleteName !== client.name"
|
|
388
|
+
:loading="busy"
|
|
389
|
+
@click="confirmDelete"
|
|
390
|
+
>
|
|
391
|
+
{{ t('features.clients.delete') }}
|
|
392
|
+
</UButton>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
</UCard>
|
|
396
|
+
<ConfirmationModal
|
|
397
|
+
v-model:open="showRemoveMemberConfirmation"
|
|
398
|
+
title="features.clients.removeMember"
|
|
399
|
+
message="features.clients.confirmRemoveMember"
|
|
400
|
+
:message-params="{ name: memberToRemove?.name ?? '', email: memberToRemove?.email ?? '', client: client.name }"
|
|
401
|
+
confirm-text="features.clients.removeMember"
|
|
402
|
+
confirm-color="error"
|
|
403
|
+
@confirm="removeMember"
|
|
404
|
+
@cancel="memberToRemove = null"
|
|
405
|
+
/>
|
|
406
|
+
</div>
|
|
407
|
+
</template>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { GenericClientDto } from '@nuxt-customer-portal/clients/shared/types/client'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ client?: GenericClientDto | null; busy?: boolean; editing?: boolean }>()
|
|
6
|
+
const emit = defineEmits<{ submit: [value: Record<string, unknown>]; cancel: [] }>()
|
|
7
|
+
const { t } = useI18n()
|
|
8
|
+
const form = reactive({
|
|
9
|
+
name: '',
|
|
10
|
+
slug: '',
|
|
11
|
+
officialName: '',
|
|
12
|
+
address: '',
|
|
13
|
+
registrationNumber: '',
|
|
14
|
+
vatNumber: '',
|
|
15
|
+
invoiceEmail: '',
|
|
16
|
+
preferredLocale: 'nl' as 'nl' | 'en'
|
|
17
|
+
})
|
|
18
|
+
const schema = computed(() =>
|
|
19
|
+
z.object({
|
|
20
|
+
name: z.string().trim().min(2, t('features.clients.validation.name')).max(160),
|
|
21
|
+
slug: props.editing
|
|
22
|
+
? z.string()
|
|
23
|
+
: z
|
|
24
|
+
.string()
|
|
25
|
+
.trim()
|
|
26
|
+
.min(1, t('features.clients.validation.slug'))
|
|
27
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, t('features.clients.validation.slug')),
|
|
28
|
+
officialName: z.string().trim().min(2, t('features.clients.validation.officialName')).max(200),
|
|
29
|
+
address: z.string().trim().max(1000),
|
|
30
|
+
registrationNumber: z.string().trim().max(200),
|
|
31
|
+
vatNumber: z.string().trim().max(100),
|
|
32
|
+
invoiceEmail: z.union([z.literal(''), z.string().trim().email(t('features.clients.validation.email')).max(320)]),
|
|
33
|
+
preferredLocale: z.enum(['nl', 'en'])
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
const reset = () =>
|
|
37
|
+
Object.assign(
|
|
38
|
+
form,
|
|
39
|
+
props.client
|
|
40
|
+
? {
|
|
41
|
+
name: props.client.name,
|
|
42
|
+
slug: props.client.slug,
|
|
43
|
+
officialName: props.client.officialName,
|
|
44
|
+
address: props.client.address,
|
|
45
|
+
registrationNumber: props.client.registrationNumber ?? '',
|
|
46
|
+
vatNumber: props.client.vatNumber ?? '',
|
|
47
|
+
invoiceEmail: props.client.invoiceEmail ?? '',
|
|
48
|
+
preferredLocale: props.client.preferredLocale
|
|
49
|
+
}
|
|
50
|
+
: {
|
|
51
|
+
name: '',
|
|
52
|
+
slug: '',
|
|
53
|
+
officialName: '',
|
|
54
|
+
address: '',
|
|
55
|
+
registrationNumber: '',
|
|
56
|
+
vatNumber: '',
|
|
57
|
+
invoiceEmail: '',
|
|
58
|
+
preferredLocale: 'nl'
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
watch(() => props.client, reset, { immediate: true })
|
|
62
|
+
const submit = () =>
|
|
63
|
+
emit('submit', {
|
|
64
|
+
name: form.name.trim(),
|
|
65
|
+
...(props.editing ? {} : { slug: form.slug.trim() }),
|
|
66
|
+
officialName: form.officialName.trim(),
|
|
67
|
+
address: form.address.trim(),
|
|
68
|
+
registrationNumber: form.registrationNumber.trim() || null,
|
|
69
|
+
vatNumber: form.vatNumber.trim() || null,
|
|
70
|
+
invoiceEmail: form.invoiceEmail.trim().toLowerCase() || null,
|
|
71
|
+
preferredLocale: form.preferredLocale
|
|
72
|
+
})
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<UCard class="scroll-mt-24">
|
|
77
|
+
<template #header>
|
|
78
|
+
<div class="flex items-center justify-between gap-3">
|
|
79
|
+
<h2 class="font-semibold">{{ t(editing ? 'features.clients.editTitle' : 'features.clients.createTitle') }}</h2>
|
|
80
|
+
<UButton
|
|
81
|
+
type="button"
|
|
82
|
+
color="neutral"
|
|
83
|
+
variant="ghost"
|
|
84
|
+
icon="i-lucide-x"
|
|
85
|
+
:aria-label="t('features.clients.close')"
|
|
86
|
+
@click="emit('cancel')"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
90
|
+
<UForm :state="form" :schema="schema" class="space-y-4" @submit="submit">
|
|
91
|
+
<div class="grid gap-4 md:grid-cols-2">
|
|
92
|
+
<UFormField name="name" :label="t('features.clients.name')" required>
|
|
93
|
+
<UInput v-model="form.name" class="w-full" />
|
|
94
|
+
</UFormField>
|
|
95
|
+
<UFormField name="slug" :label="t('features.clients.slug')" :required="!editing">
|
|
96
|
+
<UInput v-model="form.slug" :disabled="editing" class="w-full" />
|
|
97
|
+
</UFormField>
|
|
98
|
+
<UFormField name="officialName" :label="t('features.clients.officialName')" required>
|
|
99
|
+
<UInput v-model="form.officialName" class="w-full" />
|
|
100
|
+
</UFormField>
|
|
101
|
+
<UFormField name="invoiceEmail" :label="t('features.clients.invoiceEmail')">
|
|
102
|
+
<UInput v-model="form.invoiceEmail" type="email" class="w-full" />
|
|
103
|
+
</UFormField>
|
|
104
|
+
<UFormField name="registrationNumber" :label="t('features.clients.registrationNumber')">
|
|
105
|
+
<UInput v-model="form.registrationNumber" class="w-full" />
|
|
106
|
+
</UFormField>
|
|
107
|
+
<UFormField name="vatNumber" :label="t('features.clients.vatNumber')">
|
|
108
|
+
<UInput v-model="form.vatNumber" class="w-full" />
|
|
109
|
+
</UFormField>
|
|
110
|
+
<UFormField name="preferredLocale" :label="t('features.clients.locale')">
|
|
111
|
+
<USelect
|
|
112
|
+
v-model="form.preferredLocale"
|
|
113
|
+
:items="[
|
|
114
|
+
{ label: 'Nederlands', value: 'nl' },
|
|
115
|
+
{ label: 'English', value: 'en' }
|
|
116
|
+
]"
|
|
117
|
+
value-key="value"
|
|
118
|
+
class="w-full"
|
|
119
|
+
/>
|
|
120
|
+
</UFormField>
|
|
121
|
+
</div>
|
|
122
|
+
<UFormField name="address" :label="t('features.clients.address')">
|
|
123
|
+
<UTextarea v-model="form.address" class="w-full" />
|
|
124
|
+
</UFormField>
|
|
125
|
+
<div class="flex justify-end gap-2">
|
|
126
|
+
<UButton type="button" color="neutral" variant="outline" @click="emit('cancel')">
|
|
127
|
+
{{ t('features.clients.cancel') }}
|
|
128
|
+
</UButton>
|
|
129
|
+
<UButton type="submit" :loading="busy" icon="i-lucide-save">
|
|
130
|
+
{{ t(editing ? 'features.clients.save' : 'features.clients.create') }}
|
|
131
|
+
</UButton>
|
|
132
|
+
</div>
|
|
133
|
+
</UForm>
|
|
134
|
+
</UCard>
|
|
135
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { GenericClientDto } from '@nuxt-customer-portal/clients/shared/types/client'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ modelValue?: string; moduleId?: string; label?: string; required?: boolean }>()
|
|
5
|
+
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
6
|
+
const { data: clients, status } = await useFetch<GenericClientDto[]>('/api/clients/selectable', {
|
|
7
|
+
query: computed(() => ({ moduleId: props.moduleId }))
|
|
8
|
+
})
|
|
9
|
+
const items = computed(() => (clients.value ?? []).map((client) => ({ label: client.name, value: client.id })))
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<UFormField :label="label" name="clientOrganizationId" :required="required">
|
|
14
|
+
<USelect
|
|
15
|
+
:model-value="modelValue"
|
|
16
|
+
:items="items"
|
|
17
|
+
value-key="value"
|
|
18
|
+
class="w-full"
|
|
19
|
+
size="lg"
|
|
20
|
+
:loading="status === 'pending'"
|
|
21
|
+
@update:model-value="emit('update:modelValue', String($event))"
|
|
22
|
+
/>
|
|
23
|
+
</UFormField>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GenericClientDto, ClientListResponse } from '@nuxt-customer-portal/clients/shared/types/client'
|
|
2
|
+
|
|
3
|
+
export const useClients = () => ({
|
|
4
|
+
list: (query: Record<string, string | number | undefined>) => $fetch<ClientListResponse>('/api/clients', { query }),
|
|
5
|
+
get: (id: string) => $fetch<GenericClientDto>(`/api/clients/${id}`),
|
|
6
|
+
create: (input: Record<string, unknown>) => $fetch<GenericClientDto>('/api/clients', { method: 'POST', body: input }),
|
|
7
|
+
update: (id: string, input: Record<string, unknown>) =>
|
|
8
|
+
$fetch<GenericClientDto>(`/api/clients/${id}`, { method: 'PATCH', body: input }),
|
|
9
|
+
archive: (id: string, archived: boolean) =>
|
|
10
|
+
$fetch<GenericClientDto>(`/api/clients/${id}/archive`, { method: 'PATCH', body: { archived } }),
|
|
11
|
+
setModule: (id: string, moduleId: string, enabled: boolean) =>
|
|
12
|
+
$fetch<GenericClientDto>(`/api/clients/${id}/modules/${moduleId}`, { method: 'PUT', body: { enabled } }),
|
|
13
|
+
invite: (id: string, email: string, role: string) =>
|
|
14
|
+
$fetch(`/api/clients/${id}/invitations`, { method: 'POST', body: { email, role } }),
|
|
15
|
+
updateMember: (id: string, memberId: string, input: Record<string, unknown>) =>
|
|
16
|
+
$fetch(`/api/clients/${id}/members/${memberId}`, { method: 'PATCH', body: input }),
|
|
17
|
+
removeMember: (id: string, memberId: string) =>
|
|
18
|
+
$fetch(`/api/clients/${id}/members/${memberId}`, { method: 'DELETE' as never }),
|
|
19
|
+
deletion: (id: string) =>
|
|
20
|
+
$fetch<{ canDelete: boolean; memberCount: number; moduleCount: number; clientName: string }>(
|
|
21
|
+
`/api/clients/${id}/deletion`
|
|
22
|
+
),
|
|
23
|
+
remove: (id: string, clientName: string) =>
|
|
24
|
+
$fetch(`/api/clients/${id}`, { method: 'DELETE' as never, body: { clientName } })
|
|
25
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const route = useRoute()
|
|
3
|
+
const { t } = useI18n()
|
|
4
|
+
const api = useClients()
|
|
5
|
+
const clientId = computed(() => String(route.params.id))
|
|
6
|
+
const returnTo = computed(() => {
|
|
7
|
+
const value = Array.isArray(route.query.returnTo) ? route.query.returnTo[0] : route.query.returnTo
|
|
8
|
+
return typeof value === 'string' && (value === '/clients' || value.startsWith('/clients?')) ? value : '/clients'
|
|
9
|
+
})
|
|
10
|
+
const {
|
|
11
|
+
data: client,
|
|
12
|
+
pending,
|
|
13
|
+
error,
|
|
14
|
+
refresh
|
|
15
|
+
} = await useAsyncData(`client-${clientId.value}`, () => api.get(clientId.value))
|
|
16
|
+
|
|
17
|
+
useSeoMeta({
|
|
18
|
+
title: computed(() => client.value?.name ?? t('features.clients.title'))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const handleDeleted = async () => {
|
|
22
|
+
await navigateTo(returnTo.value)
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="h-full min-h-0 overflow-y-auto">
|
|
28
|
+
<div class="mx-auto flex max-w-[1440px] flex-col gap-4 p-4 sm:p-6 lg:p-8">
|
|
29
|
+
<UButton :to="returnTo" variant="link" color="neutral" icon="i-lucide-arrow-left" class="w-fit px-0">
|
|
30
|
+
{{ t('features.clients.backToClients') }}
|
|
31
|
+
</UButton>
|
|
32
|
+
|
|
33
|
+
<div v-if="pending" class="flex justify-center py-12" role="status">
|
|
34
|
+
<UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" />
|
|
35
|
+
<span class="sr-only">{{ t('features.clients.loading') }}</span>
|
|
36
|
+
</div>
|
|
37
|
+
<UAlert
|
|
38
|
+
v-else-if="error"
|
|
39
|
+
color="error"
|
|
40
|
+
icon="i-lucide-circle-alert"
|
|
41
|
+
:title="t('features.clients.notFound')"
|
|
42
|
+
variant="outline"
|
|
43
|
+
/>
|
|
44
|
+
<ClientsClientDetail v-else-if="client" :client="client" :refresh="refresh" @deleted="handleDeleted" />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|