@nuxt-customer-portal/invoices 0.3.3 → 0.4.1
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 +50 -0
- package/app/components/InvoicesAdminInvoices.vue +27 -7
- package/app/components/InvoicesClientAccessPanel.vue +3 -1
- package/app/components/InvoicesClientSettingsPanel.vue +8 -3
- package/app/components/InvoicesInvoiceDetail.vue +47 -22
- package/app/components/InvoicesInvoiceEmailModal.vue +9 -17
- package/app/components/InvoicesSenderDetailsForm.vue +25 -4
- package/app/composables/useInvoices.ts +4 -2
- package/app/pages/admin/invoices/settings.vue +7 -1
- package/app/pages/invoices/[id].vue +9 -1
- package/app/pages/invoices/index.vue +11 -2
- package/app/pages/invoices/viewers.vue +4 -0
- package/i18n/locales/en.json +11 -2
- package/i18n/locales/nl.json +11 -2
- package/migrations/0001_commerce_invoices.sql +7 -0
- package/migrations/0002_sender_country.sql +2 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/portal.manifest.mjs +1 -1
- package/server/api/invoices/admin/invoices/[id]/email-preview.get.ts +3 -1
- package/server/api/invoices/admin/invoices/[id]/email.post.ts +2 -1
- package/server/api/invoices/client/[accessId]/viewers.get.ts +4 -0
- package/server/api/invoices/client/[accessId]/viewers.put.ts +4 -0
- package/server/db/schema/invoices.ts +10 -2
- package/server/utils/commerce.ts +182 -0
- package/server/utils/invoice-email.ts +30 -9
- package/server/utils/invoice-pdf.ts +17 -5
- package/server/utils/invoice-repository.ts +51 -13
- package/server/utils/invoice-validation.ts +2 -0
- package/shared/feature.ts +77 -21
- package/shared/money.ts +2 -0
- package/shared/recipient-email.ts +4 -0
- package/shared/types/invoice.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# @nuxt-customer-portal/invoices
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Keep fresh SaaS portal registration on the organization bootstrap flow. Disable public personal-client self-registration by default while preserving invitation-only personal-client access.
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @nuxt-customer-portal/clients@0.4.1
|
|
10
|
+
- @nuxt-customer-portal/core@0.4.1
|
|
11
|
+
- @nuxt-customer-portal/organizations@0.4.1
|
|
12
|
+
- @nuxt-customer-portal/ui@0.4.1
|
|
13
|
+
|
|
14
|
+
## 0.4.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- 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.
|
|
19
|
+
- 59c8921: Replace the unused single-product purchase record with multi-item-ready orders, order lines, carts, and cart lines while preserving the current one-product checkout experience. Generate invoice lines from immutable order lines.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 59c8921: Use compact currency symbols in invoice PDFs and give future product invoices a localized purchase-reference subject distinct from their product line descriptions.
|
|
24
|
+
- 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.
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
Allow users to edit first name, last name, and an independent display name in profile settings.
|
|
29
|
+
|
|
30
|
+
Synchronize display-name edits with the linked private client name and current billing name in the same transaction, preserving organizations and historical invoices.
|
|
31
|
+
|
|
32
|
+
Let private clients edit their own billing address in profile settings, independently of the active organization, with server-side ownership checks.
|
|
33
|
+
|
|
34
|
+
Private clients use their own invoice email without contact persons; hide contact management and reject private-client contact creation or selection server-side.
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
- 59c8921: Store the sender country as structured invoice data, show it in invoice settings, and preserve it on generated invoices.
|
|
39
|
+
- Updated dependencies [59c8921]
|
|
40
|
+
- Updated dependencies [59c8921]
|
|
41
|
+
- Updated dependencies [59c8921]
|
|
42
|
+
- Updated dependencies [59c8921]
|
|
43
|
+
- Updated dependencies [59c8921]
|
|
44
|
+
- Updated dependencies [59c8921]
|
|
45
|
+
- Updated dependencies [59c8921]
|
|
46
|
+
- Updated dependencies [59c8921]
|
|
47
|
+
- Updated dependencies [59c8921]
|
|
48
|
+
- @nuxt-customer-portal/core@0.4.0
|
|
49
|
+
- @nuxt-customer-portal/clients@0.4.0
|
|
50
|
+
- @nuxt-customer-portal/organizations@0.4.0
|
|
51
|
+
- @nuxt-customer-portal/ui@0.4.0
|
|
52
|
+
|
|
3
53
|
## 0.3.3
|
|
4
54
|
|
|
5
55
|
### Patch Changes
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { currencyScale } from '@nuxt-customer-portal/invoices/shared/money'
|
|
2
3
|
import { z } from 'zod'
|
|
3
4
|
import { createHoursIntroduction } from '@nuxt-customer-portal/invoices/shared/invoice-introduction'
|
|
4
5
|
import type { InvoicesAdminBootstrap } from '@nuxt-customer-portal/invoices/app/composables/useInvoices'
|
|
@@ -121,7 +122,7 @@ const invoiceSchema = computed(() =>
|
|
|
121
122
|
})
|
|
122
123
|
)
|
|
123
124
|
const money = (minor: unknown, currency = props.data.settings.currency) =>
|
|
124
|
-
new Intl.NumberFormat(locale.value, { style: 'currency', currency }).format(Number(minor) /
|
|
125
|
+
new Intl.NumberFormat(locale.value, { style: 'currency', currency }).format(Number(minor) / currencyScale(currency))
|
|
125
126
|
const dateTime = (value: string) =>
|
|
126
127
|
new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
|
127
128
|
const quantityFormat: Intl.NumberFormatOptions = { minimumFractionDigits: 2, maximumFractionDigits: 3 }
|
|
@@ -140,7 +141,7 @@ const updateQuantity = (line: (typeof model.lines)[number], value: number | null
|
|
|
140
141
|
line.quantityMilli = Math.round(Number(value ?? 0) * 1000)
|
|
141
142
|
}
|
|
142
143
|
const updateUnitPrice = (line: (typeof model.lines)[number], value: number | null | undefined) => {
|
|
143
|
-
line.unitPriceMinor = Math.round(Number(value ?? 0) *
|
|
144
|
+
line.unitPriceMinor = Math.round(Number(value ?? 0) * currencyScale(model.currency))
|
|
144
145
|
}
|
|
145
146
|
const updateVatRate = (line: (typeof model.lines)[number], value: number | null | undefined) => {
|
|
146
147
|
line.vatRateBasisPoints = Math.round(Number(value ?? 0) * 10_000)
|
|
@@ -183,7 +184,9 @@ const missingClientInvoiceDetails = computed(() => {
|
|
|
183
184
|
}
|
|
184
185
|
const missing = [
|
|
185
186
|
!client.address.trim() && t('features.invoices.admin.address'),
|
|
186
|
-
|
|
187
|
+
client.clientType === 'person'
|
|
188
|
+
? !client.invoiceEmail?.trim() && t('features.invoices.admin.email')
|
|
189
|
+
: !client.contacts.length && t('features.invoices.admin.contactPersons')
|
|
187
190
|
]
|
|
188
191
|
return missing.filter((item): item is string => Boolean(item))
|
|
189
192
|
})
|
|
@@ -192,6 +195,7 @@ const senderInvoiceDetailsComplete = computed(() => {
|
|
|
192
195
|
const profile = props.data.organizationProfile
|
|
193
196
|
return [
|
|
194
197
|
profile.address,
|
|
198
|
+
profile.country,
|
|
195
199
|
profile.registrationNumber,
|
|
196
200
|
profile.vatNumber,
|
|
197
201
|
profile.iban,
|
|
@@ -250,7 +254,8 @@ const chooseClient = (id: string) => {
|
|
|
250
254
|
}
|
|
251
255
|
const editSelectedClient = async () => {
|
|
252
256
|
await navigateTo({
|
|
253
|
-
path: `/clients/${selectedClient.value?.organizationId}
|
|
257
|
+
path: `/clients/${selectedClient.value?.organizationId}`,
|
|
258
|
+
query: { edit: 'true' }
|
|
254
259
|
})
|
|
255
260
|
}
|
|
256
261
|
const groupEntries = (entries: InvoiceableEntryDto[]) => {
|
|
@@ -378,8 +383,16 @@ if (props.createPage) {
|
|
|
378
383
|
@sort="listing.sortBy.value = $event"
|
|
379
384
|
@toggle-direction="listing.toggleSortDir"
|
|
380
385
|
/>
|
|
386
|
+
<UAlert
|
|
387
|
+
v-if="!createPage && !formOpen && listing.error.value"
|
|
388
|
+
color="error"
|
|
389
|
+
variant="outline"
|
|
390
|
+
icon="i-lucide-circle-alert"
|
|
391
|
+
:title="t('features.invoices.messages.fetchError')"
|
|
392
|
+
:description="String(listing.error.value.message)"
|
|
393
|
+
/>
|
|
381
394
|
<InvoicesAdminEmptyState
|
|
382
|
-
v-if="!createPage && !formOpen && !senderInvoiceDetailsComplete"
|
|
395
|
+
v-else-if="!createPage && !formOpen && !senderInvoiceDetailsComplete"
|
|
383
396
|
icon="i-lucide-file-warning"
|
|
384
397
|
:title="t('features.invoices.admin.senderDetailsRequiredTitle')"
|
|
385
398
|
:description="t('features.invoices.admin.senderDetailsRequiredDescription')"
|
|
@@ -546,7 +559,11 @@ if (props.createPage) {
|
|
|
546
559
|
<UFormField :label="t('features.invoices.admin.client')">
|
|
547
560
|
<UInput :model-value="selectedClient?.name" disabled class="w-full" />
|
|
548
561
|
</UFormField>
|
|
549
|
-
<UFormField
|
|
562
|
+
<UFormField
|
|
563
|
+
v-if="selectedClient?.clientType !== 'person'"
|
|
564
|
+
name="contactId"
|
|
565
|
+
:label="t('features.invoices.admin.contact')"
|
|
566
|
+
>
|
|
550
567
|
<USelect
|
|
551
568
|
v-model="model.contactId"
|
|
552
569
|
:items="
|
|
@@ -557,6 +574,9 @@ if (props.createPage) {
|
|
|
557
574
|
class="w-full"
|
|
558
575
|
/>
|
|
559
576
|
</UFormField>
|
|
577
|
+
<UFormField v-if="selectedClient?.clientType === 'person'" :label="t('features.invoices.admin.email')">
|
|
578
|
+
<UInput :model-value="selectedClient.invoiceEmail ?? ''" disabled class="w-full" />
|
|
579
|
+
</UFormField>
|
|
560
580
|
<UFormField :label="t('features.invoices.admin.currency')">
|
|
561
581
|
<UInput v-model="model.currency" disabled class="w-full" />
|
|
562
582
|
</UFormField>
|
|
@@ -607,7 +627,7 @@ if (props.createPage) {
|
|
|
607
627
|
:label="t('features.invoices.admin.unitPrice')"
|
|
608
628
|
>
|
|
609
629
|
<UInputNumber
|
|
610
|
-
:model-value="line.unitPriceMinor /
|
|
630
|
+
:model-value="line.unitPriceMinor / currencyScale(model.currency)"
|
|
611
631
|
:min="0"
|
|
612
632
|
:step="0.01"
|
|
613
633
|
:format-options="currencyFormat"
|
|
@@ -57,7 +57,9 @@ const { data: access, error } = await useAsyncData(
|
|
|
57
57
|
t(`features.invoices.clientAccess.${member.canView ? 'granted' : 'denied'}`)
|
|
58
58
|
}}</UBadge>
|
|
59
59
|
</div>
|
|
60
|
-
<p class="text-sm text-muted">
|
|
60
|
+
<p v-if="client.clientType !== 'person'" class="text-sm text-muted">
|
|
61
|
+
{{ t('features.invoices.clientAccess.contactsExplanation') }}
|
|
62
|
+
</p>
|
|
61
63
|
</div>
|
|
62
64
|
</UCard>
|
|
63
65
|
</template>
|
|
@@ -35,7 +35,10 @@ const schema = computed(() =>
|
|
|
35
35
|
})
|
|
36
36
|
)
|
|
37
37
|
const endpoint = `/api/invoices/admin/clients/${props.client.organizationId}/contacts`
|
|
38
|
-
const { data: contacts, refresh } = await useFetch<InvoiceContactDto[]>(endpoint, {
|
|
38
|
+
const { data: contacts, refresh } = await useFetch<InvoiceContactDto[]>(endpoint, {
|
|
39
|
+
default: () => [],
|
|
40
|
+
immediate: props.client.clientType !== 'person'
|
|
41
|
+
})
|
|
39
42
|
const reset = () => {
|
|
40
43
|
editingId.value = ''
|
|
41
44
|
formOpen.value = false
|
|
@@ -77,7 +80,7 @@ const remove = async (contact: InvoiceContactDto) => {
|
|
|
77
80
|
</script>
|
|
78
81
|
|
|
79
82
|
<template>
|
|
80
|
-
<div class="space-y-6">
|
|
83
|
+
<div v-if="client.clientType !== 'person'" class="space-y-6">
|
|
81
84
|
<InvoicesClientAccessPanel :client="client" />
|
|
82
85
|
<UCard>
|
|
83
86
|
<template #header>
|
|
@@ -141,7 +144,9 @@ const remove = async (contact: InvoiceContactDto) => {
|
|
|
141
144
|
<div class="flex justify-end gap-2 md:col-span-2">
|
|
142
145
|
<UButton type="button" color="neutral" variant="outline" @click="reset">
|
|
143
146
|
{{ t('features.invoices.cancel') }} </UButton
|
|
144
|
-
><UButton type="submit" :loading="busy">{{
|
|
147
|
+
><UButton type="submit" icon="i-lucide-save" class="ml-auto flex min-w-28 justify-center" :loading="busy">{{
|
|
148
|
+
t('features.invoices.save')
|
|
149
|
+
}}</UButton>
|
|
145
150
|
</div>
|
|
146
151
|
</UForm>
|
|
147
152
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { currencyScale } from '@nuxt-customer-portal/invoices/shared/money'
|
|
2
3
|
import { z } from 'zod'
|
|
3
4
|
import type {
|
|
4
5
|
ClientInvoiceDto,
|
|
@@ -8,11 +9,19 @@ import type {
|
|
|
8
9
|
import { isKnownEmailProviderEvent } from '@nuxt-customer-portal/invoices/shared/email-delivery-status'
|
|
9
10
|
|
|
10
11
|
const props = withDefaults(
|
|
11
|
-
defineProps<{
|
|
12
|
+
defineProps<{
|
|
13
|
+
invoice: InvoiceDto | ClientInvoiceDto
|
|
14
|
+
refresh: () => Promise<unknown>
|
|
15
|
+
mode?: 'admin' | 'client'
|
|
16
|
+
backTo?: string
|
|
17
|
+
backLabel?: string
|
|
18
|
+
}>(),
|
|
12
19
|
{ mode: 'admin' }
|
|
13
20
|
)
|
|
14
21
|
const isClient = computed(() => props.mode === 'client')
|
|
15
22
|
const { t, locale } = useI18n()
|
|
23
|
+
const countryName = (country: string | null) =>
|
|
24
|
+
country ? new Intl.DisplayNames(locale.value, { type: 'region' }).of(country) || country : ''
|
|
16
25
|
const portalRuntimeSettings = useState<{
|
|
17
26
|
branding?: { portalName?: string; markLight?: string; markDark?: string }
|
|
18
27
|
} | null>('portal-runtime-settings', () => null)
|
|
@@ -38,13 +47,21 @@ const paymentOpen = ref(false)
|
|
|
38
47
|
const editOpen = ref(false)
|
|
39
48
|
const emailOpen = ref(false)
|
|
40
49
|
const emailMode = ref<'issue' | 'resend' | 'reminder'>('issue')
|
|
50
|
+
const canResendInvoice = computed(
|
|
51
|
+
() => !isClient.value && (props.invoice.status === 'ISSUED' || props.invoice.status === 'PAID')
|
|
52
|
+
)
|
|
41
53
|
const attachment = ref<File | null>(null)
|
|
42
54
|
const attachmentDeletion = ref<{ id: string; name: string } | null>(null)
|
|
43
55
|
const attachmentDeleteOpen = ref(false)
|
|
44
56
|
const statusConfirmation = ref<'VOID' | 'UNVOID' | null>(null)
|
|
45
57
|
const statusConfirmationOpen = ref(false)
|
|
46
58
|
const today = new Date().toISOString().slice(0, 10)
|
|
47
|
-
const payment = reactive({
|
|
59
|
+
const payment = reactive({
|
|
60
|
+
paidOn: today,
|
|
61
|
+
amount: props.invoice.outstandingMinor / currencyScale(props.invoice.currency),
|
|
62
|
+
reference: '',
|
|
63
|
+
note: ''
|
|
64
|
+
})
|
|
48
65
|
const edit = reactive({
|
|
49
66
|
number: props.invoice.number,
|
|
50
67
|
issueDate: props.invoice.issueDate,
|
|
@@ -64,7 +81,7 @@ const paymentSchema = computed(() =>
|
|
|
64
81
|
amount: z
|
|
65
82
|
.number()
|
|
66
83
|
.positive(t('features.invoices.validation.positiveAmount'))
|
|
67
|
-
.max(props.invoice.outstandingMinor /
|
|
84
|
+
.max(props.invoice.outstandingMinor / currencyScale(props.invoice.currency)),
|
|
68
85
|
reference: z.string().trim().max(200),
|
|
69
86
|
note: z.string().trim().max(1000)
|
|
70
87
|
})
|
|
@@ -89,7 +106,9 @@ const editSchema = computed(() =>
|
|
|
89
106
|
})
|
|
90
107
|
)
|
|
91
108
|
const money = (minor: number) =>
|
|
92
|
-
new Intl.NumberFormat(locale.value, { style: 'currency', currency: props.invoice.currency }).format(
|
|
109
|
+
new Intl.NumberFormat(locale.value, { style: 'currency', currency: props.invoice.currency }).format(
|
|
110
|
+
minor / currencyScale(props.invoice.currency)
|
|
111
|
+
)
|
|
93
112
|
const number = (value: number) =>
|
|
94
113
|
new Intl.NumberFormat(locale.value, { minimumFractionDigits: 2, maximumFractionDigits: 3 }).format(value / 1000)
|
|
95
114
|
const percentage = (basisPoints: number) =>
|
|
@@ -230,7 +249,7 @@ const savePayment = async () => {
|
|
|
230
249
|
() =>
|
|
231
250
|
api.registerInvoicePayment(props.invoice.id, {
|
|
232
251
|
paidOn: payment.paidOn,
|
|
233
|
-
amountMinor: Math.round(payment.amount *
|
|
252
|
+
amountMinor: Math.round(payment.amount * currencyScale(props.invoice.currency)),
|
|
234
253
|
reference: payment.reference || null,
|
|
235
254
|
note: payment.note || null
|
|
236
255
|
}),
|
|
@@ -300,7 +319,7 @@ const mobileMenuItems = computed(() => {
|
|
|
300
319
|
onSelect: () => openEmail('issue')
|
|
301
320
|
})
|
|
302
321
|
}
|
|
303
|
-
if (
|
|
322
|
+
if (canResendInvoice.value) {
|
|
304
323
|
items.push({
|
|
305
324
|
label: t('features.invoices.admin.resendInvoice'),
|
|
306
325
|
icon: 'i-lucide-mail',
|
|
@@ -355,11 +374,9 @@ const refreshEmailStatuses = async (forceRefresh = false) => {
|
|
|
355
374
|
}
|
|
356
375
|
}
|
|
357
376
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
})
|
|
377
|
+
if (!isClient.value) {
|
|
378
|
+
await refreshEmailStatuses()
|
|
379
|
+
}
|
|
363
380
|
</script>
|
|
364
381
|
|
|
365
382
|
<template>
|
|
@@ -367,17 +384,24 @@ onMounted(() => {
|
|
|
367
384
|
<header class="invoice-detail-header">
|
|
368
385
|
<div class="min-w-0">
|
|
369
386
|
<UButton
|
|
370
|
-
:to="isClient ? '/invoices' : '/admin/invoices'"
|
|
387
|
+
:to="backTo || (isClient ? '/invoices' : '/admin/invoices')"
|
|
371
388
|
variant="link"
|
|
372
389
|
color="neutral"
|
|
373
390
|
icon="i-lucide-arrow-left"
|
|
374
391
|
class="invoice-back-link mb-2 px-0"
|
|
375
392
|
>
|
|
376
|
-
{{ t('features.invoices.admin.backToInvoices') }}
|
|
393
|
+
{{ backLabel || t('features.invoices.admin.backToInvoices') }}
|
|
377
394
|
</UButton>
|
|
378
395
|
<div class="invoice-title-line">
|
|
379
396
|
<h1 class="text-2xl font-semibold text-highlighted">
|
|
380
|
-
{{
|
|
397
|
+
{{
|
|
398
|
+
t(
|
|
399
|
+
invoice.documentType === 'credit'
|
|
400
|
+
? 'features.invoices.admin.creditTitle'
|
|
401
|
+
: 'features.invoices.admin.invoiceTitle',
|
|
402
|
+
{ number: invoice.number }
|
|
403
|
+
)
|
|
404
|
+
}}
|
|
381
405
|
</h1>
|
|
382
406
|
<div class="invoice-status-badges">
|
|
383
407
|
<UBadge :color="statusColor" variant="subtle">
|
|
@@ -416,7 +440,7 @@ onMounted(() => {
|
|
|
416
440
|
{{ t('features.invoices.admin.issueAndSend') }}
|
|
417
441
|
</UButton>
|
|
418
442
|
<UButton
|
|
419
|
-
v-if="
|
|
443
|
+
v-if="canResendInvoice"
|
|
420
444
|
class="invoice-actions-wide"
|
|
421
445
|
icon="i-lucide-mail"
|
|
422
446
|
variant="outline"
|
|
@@ -470,7 +494,7 @@ onMounted(() => {
|
|
|
470
494
|
</header>
|
|
471
495
|
|
|
472
496
|
<InvoicesInvoiceEmailModal
|
|
473
|
-
v-if="!isClient"
|
|
497
|
+
v-if="!isClient && emailOpen"
|
|
474
498
|
v-model:open="emailOpen"
|
|
475
499
|
:invoice-id="invoice.id"
|
|
476
500
|
:mode="emailMode"
|
|
@@ -541,7 +565,9 @@ onMounted(() => {
|
|
|
541
565
|
<div class="flex justify-end gap-2">
|
|
542
566
|
<UButton type="button" color="neutral" variant="ghost" @click="editOpen = false">
|
|
543
567
|
{{ t('features.invoices.cancel') }} </UButton
|
|
544
|
-
><UButton type="submit" icon="i-lucide-save" :loading="busy">{{
|
|
568
|
+
><UButton type="submit" icon="i-lucide-save" class="ml-auto flex min-w-28 justify-center" :loading="busy">{{
|
|
569
|
+
t('features.invoices.save')
|
|
570
|
+
}}</UButton>
|
|
545
571
|
</div>
|
|
546
572
|
</UForm>
|
|
547
573
|
</UCard>
|
|
@@ -560,7 +586,7 @@ onMounted(() => {
|
|
|
560
586
|
<UInputNumber
|
|
561
587
|
v-model="payment.amount"
|
|
562
588
|
:min="0.01"
|
|
563
|
-
:max="invoice.outstandingMinor /
|
|
589
|
+
:max="invoice.outstandingMinor / currencyScale(invoice.currency)"
|
|
564
590
|
:step="0.01"
|
|
565
591
|
:increment="false"
|
|
566
592
|
:decrement="false"
|
|
@@ -609,6 +635,7 @@ onMounted(() => {
|
|
|
609
635
|
<span v-for="(line, index) in addressLines(invoice.senderAddress)" :key="index" class="block">{{
|
|
610
636
|
line
|
|
611
637
|
}}</span>
|
|
638
|
+
<span v-if="invoice.senderCountry" class="block">{{ countryName(invoice.senderCountry) }}</span>
|
|
612
639
|
</address>
|
|
613
640
|
</div>
|
|
614
641
|
</div>
|
|
@@ -806,7 +833,7 @@ onMounted(() => {
|
|
|
806
833
|
</div>
|
|
807
834
|
</section>
|
|
808
835
|
<ConfirmationModal
|
|
809
|
-
v-if="!isClient"
|
|
836
|
+
v-if="!isClient && attachmentDeleteOpen"
|
|
810
837
|
v-model:open="attachmentDeleteOpen"
|
|
811
838
|
:title="t('features.invoices.admin.removeAttachment')"
|
|
812
839
|
:message="t('features.invoices.admin.removeAttachmentDescription', { name: attachmentDeletion?.name })"
|
|
@@ -814,10 +841,9 @@ onMounted(() => {
|
|
|
814
841
|
:cancel-text="t('features.invoices.cancel')"
|
|
815
842
|
confirm-color="error"
|
|
816
843
|
@confirm="removeAttachment"
|
|
817
|
-
@cancel="attachmentDeletion = null"
|
|
818
844
|
/>
|
|
819
845
|
<ConfirmationModal
|
|
820
|
-
v-if="!isClient"
|
|
846
|
+
v-if="!isClient && statusConfirmationOpen"
|
|
821
847
|
v-model:open="statusConfirmationOpen"
|
|
822
848
|
:title="
|
|
823
849
|
t(
|
|
@@ -840,7 +866,6 @@ onMounted(() => {
|
|
|
840
866
|
:cancel-text="t('features.invoices.cancel')"
|
|
841
867
|
:confirm-color="statusConfirmation === 'VOID' ? 'error' : 'primary'"
|
|
842
868
|
@confirm="confirmStatusAction"
|
|
843
|
-
@cancel="statusConfirmation = null"
|
|
844
869
|
/>
|
|
845
870
|
</div>
|
|
846
871
|
</template>
|
|
@@ -4,8 +4,9 @@ import type { InvoiceEmailPreviewDto } from '@nuxt-customer-portal/invoices/shar
|
|
|
4
4
|
|
|
5
5
|
type EmailMode = 'issue' | 'resend' | 'reminder'
|
|
6
6
|
|
|
7
|
-
const props = defineProps<{
|
|
8
|
-
const
|
|
7
|
+
const props = defineProps<{ invoiceId: string; mode: EmailMode; refresh: () => Promise<unknown> }>()
|
|
8
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
9
|
+
const emit = defineEmits<{ sent: [] }>()
|
|
9
10
|
const { t } = useI18n()
|
|
10
11
|
const api = useInvoices()
|
|
11
12
|
const toast = useToast()
|
|
@@ -49,7 +50,7 @@ const loadPreview = async (locale?: string) => {
|
|
|
49
50
|
const result =
|
|
50
51
|
props.mode === 'reminder'
|
|
51
52
|
? await api.getInvoiceReminderPreview(props.invoiceId, locale)
|
|
52
|
-
: await api.getInvoiceEmailPreview(props.invoiceId, locale)
|
|
53
|
+
: await api.getInvoiceEmailPreview(props.invoiceId, locale, props.mode === 'resend' ? 'resend' : undefined)
|
|
53
54
|
preview.value = result
|
|
54
55
|
Object.assign(draft, {
|
|
55
56
|
to: result.to,
|
|
@@ -59,22 +60,13 @@ const loadPreview = async (locale?: string) => {
|
|
|
59
60
|
body: result.body
|
|
60
61
|
})
|
|
61
62
|
} catch (error) {
|
|
62
|
-
|
|
63
|
+
open.value = false
|
|
63
64
|
toast.add({ title: t('features.invoices.messages.saveError'), description: String(error), color: 'error' })
|
|
64
65
|
} finally {
|
|
65
66
|
loadingPreview.value = false
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
|
-
|
|
69
|
-
() => props.open,
|
|
70
|
-
(open) => {
|
|
71
|
-
if (open) {
|
|
72
|
-
void loadPreview()
|
|
73
|
-
} else {
|
|
74
|
-
preview.value = null
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
)
|
|
69
|
+
await loadPreview()
|
|
78
70
|
const send = async () => {
|
|
79
71
|
busy.value = true
|
|
80
72
|
try {
|
|
@@ -95,7 +87,7 @@ const send = async () => {
|
|
|
95
87
|
await props.refresh()
|
|
96
88
|
toast.add({ title: t(successKey.value), color: 'success' })
|
|
97
89
|
emit('sent')
|
|
98
|
-
|
|
90
|
+
open.value = false
|
|
99
91
|
} catch (error) {
|
|
100
92
|
toast.add({ title: t('features.invoices.messages.saveError'), description: String(error), color: 'error' })
|
|
101
93
|
} finally {
|
|
@@ -105,7 +97,7 @@ const send = async () => {
|
|
|
105
97
|
</script>
|
|
106
98
|
|
|
107
99
|
<template>
|
|
108
|
-
<UModal :open="open" :title="t(titleKey)"
|
|
100
|
+
<UModal v-if="open" v-model:open="open" :title="t(titleKey)">
|
|
109
101
|
<template #body>
|
|
110
102
|
<div v-if="loadingPreview" class="flex justify-center py-10" role="status">
|
|
111
103
|
<UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" /><span class="sr-only">{{
|
|
@@ -198,7 +190,7 @@ const send = async () => {
|
|
|
198
190
|
</ul>
|
|
199
191
|
</div>
|
|
200
192
|
<div class="flex justify-end gap-2">
|
|
201
|
-
<UButton type="button" color="neutral" variant="ghost" @click="
|
|
193
|
+
<UButton type="button" color="neutral" variant="ghost" @click="open = false">
|
|
202
194
|
{{ t('features.invoices.cancel') }} </UButton
|
|
203
195
|
><UButton
|
|
204
196
|
type="submit"
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import type { OrganizationInvoiceProfileDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
|
|
4
|
+
import { countryCodes } from '@nuxt-customer-portal/core/shared/countries'
|
|
4
5
|
|
|
5
6
|
const props = defineProps<{
|
|
6
7
|
profile: OrganizationInvoiceProfileDto
|
|
7
8
|
refresh: () => Promise<unknown>
|
|
8
9
|
}>()
|
|
9
10
|
|
|
10
|
-
const { t } = useI18n()
|
|
11
|
+
const { t, locale } = useI18n()
|
|
11
12
|
const toast = useToast()
|
|
12
13
|
const invoices = useInvoices()
|
|
13
14
|
const { isSystemAdmin } = usePortalSession()
|
|
@@ -20,6 +21,7 @@ const { data: domainStatus, refresh: refreshDomainStatus } = await useFetch<Doma
|
|
|
20
21
|
)
|
|
21
22
|
const draft = reactive({
|
|
22
23
|
address: '',
|
|
24
|
+
country: '',
|
|
23
25
|
registrationNumber: '',
|
|
24
26
|
vatNumber: '',
|
|
25
27
|
iban: '',
|
|
@@ -29,6 +31,7 @@ const draft = reactive({
|
|
|
29
31
|
const schema = computed(() =>
|
|
30
32
|
z.object({
|
|
31
33
|
address: z.string().trim().min(1, t('features.invoices.validation.required')).max(1000),
|
|
34
|
+
country: z.string().refine((value) => countryCodes.includes(value), t('features.invoices.validation.required')),
|
|
32
35
|
registrationNumber: z.string().trim().min(1, t('features.invoices.validation.required')).max(200),
|
|
33
36
|
vatNumber: z.string().trim().min(1, t('features.invoices.validation.required')).max(100),
|
|
34
37
|
iban: z.string().trim().min(1, t('features.invoices.validation.required')).max(100),
|
|
@@ -41,6 +44,12 @@ const schema = computed(() =>
|
|
|
41
44
|
.max(320)
|
|
42
45
|
})
|
|
43
46
|
)
|
|
47
|
+
const countryOptions = computed(() => {
|
|
48
|
+
const names = new Intl.DisplayNames(locale.value, { type: 'region' })
|
|
49
|
+
return countryCodes
|
|
50
|
+
.map((value) => ({ value, label: `${names.of(value) || value} (${value})` }))
|
|
51
|
+
.sort((a, b) => a.label.localeCompare(b.label))
|
|
52
|
+
})
|
|
44
53
|
|
|
45
54
|
const checkDomain = async (forceRefresh = true) => {
|
|
46
55
|
checkingDomain.value = true
|
|
@@ -73,6 +82,7 @@ watch(
|
|
|
73
82
|
(profile) => {
|
|
74
83
|
Object.assign(draft, {
|
|
75
84
|
address: profile.address,
|
|
85
|
+
country: profile.country ?? '',
|
|
76
86
|
registrationNumber: profile.registrationNumber ?? '',
|
|
77
87
|
vatNumber: profile.vatNumber ?? '',
|
|
78
88
|
iban: profile.iban ?? '',
|
|
@@ -204,6 +214,15 @@ const save = async () => {
|
|
|
204
214
|
<UFormField name="address" :label="t('features.invoices.admin.address')" required>
|
|
205
215
|
<UTextarea v-model="draft.address" class="w-full" />
|
|
206
216
|
</UFormField>
|
|
217
|
+
<UFormField name="country" :label="t('features.invoices.admin.country')" required>
|
|
218
|
+
<USelectMenu
|
|
219
|
+
v-model="draft.country"
|
|
220
|
+
:items="countryOptions"
|
|
221
|
+
value-key="value"
|
|
222
|
+
:placeholder="t('features.invoices.admin.selectCountry')"
|
|
223
|
+
class="w-full"
|
|
224
|
+
/>
|
|
225
|
+
</UFormField>
|
|
207
226
|
<div class="grid gap-3 md:grid-cols-2">
|
|
208
227
|
<UFormField name="registrationNumber" :label="t('features.invoices.admin.registration')" required>
|
|
209
228
|
<UInput v-model="draft.registrationNumber" class="w-full" />
|
|
@@ -221,9 +240,11 @@ const save = async () => {
|
|
|
221
240
|
<UInput v-model="draft.invoiceEmail" type="email" class="w-full" />
|
|
222
241
|
</UFormField>
|
|
223
242
|
</div>
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
243
|
+
<div class="flex justify-end">
|
|
244
|
+
<UButton type="submit" icon="i-lucide-save" class="ml-auto flex min-w-28 justify-center" :loading="busy">
|
|
245
|
+
{{ t('features.invoices.save') }}
|
|
246
|
+
</UButton>
|
|
247
|
+
</div>
|
|
227
248
|
</UForm>
|
|
228
249
|
</UCard>
|
|
229
250
|
</template>
|
|
@@ -30,8 +30,10 @@ export const useInvoices = () => ({
|
|
|
30
30
|
$fetch(`/api/invoices/admin/invoices/${id}`, { method: 'PATCH' as never, body: input }),
|
|
31
31
|
changeInvoiceStatus: (id: string, action: 'VOID' | 'UNVOID') =>
|
|
32
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`, {
|
|
33
|
+
getInvoiceEmailPreview: (id: string, locale?: string, purpose?: 'resend') =>
|
|
34
|
+
$fetch<InvoiceEmailPreviewDto>(`/api/invoices/admin/invoices/${id}/email-preview`, {
|
|
35
|
+
query: { locale, purpose }
|
|
36
|
+
}),
|
|
35
37
|
issueAndSendInvoice: (id: string, input: Record<string, unknown>) =>
|
|
36
38
|
$fetch(`/api/invoices/admin/invoices/${id}/issue`, { method: 'POST', body: input }),
|
|
37
39
|
resendInvoice: (id: string, input: Record<string, unknown>) =>
|
|
@@ -70,7 +70,13 @@ const saveGeneral = async () => {
|
|
|
70
70
|
<UInputNumber v-model="general.defaultVatRate" :min="0" :max="100" :step="0.01" class="w-full" />
|
|
71
71
|
</UFormField>
|
|
72
72
|
<div class="flex items-end justify-end">
|
|
73
|
-
<UButton
|
|
73
|
+
<UButton
|
|
74
|
+
type="submit"
|
|
75
|
+
icon="i-lucide-save"
|
|
76
|
+
class="ml-auto flex min-w-28 justify-center"
|
|
77
|
+
:loading="busy"
|
|
78
|
+
>{{ t('features.invoices.save') }}</UButton
|
|
79
|
+
>
|
|
74
80
|
</div>
|
|
75
81
|
</form>
|
|
76
82
|
</UCard>
|
|
@@ -3,6 +3,7 @@ const route = useRoute()
|
|
|
3
3
|
const { t } = useI18n()
|
|
4
4
|
const api = useInvoices()
|
|
5
5
|
const invoiceId = computed(() => String(route.params.id))
|
|
6
|
+
const fromOrder = computed(() => route.query.from === 'orders')
|
|
6
7
|
const {
|
|
7
8
|
data: invoice,
|
|
8
9
|
pending,
|
|
@@ -32,6 +33,13 @@ useSeoMeta({
|
|
|
32
33
|
:title="t('features.invoices.admin.invoiceNotFound')"
|
|
33
34
|
variant="outline"
|
|
34
35
|
/>
|
|
35
|
-
<InvoicesInvoiceDetail
|
|
36
|
+
<InvoicesInvoiceDetail
|
|
37
|
+
v-else-if="invoice"
|
|
38
|
+
:invoice="invoice"
|
|
39
|
+
:refresh="refresh"
|
|
40
|
+
mode="client"
|
|
41
|
+
:back-to="fromOrder ? '/purchases' : undefined"
|
|
42
|
+
:back-label="fromOrder ? t('features.invoices.admin.backToOrders') : undefined"
|
|
43
|
+
/>
|
|
36
44
|
</InvoicesPageShell>
|
|
37
45
|
</template>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { currencyScale } from '@nuxt-customer-portal/invoices/shared/money'
|
|
2
3
|
import type { ClientInvoiceSummaryDto, InvoiceStatus } from '@nuxt-customer-portal/invoices/shared/types/invoice'
|
|
3
4
|
|
|
4
5
|
const { t, locale } = useI18n()
|
|
@@ -45,7 +46,7 @@ const sortOptions = computed(() => [
|
|
|
45
46
|
{ label: t('features.invoices.admin.list.sortTotal'), value: 'totalMinor' }
|
|
46
47
|
])
|
|
47
48
|
const money = (minor: number, currency: string) =>
|
|
48
|
-
new Intl.NumberFormat(locale.value, { style: 'currency', currency }).format(minor /
|
|
49
|
+
new Intl.NumberFormat(locale.value, { style: 'currency', currency }).format(minor / currencyScale(currency))
|
|
49
50
|
const date = (value: string) =>
|
|
50
51
|
new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(`${value}T12:00:00`))
|
|
51
52
|
const statusColor = (status: InvoiceStatus) => (status === 'PAID' ? 'success' : 'info')
|
|
@@ -88,7 +89,15 @@ await listing.load()
|
|
|
88
89
|
@page="listing.goToPage"
|
|
89
90
|
>
|
|
90
91
|
<UAlert
|
|
91
|
-
v-if="
|
|
92
|
+
v-if="listing.error.value"
|
|
93
|
+
color="error"
|
|
94
|
+
variant="outline"
|
|
95
|
+
icon="i-lucide-circle-alert"
|
|
96
|
+
:title="t('features.invoices.messages.fetchError')"
|
|
97
|
+
:description="String(listing.error.value.message)"
|
|
98
|
+
/>
|
|
99
|
+
<UAlert
|
|
100
|
+
v-else-if="!listing.items.value.length && !listing.pending.value"
|
|
92
101
|
icon="i-lucide-receipt-text"
|
|
93
102
|
:title="t('features.invoices.clientInvoices.empty')"
|
|
94
103
|
:description="t('features.invoices.clientInvoices.emptyDescription')"
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const { t } = useI18n()
|
|
3
|
+
const { activeOrganizationIsPersonal } = usePortalSession()
|
|
4
|
+
if (activeOrganizationIsPersonal.value) {
|
|
5
|
+
await navigateTo('/invoices')
|
|
6
|
+
}
|
|
3
7
|
const api = useInvoices()
|
|
4
8
|
const toast = useToast()
|
|
5
9
|
const { data: suppliers, refresh: refreshSuppliers } = await useAsyncData(
|