@nuxt-customer-portal/invoices 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 (82) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +4 -0
  4. package/app/assets/invoices.css +332 -0
  5. package/app/components/InvoicesAdminEmptyState.vue +28 -0
  6. package/app/components/InvoicesAdminInvoices.vue +950 -0
  7. package/app/components/InvoicesAdminListToolbar.vue +19 -0
  8. package/app/components/InvoicesAdminPaginatedList.vue +95 -0
  9. package/app/components/InvoicesClientAccessPanel.vue +63 -0
  10. package/app/components/InvoicesClientSettingsPanel.vue +150 -0
  11. package/app/components/InvoicesDashboardCard.vue +29 -0
  12. package/app/components/InvoicesDashboardReceivedInvoices.vue +1 -0
  13. package/app/components/InvoicesDashboardSalesInvoices.vue +1 -0
  14. package/app/components/InvoicesInvoiceDetail.vue +1109 -0
  15. package/app/components/InvoicesInvoiceEmailModal.vue +223 -0
  16. package/app/components/InvoicesPageShell.vue +7 -0
  17. package/app/components/InvoicesSenderDetailsForm.vue +229 -0
  18. package/app/composables/useInvoiceSources.ts +9 -0
  19. package/app/composables/useInvoices.ts +68 -0
  20. package/app/composables/useInvoicesAdminList.ts +113 -0
  21. package/app/pages/admin/invoices/[id].vue +36 -0
  22. package/app/pages/admin/invoices/index.vue +61 -0
  23. package/app/pages/admin/invoices/new.vue +18 -0
  24. package/app/pages/admin/invoices/settings.vue +86 -0
  25. package/app/pages/invoices/[id].vue +37 -0
  26. package/app/pages/invoices/index.vue +131 -0
  27. package/app/pages/invoices/viewers.vue +115 -0
  28. package/app/plugins/invoices-feature.ts +60 -0
  29. package/i18n/locales/en.json +306 -0
  30. package/i18n/locales/nl.json +306 -0
  31. package/migrations/0000_baseline.sql +235 -0
  32. package/migrations/meta/0000_snapshot.json +1318 -0
  33. package/migrations/meta/_journal.json +13 -0
  34. package/nuxt.config.ts +15 -0
  35. package/package.json +60 -0
  36. package/portal.manifest.mjs +9 -0
  37. package/server/api/invoices/admin/bootstrap.get.ts +17 -0
  38. package/server/api/invoices/admin/clients/[clientId]/access.get.ts +11 -0
  39. package/server/api/invoices/admin/clients/[clientId]/contacts/[id].delete.ts +8 -0
  40. package/server/api/invoices/admin/clients/[clientId]/contacts/[id].patch.ts +13 -0
  41. package/server/api/invoices/admin/clients/[clientId]/contacts/index.get.ts +8 -0
  42. package/server/api/invoices/admin/clients/[clientId]/contacts/index.post.ts +12 -0
  43. package/server/api/invoices/admin/email-domain.get.ts +17 -0
  44. package/server/api/invoices/admin/invoices/[id]/attachments/[attachmentId].delete.ts +23 -0
  45. package/server/api/invoices/admin/invoices/[id]/attachments/[attachmentId].get.ts +30 -0
  46. package/server/api/invoices/admin/invoices/[id]/attachments.post.ts +27 -0
  47. package/server/api/invoices/admin/invoices/[id]/email-preview.get.ts +22 -0
  48. package/server/api/invoices/admin/invoices/[id]/email-status.post.ts +18 -0
  49. package/server/api/invoices/admin/invoices/[id]/email.post.ts +24 -0
  50. package/server/api/invoices/admin/invoices/[id]/index.get.ts +17 -0
  51. package/server/api/invoices/admin/invoices/[id]/issue.post.ts +24 -0
  52. package/server/api/invoices/admin/invoices/[id]/payments.post.ts +24 -0
  53. package/server/api/invoices/admin/invoices/[id]/pdf.get.ts +29 -0
  54. package/server/api/invoices/admin/invoices/[id]/reminder-preview.get.ts +24 -0
  55. package/server/api/invoices/admin/invoices/[id]/reminder.post.ts +25 -0
  56. package/server/api/invoices/admin/invoices/[id].patch.ts +24 -0
  57. package/server/api/invoices/admin/invoices/index.get.ts +20 -0
  58. package/server/api/invoices/admin/invoices/index.post.ts +19 -0
  59. package/server/api/invoices/admin/invoices/next-number.get.ts +16 -0
  60. package/server/api/invoices/admin/settings.put.ts +9 -0
  61. package/server/api/invoices/capabilities.get.ts +25 -0
  62. package/server/api/invoices/client/[accessId]/viewers.get.ts +10 -0
  63. package/server/api/invoices/client/[accessId]/viewers.put.ts +17 -0
  64. package/server/api/invoices/client/invoices/[id]/attachments/[attachmentId].get.ts +20 -0
  65. package/server/api/invoices/client/invoices/[id]/index.get.ts +12 -0
  66. package/server/api/invoices/client/invoices/[id]/pdf.get.ts +23 -0
  67. package/server/api/invoices/client/invoices/index.get.ts +14 -0
  68. package/server/api/invoices/client/suppliers.get.ts +8 -0
  69. package/server/db/schema/invoices.ts +251 -0
  70. package/server/plugins/openapi-contracts.ts +55 -0
  71. package/server/utils/invoice-email-status.ts +64 -0
  72. package/server/utils/invoice-email.ts +240 -0
  73. package/server/utils/invoice-listing.ts +71 -0
  74. package/server/utils/invoice-pdf.ts +358 -0
  75. package/server/utils/invoice-repository.ts +821 -0
  76. package/server/utils/invoice-validation.ts +112 -0
  77. package/shared/email-delivery-status.ts +23 -0
  78. package/shared/feature.ts +148 -0
  79. package/shared/invoice-introduction.ts +21 -0
  80. package/shared/invoice-number.ts +24 -0
  81. package/shared/types/invoice.ts +251 -0
  82. package/tokens.css +25 -0
@@ -0,0 +1,223 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { InvoiceEmailPreviewDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
4
+
5
+ type EmailMode = 'issue' | 'resend' | 'reminder'
6
+
7
+ const props = defineProps<{ open: boolean; invoiceId: string; mode: EmailMode; refresh: () => Promise<unknown> }>()
8
+ const emit = defineEmits<{ 'update:open': [value: boolean]; sent: [] }>()
9
+ const { t } = useI18n()
10
+ const api = useInvoices()
11
+ const toast = useToast()
12
+ const { activeOrganizationRole, isSystemAdmin } = usePortalSession()
13
+ const canConfigureEmailProvider = computed(() => isSystemAdmin.value || activeOrganizationRole.value === 'owner')
14
+ const busy = ref(false)
15
+ const loadingPreview = ref(false)
16
+ const preview = ref<InvoiceEmailPreviewDto | null>(null)
17
+ const draft = reactive({ to: '', cc: '', locale: 'nl' as 'nl' | 'en', subject: '', body: '' })
18
+ const schema = computed(() =>
19
+ z.object({
20
+ to: z.string().trim().email(t('features.invoices.validation.validEmail')),
21
+ cc: z
22
+ .string()
23
+ .refine(
24
+ (value) => !value.trim() || value.split(',').every((item) => z.string().email().safeParse(item.trim()).success),
25
+ t('features.invoices.validation.validEmail')
26
+ ),
27
+ locale: z.enum(['nl', 'en']),
28
+ subject: z.string().trim().min(1, t('features.invoices.validation.required')).max(500),
29
+ body: z.string().trim().min(1, t('features.invoices.validation.required')).max(10000)
30
+ })
31
+ )
32
+ const titleKey = computed(() =>
33
+ props.mode === 'issue'
34
+ ? 'features.invoices.admin.issueAndSend'
35
+ : props.mode === 'reminder'
36
+ ? 'features.invoices.admin.sendReminder'
37
+ : 'features.invoices.admin.resendInvoice'
38
+ )
39
+ const successKey = computed(() =>
40
+ props.mode === 'issue'
41
+ ? 'features.invoices.admin.invoiceIssuedAndSent'
42
+ : props.mode === 'reminder'
43
+ ? 'features.invoices.admin.reminderSent'
44
+ : 'features.invoices.admin.invoiceResent'
45
+ )
46
+ const loadPreview = async (locale?: string) => {
47
+ loadingPreview.value = true
48
+ try {
49
+ const result =
50
+ props.mode === 'reminder'
51
+ ? await api.getInvoiceReminderPreview(props.invoiceId, locale)
52
+ : await api.getInvoiceEmailPreview(props.invoiceId, locale)
53
+ preview.value = result
54
+ Object.assign(draft, {
55
+ to: result.to,
56
+ cc: result.cc.join(', '),
57
+ locale: result.locale === 'en' ? 'en' : 'nl',
58
+ subject: result.subject,
59
+ body: result.body
60
+ })
61
+ } catch (error) {
62
+ emit('update:open', false)
63
+ toast.add({ title: t('features.invoices.messages.saveError'), description: String(error), color: 'error' })
64
+ } finally {
65
+ loadingPreview.value = false
66
+ }
67
+ }
68
+ watch(
69
+ () => props.open,
70
+ (open) => {
71
+ if (open) {
72
+ void loadPreview()
73
+ } else {
74
+ preview.value = null
75
+ }
76
+ }
77
+ )
78
+ const send = async () => {
79
+ busy.value = true
80
+ try {
81
+ const input = {
82
+ ...draft,
83
+ cc: draft.cc
84
+ .split(',')
85
+ .map((item) => item.trim())
86
+ .filter(Boolean)
87
+ }
88
+ if (props.mode === 'issue') {
89
+ await api.issueAndSendInvoice(props.invoiceId, input)
90
+ } else if (props.mode === 'reminder') {
91
+ await api.sendInvoiceReminder(props.invoiceId, input)
92
+ } else {
93
+ await api.resendInvoice(props.invoiceId, input)
94
+ }
95
+ await props.refresh()
96
+ toast.add({ title: t(successKey.value), color: 'success' })
97
+ emit('sent')
98
+ emit('update:open', false)
99
+ } catch (error) {
100
+ toast.add({ title: t('features.invoices.messages.saveError'), description: String(error), color: 'error' })
101
+ } finally {
102
+ busy.value = false
103
+ }
104
+ }
105
+ </script>
106
+
107
+ <template>
108
+ <UModal :open="open" :title="t(titleKey)" @update:open="emit('update:open', $event)">
109
+ <template #body>
110
+ <div v-if="loadingPreview" class="flex justify-center py-10" role="status">
111
+ <UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" /><span class="sr-only">{{
112
+ t('features.invoices.loading')
113
+ }}</span>
114
+ </div>
115
+ <UForm v-else novalidate :state="draft" :schema="schema" class="space-y-4" @submit="send">
116
+ <UAlert
117
+ v-if="preview && !preview.emailProviderConfigured"
118
+ color="error"
119
+ icon="i-lucide-key-round"
120
+ :title="t('features.invoices.admin.emailProviderNotConfigured')"
121
+ variant="outline"
122
+ >
123
+ <template #description>
124
+ <p>
125
+ {{
126
+ t(
127
+ canConfigureEmailProvider
128
+ ? 'features.invoices.admin.emailProviderConfigureDescription'
129
+ : 'features.invoices.admin.emailProviderContactOwner'
130
+ )
131
+ }}
132
+ </p>
133
+ <UButton
134
+ v-if="canConfigureEmailProvider"
135
+ to="/settings/organization"
136
+ color="neutral"
137
+ variant="solid"
138
+ size="xs"
139
+ icon="i-lucide-settings"
140
+ class="mt-2"
141
+ >
142
+ {{ t('features.invoices.admin.emailProviderOpenSettings') }}
143
+ </UButton>
144
+ </template>
145
+ </UAlert>
146
+ <UAlert
147
+ v-else-if="preview && !preview.senderDomainVerified"
148
+ color="error"
149
+ icon="i-lucide-shield-alert"
150
+ :title="t('features.invoices.admin.emailDomainUnverified')"
151
+ variant="outline"
152
+ >
153
+ <template #description>
154
+ <p>{{ preview.senderDomain }}</p>
155
+ <UButton
156
+ to="https://resend.com/docs/dashboard/domains/introduction"
157
+ target="_blank"
158
+ rel="noopener noreferrer"
159
+ color="neutral"
160
+ variant="solid"
161
+ size="xs"
162
+ trailing-icon="i-lucide-external-link"
163
+ class="mt-2"
164
+ >
165
+ {{ t('features.invoices.admin.emailDomainVerificationHelp') }}
166
+ </UButton>
167
+ </template>
168
+ </UAlert>
169
+ <UAlert
170
+ v-if="preview && preview.totalAttachmentSize > preview.maximumAttachmentSize"
171
+ color="error"
172
+ icon="i-lucide-file-warning"
173
+ :title="t('features.invoices.admin.emailAttachmentsTooLarge')"
174
+ variant="outline"
175
+ />
176
+ <div class="grid gap-3 sm:grid-cols-2">
177
+ <UFormField name="to" :label="t('features.invoices.admin.recipientEmail')">
178
+ <UInput v-model="draft.to" type="email" class="w-full" /> </UFormField
179
+ ><UFormField name="cc" label="CC">
180
+ <UInput v-model="draft.cc" :placeholder="t('features.invoices.admin.ccPlaceholder')" class="w-full" />
181
+ </UFormField>
182
+ </div>
183
+ <UFormField name="locale" :label="t('features.invoices.admin.emailLanguage')">
184
+ <p class="text-sm text-muted">{{ t(`features.invoices.languages.${draft.locale}`) }}</p>
185
+ </UFormField>
186
+ <UFormField name="subject" :label="t('features.invoices.admin.emailSubject')">
187
+ <UInput v-model="draft.subject" class="w-full" />
188
+ </UFormField>
189
+ <UFormField name="body" :label="t('features.invoices.admin.emailBody')">
190
+ <UTextarea v-model="draft.body" :rows="8" class="w-full" />
191
+ </UFormField>
192
+ <div>
193
+ <p class="text-sm font-medium">{{ t('features.invoices.admin.emailAttachments') }}</p>
194
+ <ul class="mt-2 space-y-1 text-sm text-muted">
195
+ <li v-for="file in preview?.attachments" :key="file.fileName">
196
+ {{ file.fileName }} · {{ Math.ceil(file.size / 1024) }} KB
197
+ </li>
198
+ </ul>
199
+ </div>
200
+ <div class="flex justify-end gap-2">
201
+ <UButton type="button" color="neutral" variant="ghost" @click="emit('update:open', false)">
202
+ {{ t('features.invoices.cancel') }} </UButton
203
+ ><UButton
204
+ type="submit"
205
+ icon="i-lucide-send"
206
+ :loading="busy"
207
+ :disabled="
208
+ !preview?.emailProviderConfigured ||
209
+ !preview?.senderDomainVerified ||
210
+ (preview?.totalAttachmentSize ?? 0) > (preview?.maximumAttachmentSize ?? 0)
211
+ "
212
+ >
213
+ {{
214
+ t(
215
+ props.mode === 'reminder' ? 'features.invoices.admin.sendReminder' : 'features.invoices.admin.sendEmail'
216
+ )
217
+ }}
218
+ </UButton>
219
+ </div>
220
+ </UForm>
221
+ </template>
222
+ </UModal>
223
+ </template>
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ withDefaults(defineProps<{ width?: 'default' | 'narrow' }>(), { width: 'default' })
3
+ </script>
4
+
5
+ <template>
6
+ <main class="mx-auto w-full px-4 py-6 sm:px-6" :class="width === 'narrow' ? 'max-w-4xl' : 'max-w-7xl'"><slot /></main>
7
+ </template>
@@ -0,0 +1,229 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { OrganizationInvoiceProfileDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
4
+
5
+ const props = defineProps<{
6
+ profile: OrganizationInvoiceProfileDto
7
+ refresh: () => Promise<unknown>
8
+ }>()
9
+
10
+ const { t } = useI18n()
11
+ const toast = useToast()
12
+ const invoices = useInvoices()
13
+ const { isSystemAdmin } = usePortalSession()
14
+ const canConfigureProvider = computed(() => isSystemAdmin.value)
15
+ const busy = ref(false)
16
+ const checkingDomain = ref(false)
17
+ type DomainStatus = { email: string | null; domain: string | null; configured: boolean; verified: boolean }
18
+ const { data: domainStatus, refresh: refreshDomainStatus } = await useFetch<DomainStatus>(
19
+ '/api/invoices/admin/email-domain'
20
+ )
21
+ const draft = reactive({
22
+ address: '',
23
+ registrationNumber: '',
24
+ vatNumber: '',
25
+ iban: '',
26
+ bic: '',
27
+ invoiceEmail: ''
28
+ })
29
+ const schema = computed(() =>
30
+ z.object({
31
+ address: z.string().trim().min(1, t('features.invoices.validation.required')).max(1000),
32
+ registrationNumber: z.string().trim().min(1, t('features.invoices.validation.required')).max(200),
33
+ vatNumber: z.string().trim().min(1, t('features.invoices.validation.required')).max(100),
34
+ iban: z.string().trim().min(1, t('features.invoices.validation.required')).max(100),
35
+ bic: z.string().trim().min(1, t('features.invoices.validation.required')).max(100),
36
+ invoiceEmail: z
37
+ .string()
38
+ .trim()
39
+ .min(1, t('features.invoices.validation.required'))
40
+ .email(t('features.invoices.validation.validEmail'))
41
+ .max(320)
42
+ })
43
+ )
44
+
45
+ const checkDomain = async (forceRefresh = true) => {
46
+ checkingDomain.value = true
47
+ try {
48
+ const status = await $fetch<DomainStatus>('/api/invoices/admin/email-domain', {
49
+ query: forceRefresh ? { refresh: '1' } : undefined
50
+ })
51
+ domainStatus.value = status
52
+ toast.add({
53
+ title: t(
54
+ status.verified
55
+ ? 'features.invoices.admin.emailDomainNowVerified'
56
+ : 'features.invoices.admin.emailDomainStillUnverified'
57
+ ),
58
+ color: status.verified ? 'success' : 'warning'
59
+ })
60
+ } catch (error) {
61
+ toast.add({
62
+ title: t('features.invoices.admin.emailDomainCheckFailed'),
63
+ description: String(error),
64
+ color: 'error'
65
+ })
66
+ } finally {
67
+ checkingDomain.value = false
68
+ }
69
+ }
70
+
71
+ watch(
72
+ () => props.profile,
73
+ (profile) => {
74
+ Object.assign(draft, {
75
+ address: profile.address,
76
+ registrationNumber: profile.registrationNumber ?? '',
77
+ vatNumber: profile.vatNumber ?? '',
78
+ iban: profile.iban ?? '',
79
+ bic: profile.bic ?? '',
80
+ invoiceEmail: profile.invoiceEmail ?? ''
81
+ })
82
+ },
83
+ { immediate: true }
84
+ )
85
+
86
+ const save = async () => {
87
+ busy.value = true
88
+ try {
89
+ const input = Object.fromEntries(
90
+ Object.entries(draft).map(([key, value]) => [key, value || (key === 'address' ? '' : null)])
91
+ )
92
+ await invoices.updateSettings({
93
+ enabled: props.profile.enabled,
94
+ currency: props.profile.currency,
95
+ defaultVatRateBasisPoints: props.profile.defaultVatRateBasisPoints,
96
+ preferredLocale: props.profile.preferredLocale,
97
+ ...input
98
+ } as never)
99
+ await props.refresh()
100
+ await refreshDomainStatus()
101
+ toast.add({ title: t('features.invoices.messages.senderInvoiceDetailsSaved'), color: 'success' })
102
+ } catch (error) {
103
+ toast.add({
104
+ title: t('features.invoices.messages.saveError'),
105
+ description: String(error),
106
+ color: 'error'
107
+ })
108
+ } finally {
109
+ busy.value = false
110
+ }
111
+ }
112
+ </script>
113
+
114
+ <template>
115
+ <UCard id="sender-invoice-details" class="scroll-mt-6">
116
+ <template #header>
117
+ <h2 class="font-semibold">
118
+ {{ t('features.invoices.admin.senderInvoiceDetails') }}
119
+ </h2>
120
+ </template>
121
+ <UForm :state="draft" :schema="schema" class="space-y-4" @submit="save">
122
+ <UAlert
123
+ v-if="!domainStatus?.configured"
124
+ color="warning"
125
+ icon="i-lucide-key-round"
126
+ :title="t('features.invoices.admin.emailProviderNotConfigured')"
127
+ variant="outline"
128
+ >
129
+ <template #description>
130
+ <p>
131
+ {{
132
+ t(
133
+ canConfigureProvider
134
+ ? 'features.invoices.admin.emailProviderConfigureDescription'
135
+ : 'features.invoices.admin.emailProviderContactOwner'
136
+ )
137
+ }}
138
+ </p>
139
+ <UButton
140
+ v-if="canConfigureProvider"
141
+ to="/admin/email"
142
+ color="neutral"
143
+ variant="solid"
144
+ size="xs"
145
+ icon="i-lucide-settings"
146
+ class="mt-2"
147
+ >
148
+ {{ t('features.invoices.admin.emailProviderOpenSettings') }}
149
+ </UButton>
150
+ </template>
151
+ </UAlert>
152
+ <UAlert
153
+ v-else
154
+ :color="domainStatus?.verified ? 'success' : 'warning'"
155
+ :icon="domainStatus?.verified ? 'i-lucide-badge-check' : 'i-lucide-triangle-alert'"
156
+ :title="
157
+ t(
158
+ domainStatus?.verified
159
+ ? 'features.invoices.admin.emailDomainVerified'
160
+ : 'features.invoices.admin.emailDomainUnverified'
161
+ )
162
+ "
163
+ variant="outline"
164
+ >
165
+ <template #description>
166
+ <p>
167
+ {{
168
+ domainStatus?.domain
169
+ ? t(
170
+ domainStatus.verified
171
+ ? 'features.invoices.admin.emailDomainVerifiedDescription'
172
+ : 'features.invoices.admin.emailDomainStatusDescription',
173
+ { domain: domainStatus.domain }
174
+ )
175
+ : t('features.invoices.admin.emailDomainMissing')
176
+ }}
177
+ </p>
178
+ <div v-if="domainStatus?.domain" class="mt-2 flex flex-wrap gap-2">
179
+ <UButton
180
+ v-if="!domainStatus.verified"
181
+ to="https://resend.com/docs/dashboard/domains/introduction"
182
+ target="_blank"
183
+ rel="noopener noreferrer"
184
+ color="neutral"
185
+ variant="solid"
186
+ size="xs"
187
+ trailing-icon="i-lucide-external-link"
188
+ >
189
+ {{ t('features.invoices.admin.emailDomainVerificationHelp') }} </UButton
190
+ ><UButton
191
+ type="button"
192
+ color="neutral"
193
+ variant="solid"
194
+ size="xs"
195
+ icon="i-lucide-refresh-cw"
196
+ :loading="checkingDomain"
197
+ @click="checkDomain()"
198
+ >
199
+ {{ t('features.invoices.admin.emailDomainCheckAgain') }}
200
+ </UButton>
201
+ </div>
202
+ </template>
203
+ </UAlert>
204
+ <UFormField name="address" :label="t('features.invoices.admin.address')" required>
205
+ <UTextarea v-model="draft.address" class="w-full" />
206
+ </UFormField>
207
+ <div class="grid gap-3 md:grid-cols-2">
208
+ <UFormField name="registrationNumber" :label="t('features.invoices.admin.registration')" required>
209
+ <UInput v-model="draft.registrationNumber" class="w-full" />
210
+ </UFormField>
211
+ <UFormField name="vatNumber" :label="t('features.invoices.admin.vatNumber')" required>
212
+ <UInput v-model="draft.vatNumber" class="w-full" />
213
+ </UFormField>
214
+ <UFormField name="iban" label="IBAN" required>
215
+ <UInput v-model="draft.iban" class="w-full" />
216
+ </UFormField>
217
+ <UFormField name="bic" label="BIC" required>
218
+ <UInput v-model="draft.bic" class="w-full" />
219
+ </UFormField>
220
+ <UFormField name="invoiceEmail" :label="t('features.invoices.admin.invoiceEmail')" required>
221
+ <UInput v-model="draft.invoiceEmail" type="email" class="w-full" />
222
+ </UFormField>
223
+ </div>
224
+ <UButton type="submit" block icon="i-lucide-save" :loading="busy">
225
+ {{ t('features.invoices.save') }}
226
+ </UButton>
227
+ </UForm>
228
+ </UCard>
229
+ </template>
@@ -0,0 +1,9 @@
1
+ import type { InvoiceSourceProvider } from '@nuxt-customer-portal/invoices/shared/types/invoice'
2
+
3
+ export const useInvoiceSources = () => {
4
+ const providers = useState<InvoiceSourceProvider[]>('invoice-source-providers', () => [])
5
+ const register = (provider: InvoiceSourceProvider) => {
6
+ providers.value = [...providers.value.filter((item) => item.id !== provider.id), provider]
7
+ }
8
+ return { providers: readonly(providers), register }
9
+ }
@@ -0,0 +1,68 @@
1
+ import type {
2
+ ClientInvoiceDto,
3
+ ClientInvoiceSupplierDto,
4
+ ClientInvoiceViewerDto,
5
+ InvoiceClientDto,
6
+ InvoiceClientAccessOverviewDto,
7
+ InvoiceDto,
8
+ InvoiceEmailPreviewDto,
9
+ InvoiceEmailStatusRefreshDto,
10
+ InvoiceSettingsDto
11
+ } from '@nuxt-customer-portal/invoices/shared/types/invoice'
12
+
13
+ export interface InvoicesAdminBootstrap {
14
+ settings: InvoiceSettingsDto
15
+ clients: InvoiceClientDto[]
16
+ organizationProfile: InvoiceSettingsDto & { name: string; logo: string | null }
17
+ }
18
+
19
+ export const useInvoices = () => ({
20
+ clientAccessOverview: (clientId: string) =>
21
+ $fetch<InvoiceClientAccessOverviewDto>(`/api/invoices/admin/clients/${clientId}/access`),
22
+ adminBootstrap: () => $fetch<InvoicesAdminBootstrap>('/api/invoices/admin/bootstrap'),
23
+ updateSettings: (input: Omit<InvoiceSettingsDto, 'organizationId'>) =>
24
+ $fetch('/api/invoices/admin/settings', { method: 'PUT', body: input }),
25
+ createInvoice: (input: Record<string, unknown>) =>
26
+ $fetch<{ id: string }>('/api/invoices/admin/invoices', { method: 'POST', body: input }),
27
+ getNextInvoiceNumber: () => $fetch<{ number: string }>('/api/invoices/admin/invoices/next-number'),
28
+ getInvoice: (id: string) => $fetch<InvoiceDto>(`/api/invoices/admin/invoices/${id}`),
29
+ updateInvoice: (id: string, input: Record<string, unknown>) =>
30
+ $fetch(`/api/invoices/admin/invoices/${id}`, { method: 'PATCH' as never, body: input }),
31
+ changeInvoiceStatus: (id: string, action: 'VOID' | 'UNVOID') =>
32
+ $fetch(`/api/invoices/admin/invoices/${id}`, { method: 'PATCH' as never, body: { action } }),
33
+ getInvoiceEmailPreview: (id: string, locale?: string) =>
34
+ $fetch<InvoiceEmailPreviewDto>(`/api/invoices/admin/invoices/${id}/email-preview`, { query: { locale } }),
35
+ issueAndSendInvoice: (id: string, input: Record<string, unknown>) =>
36
+ $fetch(`/api/invoices/admin/invoices/${id}/issue`, { method: 'POST', body: input }),
37
+ resendInvoice: (id: string, input: Record<string, unknown>) =>
38
+ $fetch(`/api/invoices/admin/invoices/${id}/email`, { method: 'POST', body: input }),
39
+ getInvoiceReminderPreview: (id: string, locale?: string) =>
40
+ $fetch<InvoiceEmailPreviewDto>(`/api/invoices/admin/invoices/${id}/reminder-preview`, { query: { locale } }),
41
+ sendInvoiceReminder: (id: string, input: Record<string, unknown>) =>
42
+ $fetch(`/api/invoices/admin/invoices/${id}/reminder`, { method: 'POST', body: input }),
43
+ refreshInvoiceEmailStatuses: (id: string, forceRefresh = false) =>
44
+ $fetch<InvoiceEmailStatusRefreshDto>(`/api/invoices/admin/invoices/${id}/email-status`, {
45
+ method: 'POST',
46
+ query: forceRefresh ? { refresh: '1' } : undefined
47
+ }),
48
+ registerInvoicePayment: (id: string, input: Record<string, unknown>) =>
49
+ $fetch(`/api/invoices/admin/invoices/${id}/payments`, { method: 'POST', body: input }),
50
+ uploadInvoiceAttachment: (id: string, file: File) => {
51
+ const body = new FormData()
52
+ body.append('file', file)
53
+ return $fetch(`/api/invoices/admin/invoices/${id}/attachments`, { method: 'POST', body })
54
+ },
55
+ addInvoiceAttachment: (id: string, file: File) => {
56
+ const body = new FormData()
57
+ body.append('file', file)
58
+ return $fetch(`/api/invoices/admin/invoices/${id}/attachments`, { method: 'POST', body })
59
+ },
60
+ deleteInvoiceAttachment: (id: string, attachmentId: string) =>
61
+ $fetch(`/api/invoices/admin/invoices/${id}/attachments/${attachmentId}`, { method: 'DELETE' }),
62
+ clientInvoiceSuppliers: () => $fetch<ClientInvoiceSupplierDto[]>('/api/invoices/client/suppliers'),
63
+ clientInvoiceViewers: (accessId: string) =>
64
+ $fetch<ClientInvoiceViewerDto[]>(`/api/invoices/client/${accessId}/viewers`),
65
+ setClientInvoiceViewer: (accessId: string, userId: string, assigned: boolean) =>
66
+ $fetch(`/api/invoices/client/${accessId}/viewers`, { method: 'PUT', body: { userId, assigned } }),
67
+ getClientInvoice: (id: string) => $fetch<ClientInvoiceDto>(`/api/invoices/client/invoices/${id}`)
68
+ })
@@ -0,0 +1,113 @@
1
+ import type { InvoicesListResponse } from '@nuxt-customer-portal/invoices/shared/types/invoice'
2
+ import { usePaginatedResource } from '@nuxt-customer-portal/core/app/composables/usePaginatedResource'
3
+
4
+ export const useInvoicesAdminList = <Item>(options: {
5
+ endpoint: string
6
+ filterKeys?: string[]
7
+ defaultFilters?: Record<string, string>
8
+ defaultSort: string
9
+ defaultSortDir?: 'asc' | 'desc'
10
+ }) => {
11
+ const route = useRoute()
12
+ const router = useRouter()
13
+ const pageSize = 20
14
+ const defaultSortDir = options.defaultSortDir ?? 'asc'
15
+ const search = ref(String(route.query.search ?? ''))
16
+ const sortBy = ref(String(route.query.sortBy ?? options.defaultSort))
17
+ const sortDir = ref<'asc' | 'desc'>(route.query.sortDir === 'desc' ? 'desc' : defaultSortDir)
18
+ const currentPage = ref(Math.max(1, Number(route.query.page) || 1))
19
+ const filters = reactive<Record<string, string | undefined>>(
20
+ Object.fromEntries(
21
+ (options.filterKeys ?? []).map((key) => [
22
+ key,
23
+ route.query[key] ? String(route.query[key]) : options.defaultFilters?.[key]
24
+ ])
25
+ )
26
+ )
27
+ const resource = usePaginatedResource<Item, Record<string, string | undefined>>({
28
+ pageSize,
29
+ getKey: (item) => (item as { id: string }).id,
30
+ fetchPage: ({ filters: query, page, pageSize, signal }) =>
31
+ $fetch<InvoicesListResponse<Item>>(options.endpoint, { query: { ...query, page, pageSize }, signal })
32
+ })
33
+ const query = () => ({
34
+ ...(search.value.trim() ? { search: search.value.trim() } : {}),
35
+ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)),
36
+ ...(sortBy.value !== options.defaultSort ? { sortBy: sortBy.value } : {}),
37
+ ...(sortDir.value !== defaultSortDir ? { sortDir: sortDir.value } : {})
38
+ })
39
+ const routeQuery = () => ({
40
+ ...(search.value.trim() ? { search: search.value.trim() } : {}),
41
+ ...Object.fromEntries(
42
+ Object.entries(filters).filter(([key, value]) => value && value !== options.defaultFilters?.[key])
43
+ ),
44
+ ...(sortBy.value !== options.defaultSort ? { sortBy: sortBy.value } : {}),
45
+ ...(sortDir.value !== defaultSortDir ? { sortDir: sortDir.value } : {})
46
+ })
47
+ const syncRoute = (page = currentPage.value) =>
48
+ router.replace({ path: route.path, query: { ...routeQuery(), ...(page > 1 ? { page: String(page) } : {}) } })
49
+ const load = async (page = currentPage.value) => {
50
+ const result = await resource.loadPage(query(), { page, pageSize })
51
+ if (result) {
52
+ currentPage.value = result.pagination.page
53
+ }
54
+ return result
55
+ }
56
+ const resetAndLoad = async () => {
57
+ currentPage.value = 1
58
+ await syncRoute(1)
59
+ await load(1)
60
+ }
61
+ const loadNext = async () => {
62
+ const result = await resource.loadNextPage(query())
63
+ if (result) {
64
+ currentPage.value = result.pagination.page
65
+ await syncRoute(currentPage.value)
66
+ }
67
+ }
68
+ const loadPrevious = async () => {
69
+ const result = await resource.loadPreviousPage(query())
70
+ if (result) {
71
+ currentPage.value = result.pagination.page
72
+ await syncRoute(currentPage.value)
73
+ }
74
+ return result
75
+ }
76
+ const goToPage = async (page: number) => {
77
+ currentPage.value = page
78
+ await syncRoute(page)
79
+ await load(page)
80
+ }
81
+ const setFilter = (key: string, value: string | undefined) => {
82
+ filters[key] = value
83
+ }
84
+ const toggleSortDir = () => {
85
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
86
+ }
87
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
88
+ watch(search, () => {
89
+ clearTimeout(searchTimer)
90
+ searchTimer = setTimeout(() => {
91
+ void resetAndLoad()
92
+ }, 300)
93
+ })
94
+ watch([sortBy, sortDir, ...Object.keys(filters).map((key) => () => filters[key])], () => {
95
+ void resetAndLoad()
96
+ })
97
+ onScopeDispose(() => clearTimeout(searchTimer))
98
+ return {
99
+ ...resource,
100
+ search,
101
+ filters,
102
+ sortBy,
103
+ sortDir,
104
+ currentPage,
105
+ load,
106
+ refresh: load,
107
+ loadNext,
108
+ loadPrevious,
109
+ goToPage,
110
+ setFilter,
111
+ toggleSortDir
112
+ }
113
+ }