@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +3 -0
  4. package/app/components/ClientsClientDetail.vue +407 -0
  5. package/app/components/ClientsClientForm.vue +135 -0
  6. package/app/components/ClientsClientPicker.vue +24 -0
  7. package/app/composables/useClients.ts +25 -0
  8. package/app/pages/clients/[id].vue +47 -0
  9. package/app/pages/clients/index.vue +265 -0
  10. package/app/plugins/clients-feature.ts +3 -0
  11. package/i18n/locales/en.json +74 -0
  12. package/i18n/locales/nl.json +74 -0
  13. package/migrations/0000_baseline.sql +33 -0
  14. package/nuxt.config.ts +17 -0
  15. package/package.json +55 -0
  16. package/portal.manifest.mjs +8 -0
  17. package/server/api/clients/[id]/archive/index.patch.ts +11 -0
  18. package/server/api/clients/[id]/deletion/index.get.ts +7 -0
  19. package/server/api/clients/[id]/index.delete.ts +10 -0
  20. package/server/api/clients/[id]/index.get.ts +12 -0
  21. package/server/api/clients/[id]/index.patch.ts +10 -0
  22. package/server/api/clients/[id]/invitations/[invitationId].patch.ts +8 -0
  23. package/server/api/clients/[id]/invitations/index.post.ts +66 -0
  24. package/server/api/clients/[id]/members/[memberId].delete.ts +29 -0
  25. package/server/api/clients/[id]/members/[memberId].patch.ts +20 -0
  26. package/server/api/clients/[id]/modules/[moduleId].put.ts +12 -0
  27. package/server/api/clients/index.get.ts +8 -0
  28. package/server/api/clients/index.post.ts +10 -0
  29. package/server/api/clients/selectable.get.ts +11 -0
  30. package/server/db/schema/clients.ts +55 -0
  31. package/server/utils/client-access.ts +23 -0
  32. package/server/utils/client-email-locale.ts +18 -0
  33. package/server/utils/client-repository.ts +390 -0
  34. package/server/utils/client-validation.ts +57 -0
  35. package/shared/feature.ts +50 -0
  36. package/shared/types/client.ts +51 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @nuxt-customer-portal/clients
2
+
3
+ ## 0.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @nuxt-customer-portal/core@0.3.0
8
+ - @nuxt-customer-portal/ui@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,3 @@
1
+ # Clients layer
2
+
3
+ Owns CLIENT organizations, business profiles, memberships, invitations, archival, and generic feature-module activations. Feature layers depend on its public server contracts instead of implementing their own client directories.
@@ -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>