@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
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ClientListResponse, GenericClientDto } from '@nuxt-customer-portal/clients/shared/types/client'
|
|
3
|
+
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const route = useRoute()
|
|
6
|
+
const router = useRouter()
|
|
7
|
+
const toast = useToast()
|
|
8
|
+
const api = useClients()
|
|
9
|
+
const runtimeConfig = useRuntimeConfig()
|
|
10
|
+
const defaultModules = (runtimeConfig.public.clients as { defaultModules?: string[] } | undefined)?.defaultModules ?? []
|
|
11
|
+
const search = ref(String(route.query.search ?? ''))
|
|
12
|
+
const status = ref(route.query.status === 'archived' ? 'archived' : route.query.status === 'all' ? 'all' : 'active')
|
|
13
|
+
const sortBy = ref(
|
|
14
|
+
['name', 'createdAt', 'status'].includes(String(route.query.sortBy)) ? String(route.query.sortBy) : 'name'
|
|
15
|
+
)
|
|
16
|
+
const sortDir = ref<'asc' | 'desc'>(route.query.sortDir === 'desc' ? 'desc' : 'asc')
|
|
17
|
+
const page = ref(Math.max(1, Number(route.query.page) || 1))
|
|
18
|
+
const result = ref<ClientListResponse | null>(null)
|
|
19
|
+
const pending = ref(false)
|
|
20
|
+
const busy = ref(false)
|
|
21
|
+
const formOpen = ref(false)
|
|
22
|
+
const listContainer = ref<HTMLElement | null>(null)
|
|
23
|
+
const listScrollTop = ref(0)
|
|
24
|
+
const scrollRestored = ref(false)
|
|
25
|
+
const clients = computed(() => result.value?.items ?? [])
|
|
26
|
+
const statusOptions = computed(() => [
|
|
27
|
+
{ label: t('features.clients.all'), value: 'all' },
|
|
28
|
+
{ label: t('features.clients.active'), value: 'active' },
|
|
29
|
+
{ label: t('features.clients.archived'), value: 'archived' }
|
|
30
|
+
])
|
|
31
|
+
const sortOptions = computed(() => [
|
|
32
|
+
{ label: t('features.clients.sortName'), value: 'name' },
|
|
33
|
+
{ label: t('features.clients.sortCreated'), value: 'createdAt' },
|
|
34
|
+
{ label: t('features.clients.sortStatus'), value: 'status' }
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const routeQuery = () => ({
|
|
38
|
+
...(search.value.trim() ? { search: search.value.trim() } : {}),
|
|
39
|
+
...(status.value !== 'active' ? { status: status.value } : {}),
|
|
40
|
+
...(sortBy.value !== 'name' ? { sortBy: sortBy.value } : {}),
|
|
41
|
+
...(sortDir.value !== 'asc' ? { sortDir: sortDir.value } : {}),
|
|
42
|
+
...(page.value > 1 ? { page: String(page.value) } : {})
|
|
43
|
+
})
|
|
44
|
+
const requestQuery = () => ({
|
|
45
|
+
...routeQuery(),
|
|
46
|
+
status: status.value === 'all' ? undefined : status.value
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const listReturnPath = computed(() => {
|
|
50
|
+
const query = new URLSearchParams({
|
|
51
|
+
...routeQuery(),
|
|
52
|
+
...(listScrollTop.value > 0 ? { scroll: String(Math.round(listScrollTop.value)) } : {})
|
|
53
|
+
})
|
|
54
|
+
const suffix = query.toString()
|
|
55
|
+
return suffix ? `/clients?${suffix}` : '/clients'
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const clientDetailTo = (client: GenericClientDto) => ({
|
|
59
|
+
path: `/clients/${client.id}`,
|
|
60
|
+
query: { returnTo: listReturnPath.value }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const load = async () => {
|
|
64
|
+
pending.value = true
|
|
65
|
+
try {
|
|
66
|
+
result.value = await api.list({ ...requestQuery(), page: page.value, pageSize: 20 })
|
|
67
|
+
} finally {
|
|
68
|
+
pending.value = false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const restoreScroll = async () => {
|
|
73
|
+
if (!import.meta.client || scrollRestored.value) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const value = Number(route.query.scroll)
|
|
77
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
await nextTick()
|
|
81
|
+
if (!listContainer.value) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
listContainer.value.scrollTop = value
|
|
85
|
+
scrollRestored.value = true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const syncAndLoad = async (resetPage = false) => {
|
|
89
|
+
if (resetPage) {
|
|
90
|
+
page.value = 1
|
|
91
|
+
}
|
|
92
|
+
scrollRestored.value = false
|
|
93
|
+
await router.replace({ path: route.path, query: routeQuery() })
|
|
94
|
+
await load()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
98
|
+
watch(search, () => {
|
|
99
|
+
clearTimeout(searchTimer)
|
|
100
|
+
searchTimer = setTimeout(() => {
|
|
101
|
+
void syncAndLoad(true)
|
|
102
|
+
}, 300)
|
|
103
|
+
})
|
|
104
|
+
watch([status, sortBy, sortDir], () => {
|
|
105
|
+
void syncAndLoad(true)
|
|
106
|
+
})
|
|
107
|
+
watch(pending, (value) => {
|
|
108
|
+
if (!value) {
|
|
109
|
+
void restoreScroll()
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
onScopeDispose(() => clearTimeout(searchTimer))
|
|
113
|
+
onKeyStroke('Escape', () => {
|
|
114
|
+
formOpen.value = false
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const save = async (input: Record<string, unknown>) => {
|
|
118
|
+
busy.value = true
|
|
119
|
+
try {
|
|
120
|
+
const client = await api.create({ ...input, moduleIds: defaultModules })
|
|
121
|
+
formOpen.value = false
|
|
122
|
+
await load()
|
|
123
|
+
toast.add({ title: t('features.clients.saved'), color: 'success' })
|
|
124
|
+
await navigateTo(clientDetailTo(client))
|
|
125
|
+
} catch (error) {
|
|
126
|
+
toast.add({ title: t('features.clients.saveFailed'), description: String(error), color: 'error' })
|
|
127
|
+
} finally {
|
|
128
|
+
busy.value = false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const goToPage = async (value: number) => {
|
|
133
|
+
page.value = value
|
|
134
|
+
listScrollTop.value = 0
|
|
135
|
+
await syncAndLoad()
|
|
136
|
+
listContainer.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await load()
|
|
140
|
+
onMounted(() => {
|
|
141
|
+
void restoreScroll()
|
|
142
|
+
})
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<template>
|
|
146
|
+
<div class="flex h-full min-h-0 flex-col">
|
|
147
|
+
<div
|
|
148
|
+
ref="listContainer"
|
|
149
|
+
class="min-h-0 flex-1 overflow-y-auto"
|
|
150
|
+
@scroll="listScrollTop = listContainer?.scrollTop ?? 0"
|
|
151
|
+
>
|
|
152
|
+
<div class="mx-auto flex max-w-[1440px] flex-col gap-4 p-4 sm:p-6 lg:p-8">
|
|
153
|
+
<header class="flex items-center justify-between gap-3 border-b border-default pb-4 sm:items-end">
|
|
154
|
+
<div class="flex min-w-0 gap-3">
|
|
155
|
+
<UIcon name="i-lucide-building-2" class="mt-1 size-6 shrink-0 text-primary" />
|
|
156
|
+
<div class="min-w-0">
|
|
157
|
+
<h1 class="text-2xl font-semibold">{{ t('features.clients.title') }}</h1>
|
|
158
|
+
<p class="hidden text-sm text-muted sm:block">{{ t('features.clients.description') }}</p>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
162
|
+
<UButton
|
|
163
|
+
v-if="clients.length && !formOpen"
|
|
164
|
+
class="rounded-full sm:hidden"
|
|
165
|
+
icon="i-lucide-plus"
|
|
166
|
+
:aria-label="t('features.clients.new')"
|
|
167
|
+
@click="formOpen = true"
|
|
168
|
+
/>
|
|
169
|
+
<UButton
|
|
170
|
+
v-if="clients.length && !formOpen"
|
|
171
|
+
class="hidden sm:inline-flex"
|
|
172
|
+
size="sm"
|
|
173
|
+
variant="outline"
|
|
174
|
+
icon="i-lucide-plus"
|
|
175
|
+
@click="formOpen = true"
|
|
176
|
+
>
|
|
177
|
+
{{ t('features.clients.new') }}
|
|
178
|
+
</UButton>
|
|
179
|
+
<UButton
|
|
180
|
+
icon="i-lucide-refresh-cw"
|
|
181
|
+
color="neutral"
|
|
182
|
+
variant="ghost"
|
|
183
|
+
size="sm"
|
|
184
|
+
:loading="pending"
|
|
185
|
+
:aria-label="t('common.refresh')"
|
|
186
|
+
@click="load"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
</header>
|
|
190
|
+
|
|
191
|
+
<PortalListToolbar
|
|
192
|
+
v-model:search="search"
|
|
193
|
+
:search-placeholder="t('features.clients.search')"
|
|
194
|
+
:filters="[{ key: 'status', placeholder: t('features.clients.status'), items: statusOptions }]"
|
|
195
|
+
:filter-values="{ status }"
|
|
196
|
+
:sort-options="sortOptions"
|
|
197
|
+
:sort-by="sortBy"
|
|
198
|
+
:sort-dir="sortDir"
|
|
199
|
+
@filter="(_key, value) => (status = value || 'all')"
|
|
200
|
+
@sort="sortBy = $event"
|
|
201
|
+
@toggle-direction="sortDir = sortDir === 'asc' ? 'desc' : 'asc'"
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
<ClientsClientForm v-if="formOpen" :busy="busy" @submit="save" @cancel="formOpen = false" />
|
|
205
|
+
|
|
206
|
+
<UCard v-if="!clients.length && !pending && !formOpen" variant="subtle">
|
|
207
|
+
<div class="flex flex-col items-center gap-3 py-10 text-center">
|
|
208
|
+
<UIcon name="i-lucide-building-2" class="size-10 text-muted" />
|
|
209
|
+
<div>
|
|
210
|
+
<h2 class="font-semibold">{{ t('features.clients.emptyTitle') }}</h2>
|
|
211
|
+
<p class="text-sm text-muted">{{ t('features.clients.emptyDescription') }}</p>
|
|
212
|
+
</div>
|
|
213
|
+
<UButton icon="i-lucide-plus" @click="formOpen = true">{{ t('features.clients.createFirst') }}</UButton>
|
|
214
|
+
</div>
|
|
215
|
+
</UCard>
|
|
216
|
+
|
|
217
|
+
<div v-else class="grid gap-3">
|
|
218
|
+
<UCard
|
|
219
|
+
v-for="client in clients"
|
|
220
|
+
:key="client.id"
|
|
221
|
+
class="transition-colors hover:ring-1 hover:ring-primary/50"
|
|
222
|
+
>
|
|
223
|
+
<div class="grid grid-cols-[minmax(0,1fr)_2.75rem] items-center gap-3">
|
|
224
|
+
<NuxtLink
|
|
225
|
+
:to="clientDetailTo(client)"
|
|
226
|
+
class="group flex min-w-0 items-center gap-3 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
|
|
227
|
+
>
|
|
228
|
+
<UAvatar :src="client.avatarLogo || client.logo || undefined" :alt="client.name" />
|
|
229
|
+
<div class="min-w-0 flex-1">
|
|
230
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
231
|
+
<p class="truncate font-semibold">{{ client.name }}</p>
|
|
232
|
+
<UBadge :color="client.archivedAt ? 'neutral' : 'success'" variant="subtle">{{
|
|
233
|
+
t(client.archivedAt ? 'features.clients.archived' : 'features.clients.active')
|
|
234
|
+
}}</UBadge>
|
|
235
|
+
</div>
|
|
236
|
+
<p class="mt-1 truncate text-sm text-muted">{{ client.officialName }} · {{ client.slug }}</p>
|
|
237
|
+
</div>
|
|
238
|
+
</NuxtLink>
|
|
239
|
+
<NuxtLink
|
|
240
|
+
:to="clientDetailTo(client)"
|
|
241
|
+
:aria-label="t('features.clients.openClient', { name: client.name })"
|
|
242
|
+
class="grid size-11 place-items-center justify-self-end rounded focus-visible:outline-2 focus-visible:outline-primary"
|
|
243
|
+
>
|
|
244
|
+
<UIcon name="i-lucide-chevron-right" class="size-5 text-muted" />
|
|
245
|
+
</NuxtLink>
|
|
246
|
+
</div>
|
|
247
|
+
</UCard>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
<footer
|
|
252
|
+
v-if="result"
|
|
253
|
+
class="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 border-t border-default px-4 pt-3 text-sm text-muted sm:px-6 lg:px-8"
|
|
254
|
+
>
|
|
255
|
+
<span>{{ t('features.clients.resultCount', result.pagination.totalItems) }}</span>
|
|
256
|
+
<UPagination
|
|
257
|
+
v-if="result.pagination.totalPages > 1"
|
|
258
|
+
:page="page"
|
|
259
|
+
:total="result.pagination.totalItems"
|
|
260
|
+
:items-per-page="20"
|
|
261
|
+
@update:page="goToPage"
|
|
262
|
+
/>
|
|
263
|
+
</footer>
|
|
264
|
+
</div>
|
|
265
|
+
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"features": {
|
|
3
|
+
"clients": {
|
|
4
|
+
"title": "Clients",
|
|
5
|
+
"description": "Manage client companies, members, profiles, and module access.",
|
|
6
|
+
"backToClients": "Back to clients",
|
|
7
|
+
"openClient": "Open {name}",
|
|
8
|
+
"loading": "Loading client",
|
|
9
|
+
"notFound": "Client not found",
|
|
10
|
+
"profile": "Client details",
|
|
11
|
+
"lifecycle": "Status and deletion",
|
|
12
|
+
"new": "New client",
|
|
13
|
+
"create": "Create client",
|
|
14
|
+
"createFirst": "Create your first client",
|
|
15
|
+
"createTitle": "Create client",
|
|
16
|
+
"editTitle": "Edit client",
|
|
17
|
+
"edit": "Edit client",
|
|
18
|
+
"delete": "Delete client",
|
|
19
|
+
"deleteTitle": "Delete {name}",
|
|
20
|
+
"deleteDescription": "This permanently deletes the client. Type its exact name to continue.",
|
|
21
|
+
"deleteBlocked": "This client cannot be deleted while it has {members} members or {modules} module records. Archive it instead.",
|
|
22
|
+
"typeName": "Type “{name}” to confirm",
|
|
23
|
+
"close": "Close",
|
|
24
|
+
"cancel": "Cancel",
|
|
25
|
+
"save": "Save changes",
|
|
26
|
+
"saved": "Client saved",
|
|
27
|
+
"saveFailed": "Could not save the client",
|
|
28
|
+
"deleted": "Client deleted",
|
|
29
|
+
"name": "Display name",
|
|
30
|
+
"slug": "Slug",
|
|
31
|
+
"officialName": "Official company name",
|
|
32
|
+
"address": "Address",
|
|
33
|
+
"registrationNumber": "Registration number",
|
|
34
|
+
"vatNumber": "VAT number",
|
|
35
|
+
"invoiceEmail": "Email address",
|
|
36
|
+
"status": "Status",
|
|
37
|
+
"locale": "Preferred language",
|
|
38
|
+
"members": "Members and contacts",
|
|
39
|
+
"memberEmail": "Member email",
|
|
40
|
+
"invite": "Invite",
|
|
41
|
+
"invited": "Invitation sent",
|
|
42
|
+
"inviteFailed": "The invitation could not be sent. Check the email address and try again.",
|
|
43
|
+
"pendingInvitations": "Pending invitations",
|
|
44
|
+
"noPendingInvitations": "No pending invitations.",
|
|
45
|
+
"invitationPending": "Pending",
|
|
46
|
+
"invitationExpires": "Expires {date}",
|
|
47
|
+
"removeMember": "Remove member",
|
|
48
|
+
"confirmRemoveMember": "Remove {name} ({email}) as a member of {client}? This user will lose access to this client. Their user account will remain.",
|
|
49
|
+
"memberRemoved": "Member removed from client",
|
|
50
|
+
"removeMemberFailed": "Could not remove the member. Refresh the page and try again.",
|
|
51
|
+
"noMembers": "This client does not have members yet.",
|
|
52
|
+
"modules": "Modules",
|
|
53
|
+
"archive": "Archive client",
|
|
54
|
+
"restore": "Restore client",
|
|
55
|
+
"active": "Active",
|
|
56
|
+
"archived": "Archived",
|
|
57
|
+
"all": "All statuses",
|
|
58
|
+
"search": "Search clients",
|
|
59
|
+
"sortName": "Name",
|
|
60
|
+
"sortCreated": "Created",
|
|
61
|
+
"sortStatus": "Status",
|
|
62
|
+
"direction": "Toggle sort direction",
|
|
63
|
+
"emptyTitle": "No clients yet",
|
|
64
|
+
"emptyDescription": "Create a client before linking projects, time, invoices, or service requests.",
|
|
65
|
+
"resultCount": "{count} Client | {count} Clients",
|
|
66
|
+
"validation": {
|
|
67
|
+
"name": "Enter at least two characters.",
|
|
68
|
+
"slug": "Use lowercase letters, numbers, and single hyphens only.",
|
|
69
|
+
"officialName": "Enter the official company name.",
|
|
70
|
+
"email": "Enter a valid email address."
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"features": {
|
|
3
|
+
"clients": {
|
|
4
|
+
"title": "Klanten",
|
|
5
|
+
"description": "Beheer klantbedrijven, leden, profielen en moduletoegang.",
|
|
6
|
+
"backToClients": "Terug naar klanten",
|
|
7
|
+
"openClient": "Open {name}",
|
|
8
|
+
"loading": "Klant laden",
|
|
9
|
+
"notFound": "Klant niet gevonden",
|
|
10
|
+
"profile": "Klantgegevens",
|
|
11
|
+
"lifecycle": "Status en verwijderen",
|
|
12
|
+
"new": "Nieuwe klant",
|
|
13
|
+
"create": "Klant aanmaken",
|
|
14
|
+
"createFirst": "Maak je eerste klant aan",
|
|
15
|
+
"createTitle": "Klant aanmaken",
|
|
16
|
+
"editTitle": "Klant bewerken",
|
|
17
|
+
"edit": "Klant bewerken",
|
|
18
|
+
"delete": "Klant verwijderen",
|
|
19
|
+
"deleteTitle": "{name} verwijderen",
|
|
20
|
+
"deleteDescription": "Hiermee verwijder je de klant permanent. Typ de exacte naam om door te gaan.",
|
|
21
|
+
"deleteBlocked": "Deze klant kan niet worden verwijderd zolang er {members} leden of {modules} moduleregistraties zijn. Archiveer de klant in plaats daarvan.",
|
|
22
|
+
"typeName": "Typ ‘{name}’ ter bevestiging",
|
|
23
|
+
"close": "Sluiten",
|
|
24
|
+
"cancel": "Annuleren",
|
|
25
|
+
"save": "Wijzigingen opslaan",
|
|
26
|
+
"saved": "Klant opgeslagen",
|
|
27
|
+
"saveFailed": "De klant kon niet worden opgeslagen",
|
|
28
|
+
"deleted": "Klant verwijderd",
|
|
29
|
+
"name": "Weergavenaam",
|
|
30
|
+
"slug": "Slug",
|
|
31
|
+
"officialName": "Officiële bedrijfsnaam",
|
|
32
|
+
"address": "Adres",
|
|
33
|
+
"registrationNumber": "Registratienummer",
|
|
34
|
+
"vatNumber": "Btw-nummer",
|
|
35
|
+
"invoiceEmail": "E-mailadres",
|
|
36
|
+
"status": "Status",
|
|
37
|
+
"locale": "Voorkeurstaal",
|
|
38
|
+
"members": "Leden en contactpersonen",
|
|
39
|
+
"memberEmail": "E-mailadres van lid",
|
|
40
|
+
"invite": "Uitnodigen",
|
|
41
|
+
"invited": "Uitnodiging verstuurd",
|
|
42
|
+
"inviteFailed": "De uitnodiging kon niet worden verstuurd. Controleer het e-mailadres en probeer opnieuw.",
|
|
43
|
+
"pendingInvitations": "Openstaande uitnodigingen",
|
|
44
|
+
"noPendingInvitations": "Geen openstaande uitnodigingen.",
|
|
45
|
+
"invitationPending": "Openstaand",
|
|
46
|
+
"invitationExpires": "Verloopt op {date}",
|
|
47
|
+
"removeMember": "Lid verwijderen",
|
|
48
|
+
"confirmRemoveMember": "Wil je {name} ({email}) verwijderen als lid van {client}? Deze gebruiker verliest toegang tot deze klant. Het gebruikersaccount zelf blijft bestaan.",
|
|
49
|
+
"memberRemoved": "Lid verwijderd van de klant",
|
|
50
|
+
"removeMemberFailed": "Het lid kon niet worden verwijderd. Vernieuw de pagina en probeer opnieuw.",
|
|
51
|
+
"noMembers": "Deze klant heeft nog geen leden.",
|
|
52
|
+
"modules": "Modules",
|
|
53
|
+
"archive": "Klant archiveren",
|
|
54
|
+
"restore": "Klant herstellen",
|
|
55
|
+
"active": "Actief",
|
|
56
|
+
"archived": "Gearchiveerd",
|
|
57
|
+
"all": "Alle statussen",
|
|
58
|
+
"search": "Klanten zoeken",
|
|
59
|
+
"sortName": "Naam",
|
|
60
|
+
"sortCreated": "Aangemaakt",
|
|
61
|
+
"sortStatus": "Status",
|
|
62
|
+
"direction": "Sorteerrichting wijzigen",
|
|
63
|
+
"emptyTitle": "Nog geen klanten",
|
|
64
|
+
"emptyDescription": "Maak een klant aan voordat je projecten, uren, facturen of serviceverzoeken koppelt.",
|
|
65
|
+
"resultCount": "{count} klant | {count} klanten",
|
|
66
|
+
"validation": {
|
|
67
|
+
"name": "Voer minimaal twee tekens in.",
|
|
68
|
+
"slug": "Gebruik alleen kleine letters, cijfers en enkele koppeltekens.",
|
|
69
|
+
"officialName": "Voer de officiële bedrijfsnaam in.",
|
|
70
|
+
"email": "Voer een geldig e-mailadres in."
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
CREATE SCHEMA IF NOT EXISTS "clients";
|
|
2
|
+
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "clients"."client_profile" (
|
|
4
|
+
"organization_id" text PRIMARY KEY REFERENCES "organization"("id") ON DELETE restrict,
|
|
5
|
+
"official_name" text NOT NULL,
|
|
6
|
+
"address" text DEFAULT '' NOT NULL,
|
|
7
|
+
"registration_number" text,
|
|
8
|
+
"vat_number" text,
|
|
9
|
+
"invoice_email" text,
|
|
10
|
+
"preferred_locale" text DEFAULT 'nl' NOT NULL,
|
|
11
|
+
"archived_at" timestamp,
|
|
12
|
+
"archived_by_id" text REFERENCES "user"("id") ON DELETE set null,
|
|
13
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
14
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE INDEX IF NOT EXISTS "client_profile_archived_idx" ON "clients"."client_profile" ("archived_at");
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS "clients"."client_module" (
|
|
20
|
+
"id" text PRIMARY KEY,
|
|
21
|
+
"organization_id" text NOT NULL REFERENCES "organization"("id") ON DELETE restrict,
|
|
22
|
+
"module_id" text NOT NULL,
|
|
23
|
+
"enabled" boolean DEFAULT true NOT NULL,
|
|
24
|
+
"enabled_at" timestamp DEFAULT now() NOT NULL,
|
|
25
|
+
"enabled_by_id" text REFERENCES "user"("id") ON DELETE set null,
|
|
26
|
+
"disabled_at" timestamp,
|
|
27
|
+
"disabled_by_id" text REFERENCES "user"("id") ON DELETE set null,
|
|
28
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
29
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "client_module_org_module_uidx" ON "clients"."client_module" ("organization_id", "module_id");
|
|
33
|
+
CREATE INDEX IF NOT EXISTS "client_module_org_enabled_idx" ON "clients"."client_module" ("organization_id", "enabled");
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
3
|
+
export default defineNuxtConfig({
|
|
4
|
+
$meta: { name: 'nuxt-customer-portal-clients' },
|
|
5
|
+
compatibilityDate: '2025-10-24',
|
|
6
|
+
modules: ['@nuxtjs/i18n'],
|
|
7
|
+
components: [{ path: fileURLToPath(new URL('./app/components', import.meta.url)), global: true }],
|
|
8
|
+
i18n: {
|
|
9
|
+
locales: [
|
|
10
|
+
{ code: 'en', iso: 'en-US', name: 'English', file: 'en.json' },
|
|
11
|
+
{ code: 'nl', iso: 'nl-NL', name: 'Nederlands', file: 'nl.json' }
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
runtimeConfig: {
|
|
15
|
+
public: { clients: { defaultModules: [] as string[] } }
|
|
16
|
+
}
|
|
17
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nuxt-customer-portal/clients",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"homepage": "https://nuxt-customer-portal.com",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ludulicious/nuxt-customer-portal.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./nuxt.config.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"app",
|
|
16
|
+
"i18n",
|
|
17
|
+
"migrations",
|
|
18
|
+
"server",
|
|
19
|
+
"shared",
|
|
20
|
+
"nuxt.config.ts",
|
|
21
|
+
"portal.manifest.mjs",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./nuxt.config.ts",
|
|
26
|
+
"./feature": "./shared/feature.ts",
|
|
27
|
+
"./types": "./shared/types/client.ts",
|
|
28
|
+
"./schema": "./server/db/schema/clients.ts",
|
|
29
|
+
"./portal-manifest": "./portal.manifest.mjs",
|
|
30
|
+
"./app/*": "./app/*.ts",
|
|
31
|
+
"./server/*": "./server/*.ts",
|
|
32
|
+
"./shared/*": "./shared/*.ts"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@nuxtjs/i18n": "^10.6.0",
|
|
39
|
+
"@types/pg": "8.15.5",
|
|
40
|
+
"drizzle-orm": "^0.45.2",
|
|
41
|
+
"nanoid": "^5.1.6",
|
|
42
|
+
"pg": "^8.22.0",
|
|
43
|
+
"zod": "^4.4.3",
|
|
44
|
+
"@nuxt-customer-portal/core": "^0.3.0",
|
|
45
|
+
"@nuxt-customer-portal/ui": "^0.3.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"nuxt": "^4.5.1",
|
|
49
|
+
"vue": "^3.5.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"nuxt": "^4.5.1",
|
|
53
|
+
"vue": "^3.5.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
2
|
+
import { getClient, setClientArchived } from '@nuxt-customer-portal/clients/server/utils/client-repository'
|
|
3
|
+
import { clientArchiveSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const id = getRouterParam(event, 'id')!
|
|
7
|
+
const { session } = await requireOwnerClientManager(event)
|
|
8
|
+
const { archived } = clientArchiveSchema.parse(await readBody(event))
|
|
9
|
+
await setClientArchived(id, session.user.id, archived)
|
|
10
|
+
return getClient(id)
|
|
11
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
2
|
+
import { getGenericClientDeletionEligibility } from '@nuxt-customer-portal/clients/server/utils/client-repository'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
await requireOwnerClientManager(event)
|
|
6
|
+
return getGenericClientDeletionEligibility(getRouterParam(event, 'id')!)
|
|
7
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { requireOwnerClientManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
2
|
+
import { deleteGenericClient } from '@nuxt-customer-portal/clients/server/utils/client-repository'
|
|
3
|
+
import { genericClientDeleteSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
await requireOwnerClientManager(event)
|
|
7
|
+
const { clientName } = genericClientDeleteSchema.parse(await readBody(event))
|
|
8
|
+
await deleteGenericClient(getRouterParam(event, 'id')!, clientName)
|
|
9
|
+
return { success: true }
|
|
10
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
2
|
+
import { getClient } from '@nuxt-customer-portal/clients/server/utils/client-repository'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const id = getRouterParam(event, 'id')!
|
|
6
|
+
await requireClientProfileManager(event, id)
|
|
7
|
+
const selected = await getClient(id)
|
|
8
|
+
if (!selected) {
|
|
9
|
+
throw createError({ statusCode: 404, message: 'Client not found' })
|
|
10
|
+
}
|
|
11
|
+
return selected
|
|
12
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
2
|
+
import { getClient, updateClient } from '@nuxt-customer-portal/clients/server/utils/client-repository'
|
|
3
|
+
import { clientUpdateSchema } from '@nuxt-customer-portal/clients/server/utils/client-validation'
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const id = getRouterParam(event, 'id')!
|
|
7
|
+
await requireClientProfileManager(event, id)
|
|
8
|
+
await updateClient(id, clientUpdateSchema.parse(await readBody(event)))
|
|
9
|
+
return getClient(id)
|
|
10
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { changePendingInvitation } from '@nuxt-customer-portal/core/server/utils/invitation-management'
|
|
2
|
+
import { requireClientProfileManager } from '@nuxt-customer-portal/clients/server/utils/client-access'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const organizationId = getRouterParam(event, 'id')!
|
|
6
|
+
await requireClientProfileManager(event, organizationId)
|
|
7
|
+
return changePendingInvitation(organizationId, getRouterParam(event, 'invitationId')!, await readBody(event))
|
|
8
|
+
})
|