@nuxt-customer-portal/clients 0.3.3 → 0.4.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 (47) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -0
  3. package/app/components/ClientsClientDetail.vue +38 -16
  4. package/app/components/ClientsClientForm.vue +134 -21
  5. package/app/components/ClientsClientPicker.vue +7 -1
  6. package/app/components/ClientsPersonalAddress.vue +58 -0
  7. package/app/components/ClientsPersonalProfile.vue +46 -0
  8. package/app/components/ClientsPrivateInvitation.vue +110 -0
  9. package/app/composables/useClients.ts +21 -0
  10. package/app/middleware/personal-onboarding.global.ts +14 -0
  11. package/app/pages/clients/[id].vue +7 -1
  12. package/app/pages/clients/index.vue +91 -8
  13. package/app/pages/personal-onboarding.vue +79 -0
  14. package/app/plugins/clients-feature.ts +10 -2
  15. package/i18n/locales/en.json +28 -2
  16. package/i18n/locales/nl.json +28 -2
  17. package/migrations/0001_person_clients.sql +37 -0
  18. package/migrations/0002_person_names.sql +18 -0
  19. package/nuxt.config.ts +7 -1
  20. package/package.json +3 -3
  21. package/portal.manifest.mjs +1 -1
  22. package/server/api/admin/private-client-invitations/index.get.ts +27 -0
  23. package/server/api/admin/private-client-invitations/index.post.ts +137 -0
  24. package/server/api/client-account.get.ts +12 -0
  25. package/server/api/clients/[id]/index.patch.ts +5 -1
  26. package/server/api/clients/[id]/invitations/[invitationId]/resend.post.ts +78 -0
  27. package/server/api/clients/[id]/invitations/[invitationId].patch.ts +2 -2
  28. package/server/api/clients/[id]/invitations/index.post.ts +35 -12
  29. package/server/api/clients/[id]/members/[memberId].delete.ts +2 -2
  30. package/server/api/clients/[id]/members/[memberId].patch.ts +6 -2
  31. package/server/api/clients/index.post.ts +5 -1
  32. package/server/api/personal-client/address.get.ts +15 -0
  33. package/server/api/personal-client/address.patch.ts +32 -0
  34. package/server/api/personal-client.get.ts +43 -0
  35. package/server/api/personal-client.post.ts +77 -0
  36. package/server/db/schema/clients.ts +29 -3
  37. package/server/middleware/00-client-configuration.ts +5 -0
  38. package/server/plugins/client-accounts.ts +114 -0
  39. package/server/utils/client-access.ts +9 -0
  40. package/server/utils/client-configuration.ts +37 -0
  41. package/server/utils/client-repository.ts +132 -63
  42. package/server/utils/client-validation.ts +33 -5
  43. package/server/utils/purchase-client.ts +90 -0
  44. package/shared/feature.ts +16 -0
  45. package/shared/personal-onboarding.ts +6 -0
  46. package/shared/private-client-invitation.ts +11 -0
  47. package/shared/types/client.ts +5 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @nuxt-customer-portal/clients
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 59c8921: Support organization and personal clients with optional verified self-registration, single-person access safeguards, and shared provider, client, and user timezone preferences. Preserve existing B2B defaults, module references, invoice history, and Timesheets timezone behavior.
8
+
9
+ Collect and store first and last names during signup and personal onboarding. Route ordinary personal registration and verified users without memberships or pending invitations through onboarding.
10
+
11
+ Allow users to edit first name, last name, and an independent display name in profile settings.
12
+
13
+ Synchronize display-name edits with the linked private client name and current billing name in the same transaction, preserving organizations and historical invoices.
14
+
15
+ Let private clients edit their own billing address in profile settings, independently of the active organization, with server-side ownership checks.
16
+
17
+ Private clients use their own invoice email without contact persons; hide contact management and reject private-client contact creation or selection server-side.
18
+
19
+ Add a B2C-only Invite private client action to the Users page, supporting new and existing private clients with atomic record creation, request retry protection, and pending invitation recovery when email delivery fails.
20
+
21
+ - 59c8921: Add the Products store module and product invoice integration: translated digital products and services, secret catalog API keys, Stripe one-time checkout, guest invitations, purchaser-only delivery, exact multi-currency invoices, and refund credit notes. Add narrowly scoped external authentication routes, API documentation contracts, and configurable module activation. Existing stores remain closed until explicitly configured and enabled.
22
+
23
+ ### Patch Changes
24
+
25
+ - 59c8921: Allow SaaS administrators to configure allowed client types and personal self-registration in Portal Settings. Apply saved preferences to server authorization, onboarding, client forms and navigation, preserving configuration defaults for existing portals. Prevent disabling client types that still have records, including during concurrent creation.
26
+ - Updated dependencies [59c8921]
27
+ - Updated dependencies [59c8921]
28
+ - Updated dependencies [59c8921]
29
+ - Updated dependencies [59c8921]
30
+ - Updated dependencies [59c8921]
31
+ - Updated dependencies [59c8921]
32
+ - Updated dependencies [59c8921]
33
+ - Updated dependencies [59c8921]
34
+ - Updated dependencies [59c8921]
35
+ - @nuxt-customer-portal/core@0.4.0
36
+ - @nuxt-customer-portal/ui@0.4.0
37
+
3
38
  ## 0.3.3
4
39
 
5
40
  ### Patch Changes
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
1
  # Clients layer
2
2
 
3
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.
4
+
5
+ ## Business and personal clients
6
+
7
+ The default configuration remains B2B-only. Enable `clients.allowedTypes: ['organization', 'person']` and optionally `clients.personalSelfRegistration: true` for mixed portals. See [configuration, access, timezone contracts, and migration guidance](../../docs/guides/business-and-personal-clients.md).
@@ -2,14 +2,14 @@
2
2
  import { z } from 'zod'
3
3
  import type { GenericClientDto } from '@nuxt-customer-portal/clients/shared/types/client'
4
4
 
5
- const props = defineProps<{ client: GenericClientDto; refresh: () => Promise<unknown> }>()
5
+ const props = defineProps<{ client: GenericClientDto; refresh: () => Promise<unknown>; startEditing?: boolean }>()
6
6
  const emit = defineEmits<{ deleted: [] }>()
7
7
  const { t } = useI18n()
8
8
  const toast = useToast()
9
9
  const api = useClients()
10
10
  const { clientIntegrations } = usePortalFeatures()
11
11
  const busy = ref(false)
12
- const editing = ref(false)
12
+ const editing = ref(props.startEditing ?? false)
13
13
  const deleting = ref(false)
14
14
  const deleteName = ref('')
15
15
  const deletion = ref<{ canDelete: boolean; memberCount: number; moduleCount: number; clientName: string } | null>(null)
@@ -146,7 +146,7 @@ const toggleEditing = () => {
146
146
  {{ t(client.archivedAt ? 'features.clients.archived' : 'features.clients.active') }}
147
147
  </UBadge>
148
148
  </div>
149
- <p class="text-sm text-muted">{{ client.officialName }}</p>
149
+ <p v-if="client.officialName !== client.name" class="text-sm text-muted">{{ client.officialName }}</p>
150
150
  </div>
151
151
  </div>
152
152
  <UButton size="sm" variant="outline" icon="i-lucide-pencil" @click="toggleEditing">
@@ -168,23 +168,31 @@ const toggleEditing = () => {
168
168
  <h2 class="font-semibold">{{ t('features.clients.profile') }}</h2>
169
169
  </template>
170
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.clientType') }}</dt>
173
+ <dd>{{ t(`features.clients.types.${client.clientType}`) }}</dd>
174
+ </div>
175
+ <div>
176
+ <dt class="text-sm text-muted">{{ t('timezones.label') }}</dt>
177
+ <dd>{{ client.timezone || t('timezones.inherit', { timezone: client.schedulingTimezone }) }}</dd>
178
+ </div>
171
179
  <div>
172
180
  <dt class="text-sm text-muted">{{ t('features.clients.name') }}</dt>
173
181
  <dd class="font-medium">{{ client.name }}</dd>
174
182
  </div>
175
- <div>
183
+ <div v-if="client.clientType === 'organization'">
176
184
  <dt class="text-sm text-muted">{{ t('features.clients.officialName') }}</dt>
177
185
  <dd>{{ client.officialName }}</dd>
178
186
  </div>
179
- <div>
187
+ <div v-if="client.clientType === 'organization'">
180
188
  <dt class="text-sm text-muted">{{ t('features.clients.slug') }}</dt>
181
189
  <dd class="font-mono text-sm">{{ client.slug }}</dd>
182
190
  </div>
183
- <div>
191
+ <div v-if="client.clientType === 'organization'">
184
192
  <dt class="text-sm text-muted">{{ t('features.clients.registrationNumber') }}</dt>
185
193
  <dd>{{ client.registrationNumber || '—' }}</dd>
186
194
  </div>
187
- <div>
195
+ <div v-if="client.clientType === 'organization'">
188
196
  <dt class="text-sm text-muted">{{ t('features.clients.vatNumber') }}</dt>
189
197
  <dd>{{ client.vatNumber || '—' }}</dd>
190
198
  </div>
@@ -203,16 +211,24 @@ const toggleEditing = () => {
203
211
  </dl>
204
212
  </UCard>
205
213
 
206
- <UCard>
214
+ <UCard v-if="client.clientType !== 'person' || !client.members.length">
207
215
  <template #header>
208
- <h2 class="font-semibold">{{ t('features.clients.members') }}</h2>
216
+ <h2 class="font-semibold">
217
+ {{ t(client.clientType === 'person' ? 'features.clients.invitePrivateClient' : 'features.clients.members') }}
218
+ </h2>
209
219
  </template>
210
220
  <div class="grid gap-4">
211
221
  <UForm
222
+ v-if="client.clientType !== 'person' || !client.invitations.length"
212
223
  :state="invitationForm"
213
224
  :schema="invitationSchema"
214
225
  novalidate
215
- class="grid items-start gap-2 sm:grid-cols-[minmax(0,1fr)_150px_auto]"
226
+ :class="[
227
+ 'grid items-start gap-2',
228
+ client.clientType === 'person'
229
+ ? 'sm:grid-cols-[minmax(0,1fr)_auto]'
230
+ : 'sm:grid-cols-[minmax(0,1fr)_150px_auto]'
231
+ ]"
216
232
  @submit="inviteMember"
217
233
  >
218
234
  <UFormField name="email">
@@ -223,7 +239,7 @@ const toggleEditing = () => {
223
239
  class="w-full"
224
240
  />
225
241
  </UFormField>
226
- <UFormField name="role">
242
+ <UFormField v-if="client.clientType !== 'person'" name="role">
227
243
  <USelect v-model="invitationForm.role" :items="['member', 'admin', 'owner']" class="w-full" />
228
244
  </UFormField>
229
245
  <UButton type="submit" :disabled="!invitationForm.email.trim()" :loading="busy">
@@ -247,6 +263,7 @@ const toggleEditing = () => {
247
263
  </div>
248
264
  <div class="flex items-center gap-2">
249
265
  <USelect
266
+ v-if="client.clientType !== 'person'"
250
267
  :model-value="item.role"
251
268
  :items="['member', 'admin', 'owner']"
252
269
  size="xs"
@@ -265,9 +282,11 @@ const toggleEditing = () => {
265
282
  </div>
266
283
  </div>
267
284
  </div>
268
- <p v-else class="text-sm text-muted">{{ t('features.clients.noMembers') }}</p>
285
+ <p v-else-if="client.clientType !== 'person'" class="text-sm text-muted">
286
+ {{ t('features.clients.noMembers') }}
287
+ </p>
269
288
 
270
- <div class="border-t border-default pt-4">
289
+ <div v-if="client.clientType !== 'person' || client.invitations.length">
271
290
  <h3 class="mb-3 text-sm font-semibold">
272
291
  {{ t('features.clients.pendingInvitations') }} ({{ client.invitations.length }})
273
292
  </h3>
@@ -289,12 +308,15 @@ const toggleEditing = () => {
289
308
  </div>
290
309
  <div class="flex items-center gap-2">
291
310
  <UBadge color="warning" variant="soft">{{ t('features.clients.invitationPending') }}</UBadge
292
- ><UBadge color="neutral" variant="soft">{{ invitation.role }}</UBadge>
311
+ ><UBadge v-if="client.clientType !== 'person'" color="neutral" variant="soft">{{
312
+ invitation.role
313
+ }}</UBadge>
293
314
  <InvitationActions
294
315
  :endpoint="`/api/clients/${client.id}/invitations/${invitation.id}`"
295
316
  :email="invitation.email"
296
317
  :role="invitation.role"
297
- can-edit
318
+ :can-edit="client.clientType !== 'person'"
319
+ can-resend
298
320
  can-revoke
299
321
  @refresh="refresh()"
300
322
  />
@@ -394,6 +416,7 @@ const toggleEditing = () => {
394
416
  </div>
395
417
  </UCard>
396
418
  <ConfirmationModal
419
+ v-if="showRemoveMemberConfirmation"
397
420
  v-model:open="showRemoveMemberConfirmation"
398
421
  title="features.clients.removeMember"
399
422
  message="features.clients.confirmRemoveMember"
@@ -401,7 +424,6 @@ const toggleEditing = () => {
401
424
  confirm-text="features.clients.removeMember"
402
425
  confirm-color="error"
403
426
  @confirm="removeMember"
404
- @cancel="memberToRemove = null"
405
427
  />
406
428
  </div>
407
429
  </template>
@@ -1,12 +1,20 @@
1
1
  <script setup lang="ts">
2
2
  import { z } from 'zod'
3
+ import { isValidTimezone } from '@nuxt-customer-portal/core/shared/timezone'
3
4
  import type { GenericClientDto } from '@nuxt-customer-portal/clients/shared/types/client'
4
5
 
5
6
  const props = defineProps<{ client?: GenericClientDto | null; busy?: boolean; editing?: boolean }>()
6
7
  const emit = defineEmits<{ submit: [value: Record<string, unknown>]; cancel: [] }>()
7
8
  const { t } = useI18n()
9
+ const configuration = useClientConfiguration()
10
+ const allowedTypes = computed(() => configuration.value.allowedTypes ?? ['organization'])
11
+ const { data: timezones } = await useFetch<{ providerTimezone: string }>('/api/timezones')
8
12
  const form = reactive({
13
+ clientType: (allowedTypes.value[0] ?? 'organization') as 'organization' | 'person',
14
+ timezone: null as string | null,
9
15
  name: '',
16
+ firstName: '',
17
+ lastName: '',
10
18
  slug: '',
11
19
  officialName: '',
12
20
  address: '',
@@ -15,17 +23,35 @@ const form = reactive({
15
23
  invoiceEmail: '',
16
24
  preferredLocale: 'nl' as 'nl' | 'en'
17
25
  })
26
+ const displayNameCustomized = ref(false)
18
27
  const schema = computed(() =>
19
28
  z.object({
29
+ clientType: z.enum(['organization', 'person']),
30
+ timezone: z
31
+ .string()
32
+ .nullable()
33
+ .refine((value) => value === null || isValidTimezone(value), t('timezones.invalid')),
20
34
  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),
35
+ firstName:
36
+ form.clientType === 'person'
37
+ ? z.string().trim().min(1, t('features.clients.validation.firstName')).max(80)
38
+ : z.string(),
39
+ lastName:
40
+ form.clientType === 'person'
41
+ ? z.string().trim().min(1, t('features.clients.validation.lastName')).max(80)
42
+ : z.string(),
43
+ slug:
44
+ props.editing || form.clientType === 'person'
45
+ ? z.string()
46
+ : z
47
+ .string()
48
+ .trim()
49
+ .min(1, t('features.clients.validation.slug'))
50
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, t('features.clients.validation.slug')),
51
+ officialName:
52
+ form.clientType === 'person'
53
+ ? z.string()
54
+ : z.string().trim().min(2, t('features.clients.validation.officialName')).max(200),
29
55
  address: z.string().trim().max(1000),
30
56
  registrationNumber: z.string().trim().max(200),
31
57
  vatNumber: z.string().trim().max(100),
@@ -38,7 +64,11 @@ const reset = () =>
38
64
  form,
39
65
  props.client
40
66
  ? {
67
+ clientType: props.client.clientType,
68
+ timezone: props.client.timezone,
41
69
  name: props.client.name,
70
+ firstName: props.client.firstName ?? '',
71
+ lastName: props.client.lastName ?? '',
42
72
  slug: props.client.slug,
43
73
  officialName: props.client.officialName,
44
74
  address: props.client.address,
@@ -48,7 +78,11 @@ const reset = () =>
48
78
  preferredLocale: props.client.preferredLocale
49
79
  }
50
80
  : {
81
+ clientType: allowedTypes.value[0] ?? 'organization',
82
+ timezone: null,
51
83
  name: '',
84
+ firstName: '',
85
+ lastName: '',
52
86
  slug: '',
53
87
  officialName: '',
54
88
  address: '',
@@ -58,15 +92,43 @@ const reset = () =>
58
92
  preferredLocale: 'nl'
59
93
  }
60
94
  )
61
- watch(() => props.client, reset, { immediate: true })
95
+ watch(
96
+ () => props.client,
97
+ () => {
98
+ reset()
99
+ displayNameCustomized.value = Boolean(props.client)
100
+ },
101
+ { immediate: true }
102
+ )
103
+ watch(
104
+ () => [form.firstName, form.lastName],
105
+ () => {
106
+ if (form.clientType === 'person' && !displayNameCustomized.value) {
107
+ form.name = [form.firstName.trim(), form.lastName.trim()].filter(Boolean).join(' ')
108
+ }
109
+ }
110
+ )
111
+ watch(
112
+ () => form.clientType,
113
+ (type) => {
114
+ if (!props.editing && type === 'person') {
115
+ displayNameCustomized.value = false
116
+ form.name = [form.firstName.trim(), form.lastName.trim()].filter(Boolean).join(' ')
117
+ }
118
+ }
119
+ )
62
120
  const submit = () =>
63
121
  emit('submit', {
64
122
  name: form.name.trim(),
65
- ...(props.editing ? {} : { slug: form.slug.trim() }),
66
- officialName: form.officialName.trim(),
123
+ ...(form.clientType === 'person' ? { firstName: form.firstName.trim(), lastName: form.lastName.trim() } : {}),
124
+ ...(props.editing
125
+ ? {}
126
+ : { clientType: form.clientType, ...(form.clientType === 'organization' ? { slug: form.slug.trim() } : {}) }),
127
+ timezone: form.timezone,
128
+ officialName: form.clientType === 'person' ? form.name.trim() : form.officialName.trim(),
67
129
  address: form.address.trim(),
68
- registrationNumber: form.registrationNumber.trim() || null,
69
- vatNumber: form.vatNumber.trim() || null,
130
+ registrationNumber: form.clientType === 'person' ? null : form.registrationNumber.trim() || null,
131
+ vatNumber: form.clientType === 'person' ? null : form.vatNumber.trim() || null,
70
132
  invoiceEmail: form.invoiceEmail.trim().toLowerCase() || null,
71
133
  preferredLocale: form.preferredLocale
72
134
  })
@@ -76,7 +138,17 @@ const submit = () =>
76
138
  <UCard class="scroll-mt-24">
77
139
  <template #header>
78
140
  <div class="flex items-center justify-between gap-3">
79
- <h2 class="font-semibold">{{ t(editing ? 'features.clients.editTitle' : 'features.clients.createTitle') }}</h2>
141
+ <h2 class="font-semibold">
142
+ {{
143
+ t(
144
+ editing
145
+ ? form.clientType === 'person'
146
+ ? 'features.clients.editPersonalProfile'
147
+ : 'features.clients.editTitle'
148
+ : 'features.clients.createTitle'
149
+ )
150
+ }}
151
+ </h2>
80
152
  <UButton
81
153
  type="button"
82
154
  color="neutral"
@@ -87,24 +159,57 @@ const submit = () =>
87
159
  />
88
160
  </div>
89
161
  </template>
90
- <UForm :state="form" :schema="schema" class="space-y-4" @submit="submit">
162
+ <UForm novalidate :state="form" :schema="schema" class="space-y-4" @submit="submit">
163
+ <UFormField
164
+ v-if="!editing && allowedTypes.length > 1"
165
+ name="clientType"
166
+ :label="t('features.clients.clientType')"
167
+ >
168
+ <USelect
169
+ v-model="form.clientType"
170
+ :items="allowedTypes.map((value) => ({ value, label: t(`features.clients.types.${value}`) }))"
171
+ value-key="value"
172
+ />
173
+ </UFormField>
91
174
  <div class="grid gap-4 md:grid-cols-2">
175
+ <template v-if="form.clientType === 'person'">
176
+ <UFormField name="firstName" :label="t('features.clients.firstName')" required>
177
+ <UInput v-model="form.firstName" autocomplete="given-name" class="w-full" />
178
+ </UFormField>
179
+ <UFormField name="lastName" :label="t('features.clients.lastName')" required>
180
+ <UInput v-model="form.lastName" autocomplete="family-name" class="w-full" />
181
+ </UFormField>
182
+ </template>
92
183
  <UFormField name="name" :label="t('features.clients.name')" required>
93
- <UInput v-model="form.name" class="w-full" />
184
+ <UInput v-model="form.name" class="w-full" @update:model-value="displayNameCustomized = true" />
94
185
  </UFormField>
95
- <UFormField name="slug" :label="t('features.clients.slug')" :required="!editing">
186
+ <UFormField
187
+ v-if="form.clientType === 'organization'"
188
+ name="slug"
189
+ :label="t('features.clients.slug')"
190
+ :required="!editing"
191
+ >
96
192
  <UInput v-model="form.slug" :disabled="editing" class="w-full" />
97
193
  </UFormField>
98
- <UFormField name="officialName" :label="t('features.clients.officialName')" required>
194
+ <UFormField
195
+ v-if="form.clientType === 'organization'"
196
+ name="officialName"
197
+ :label="t('features.clients.officialName')"
198
+ required
199
+ >
99
200
  <UInput v-model="form.officialName" class="w-full" />
100
201
  </UFormField>
101
202
  <UFormField name="invoiceEmail" :label="t('features.clients.invoiceEmail')">
102
203
  <UInput v-model="form.invoiceEmail" type="email" class="w-full" />
103
204
  </UFormField>
104
- <UFormField name="registrationNumber" :label="t('features.clients.registrationNumber')">
205
+ <UFormField
206
+ v-if="form.clientType === 'organization'"
207
+ name="registrationNumber"
208
+ :label="t('features.clients.registrationNumber')"
209
+ >
105
210
  <UInput v-model="form.registrationNumber" class="w-full" />
106
211
  </UFormField>
107
- <UFormField name="vatNumber" :label="t('features.clients.vatNumber')">
212
+ <UFormField v-if="form.clientType === 'organization'" name="vatNumber" :label="t('features.clients.vatNumber')">
108
213
  <UInput v-model="form.vatNumber" class="w-full" />
109
214
  </UFormField>
110
215
  <UFormField name="preferredLocale" :label="t('features.clients.locale')">
@@ -118,6 +223,9 @@ const submit = () =>
118
223
  class="w-full"
119
224
  />
120
225
  </UFormField>
226
+ <UFormField name="timezone" :label="t('timezones.label')">
227
+ <PortalTimezoneSelect v-model="form.timezone" :inherited-timezone="timezones?.providerTimezone" />
228
+ </UFormField>
121
229
  </div>
122
230
  <UFormField name="address" :label="t('features.clients.address')">
123
231
  <UTextarea v-model="form.address" class="w-full" />
@@ -126,7 +234,12 @@ const submit = () =>
126
234
  <UButton type="button" color="neutral" variant="outline" @click="emit('cancel')">
127
235
  {{ t('features.clients.cancel') }}
128
236
  </UButton>
129
- <UButton type="submit" :loading="busy" icon="i-lucide-save">
237
+ <UButton
238
+ type="submit"
239
+ :icon="editing ? 'i-lucide-save' : 'i-lucide-plus'"
240
+ class="ml-auto flex min-w-28 justify-center"
241
+ :loading="busy"
242
+ >
130
243
  {{ t(editing ? 'features.clients.save' : 'features.clients.create') }}
131
244
  </UButton>
132
245
  </div>
@@ -6,7 +6,13 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
6
6
  const { data: clients, status } = await useFetch<GenericClientDto[]>('/api/clients/selectable', {
7
7
  query: computed(() => ({ moduleId: props.moduleId }))
8
8
  })
9
- const items = computed(() => (clients.value ?? []).map((client) => ({ label: client.name, value: client.id })))
9
+ const { t } = useI18n()
10
+ const items = computed(() =>
11
+ (clients.value ?? []).map((client) => ({
12
+ label: `${client.name} · ${t(`features.clients.types.${client.clientType}`)}`,
13
+ value: client.id
14
+ }))
15
+ )
10
16
  </script>
11
17
 
12
18
  <template>
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+
4
+ const { t } = useI18n()
5
+ const api = useClients()
6
+ const { data: client, error: loadError } = await useAsyncData('own-personal-address', () => api.personalAddress())
7
+ const state = reactive({ address: client.value?.address ?? '' })
8
+ const schema = computed(() => z.object({ address: z.string().trim().max(1000, t('features.clients.addressTooLong')) }))
9
+ const busy = ref(false)
10
+ const error = ref(false)
11
+ const toast = useToast()
12
+ watch(client, (value) => {
13
+ state.address = value?.address ?? ''
14
+ })
15
+ async function save() {
16
+ busy.value = true
17
+ error.value = false
18
+ try {
19
+ client.value = await api.updatePersonalAddress(state.address)
20
+ toast.add({ title: t('features.clients.addressSaved'), color: 'success' })
21
+ } catch {
22
+ error.value = true
23
+ } finally {
24
+ busy.value = false
25
+ }
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <UAlert v-if="loadError" class="mb-8" color="error" :title="t('features.clients.addressLoadFailed')" />
31
+ <AppCard v-else-if="client" class="mb-8" :title="t('features.clients.personalAddress')">
32
+ <UForm :schema="schema" :state="state" novalidate class="space-y-4" @submit="save">
33
+ <UFormField
34
+ name="address"
35
+ :label="t('features.clients.address')"
36
+ :description="t('features.clients.personalAddressDescription')"
37
+ >
38
+ <UTextarea
39
+ v-model="state.address"
40
+ autocomplete="street-address"
41
+ class="w-full"
42
+ :rows="3"
43
+ :disabled="client.archived"
44
+ />
45
+ </UFormField>
46
+ <UAlert v-if="client.archived" color="warning" :title="t('features.clients.archived')" />
47
+ <UAlert v-if="error" color="error" :title="t('features.clients.saveFailed')" />
48
+ <UButton
49
+ type="submit"
50
+ icon="i-lucide-save"
51
+ class="ml-auto flex min-w-28 justify-center"
52
+ :loading="busy"
53
+ :disabled="client.archived || state.address === client.address"
54
+ >{{ t('features.clients.save') }}</UButton
55
+ >
56
+ </UForm>
57
+ </AppCard>
58
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{ clientId: string }>()
3
+ const api = useClients()
4
+ const { t } = useI18n()
5
+ const { data: client, refresh } = await useAsyncData(
6
+ () => `personal-profile-${props.clientId}`,
7
+ () => api.get(props.clientId),
8
+ { watch: [() => props.clientId] }
9
+ )
10
+ const busy = ref(false)
11
+ const error = ref('')
12
+ const editing = ref(false)
13
+ const save = async (input: Record<string, unknown>) => {
14
+ busy.value = true
15
+ error.value = ''
16
+ try {
17
+ await api.update(props.clientId, input)
18
+ await refresh()
19
+ editing.value = false
20
+ } catch {
21
+ error.value = t('features.clients.saveFailed')
22
+ } finally {
23
+ busy.value = false
24
+ }
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <div class="space-y-4">
30
+ <UAlert v-if="error" color="error" :title="error" />
31
+ <ClientsClientForm
32
+ v-if="client && editing"
33
+ :client="client"
34
+ editing
35
+ :busy="busy"
36
+ @submit="save"
37
+ @cancel="editing = false"
38
+ />
39
+ <UCard v-else-if="client"
40
+ ><h2 class="font-semibold">{{ t('features.clients.personalAccount') }} · {{ client.name }}</h2>
41
+ <p>{{ client.invoiceEmail }}</p>
42
+ <p>{{ client.schedulingTimezone }}</p>
43
+ <UButton class="mt-4" @click="editing = true">{{ t('features.clients.editPersonalProfile') }}</UButton></UCard
44
+ >
45
+ </div>
46
+ </template>