@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.
- package/CHANGELOG.md +35 -0
- package/README.md +4 -0
- package/app/components/ClientsClientDetail.vue +38 -16
- package/app/components/ClientsClientForm.vue +134 -21
- package/app/components/ClientsClientPicker.vue +7 -1
- package/app/components/ClientsPersonalAddress.vue +58 -0
- package/app/components/ClientsPersonalProfile.vue +46 -0
- package/app/components/ClientsPrivateInvitation.vue +110 -0
- package/app/composables/useClients.ts +21 -0
- package/app/middleware/personal-onboarding.global.ts +14 -0
- package/app/pages/clients/[id].vue +7 -1
- package/app/pages/clients/index.vue +91 -8
- package/app/pages/personal-onboarding.vue +79 -0
- package/app/plugins/clients-feature.ts +10 -2
- package/i18n/locales/en.json +28 -2
- package/i18n/locales/nl.json +28 -2
- package/migrations/0001_person_clients.sql +37 -0
- package/migrations/0002_person_names.sql +18 -0
- package/nuxt.config.ts +7 -1
- package/package.json +3 -3
- package/portal.manifest.mjs +1 -1
- package/server/api/admin/private-client-invitations/index.get.ts +27 -0
- package/server/api/admin/private-client-invitations/index.post.ts +137 -0
- package/server/api/client-account.get.ts +12 -0
- package/server/api/clients/[id]/index.patch.ts +5 -1
- package/server/api/clients/[id]/invitations/[invitationId]/resend.post.ts +78 -0
- package/server/api/clients/[id]/invitations/[invitationId].patch.ts +2 -2
- package/server/api/clients/[id]/invitations/index.post.ts +35 -12
- package/server/api/clients/[id]/members/[memberId].delete.ts +2 -2
- package/server/api/clients/[id]/members/[memberId].patch.ts +6 -2
- package/server/api/clients/index.post.ts +5 -1
- package/server/api/personal-client/address.get.ts +15 -0
- package/server/api/personal-client/address.patch.ts +32 -0
- package/server/api/personal-client.get.ts +43 -0
- package/server/api/personal-client.post.ts +77 -0
- package/server/db/schema/clients.ts +29 -3
- package/server/middleware/00-client-configuration.ts +5 -0
- package/server/plugins/client-accounts.ts +114 -0
- package/server/utils/client-access.ts +9 -0
- package/server/utils/client-configuration.ts +37 -0
- package/server/utils/client-repository.ts +132 -63
- package/server/utils/client-validation.ts +33 -5
- package/server/utils/purchase-client.ts +90 -0
- package/shared/feature.ts +16 -0
- package/shared/personal-onboarding.ts +6 -0
- package/shared/private-client-invitation.ts +11 -0
- 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">
|
|
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="
|
|
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">
|
|
285
|
+
<p v-else-if="client.clientType !== 'person'" class="text-sm text-muted">
|
|
286
|
+
{{ t('features.clients.noMembers') }}
|
|
287
|
+
</p>
|
|
269
288
|
|
|
270
|
-
<div
|
|
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">{{
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
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
|
-
...(
|
|
66
|
-
|
|
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">
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>
|