@nuxt-customer-portal/invoices 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/app/components/InvoicesAdminInvoices.vue +27 -7
  3. package/app/components/InvoicesClientAccessPanel.vue +3 -1
  4. package/app/components/InvoicesClientSettingsPanel.vue +8 -3
  5. package/app/components/InvoicesInvoiceDetail.vue +47 -22
  6. package/app/components/InvoicesInvoiceEmailModal.vue +9 -17
  7. package/app/components/InvoicesSenderDetailsForm.vue +25 -4
  8. package/app/composables/useInvoices.ts +4 -2
  9. package/app/pages/admin/invoices/settings.vue +7 -1
  10. package/app/pages/invoices/[id].vue +9 -1
  11. package/app/pages/invoices/index.vue +11 -2
  12. package/app/pages/invoices/viewers.vue +4 -0
  13. package/i18n/locales/en.json +11 -2
  14. package/i18n/locales/nl.json +11 -2
  15. package/migrations/0001_commerce_invoices.sql +7 -0
  16. package/migrations/0002_sender_country.sql +2 -0
  17. package/migrations/meta/_journal.json +7 -0
  18. package/package.json +5 -5
  19. package/portal.manifest.mjs +1 -1
  20. package/server/api/invoices/admin/invoices/[id]/email-preview.get.ts +3 -1
  21. package/server/api/invoices/admin/invoices/[id]/email.post.ts +2 -1
  22. package/server/api/invoices/client/[accessId]/viewers.get.ts +4 -0
  23. package/server/api/invoices/client/[accessId]/viewers.put.ts +4 -0
  24. package/server/db/schema/invoices.ts +10 -2
  25. package/server/utils/commerce.ts +182 -0
  26. package/server/utils/invoice-email.ts +30 -9
  27. package/server/utils/invoice-pdf.ts +17 -5
  28. package/server/utils/invoice-repository.ts +51 -13
  29. package/server/utils/invoice-validation.ts +2 -0
  30. package/shared/feature.ts +77 -21
  31. package/shared/money.ts +2 -0
  32. package/shared/recipient-email.ts +4 -0
  33. package/shared/types/invoice.ts +9 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # @nuxt-customer-portal/invoices
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+ - 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.
9
+
10
+ ### Patch Changes
11
+
12
+ - 59c8921: Use compact currency symbols in invoice PDFs and give future product invoices a localized purchase-reference subject distinct from their product line descriptions.
13
+ - 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.
14
+
15
+ 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.
16
+
17
+ Allow users to edit first name, last name, and an independent display name in profile settings.
18
+
19
+ Synchronize display-name edits with the linked private client name and current billing name in the same transaction, preserving organizations and historical invoices.
20
+
21
+ Let private clients edit their own billing address in profile settings, independently of the active organization, with server-side ownership checks.
22
+
23
+ Private clients use their own invoice email without contact persons; hide contact management and reject private-client contact creation or selection server-side.
24
+
25
+ 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.
26
+
27
+ - 59c8921: Store the sender country as structured invoice data, show it in invoice settings, and preserve it on generated invoices.
28
+ - Updated dependencies [59c8921]
29
+ - Updated dependencies [59c8921]
30
+ - Updated dependencies [59c8921]
31
+ - Updated dependencies [59c8921]
32
+ - Updated dependencies [59c8921]
33
+ - Updated dependencies [59c8921]
34
+ - Updated dependencies [59c8921]
35
+ - Updated dependencies [59c8921]
36
+ - Updated dependencies [59c8921]
37
+ - @nuxt-customer-portal/core@0.4.0
38
+ - @nuxt-customer-portal/clients@0.4.0
39
+ - @nuxt-customer-portal/organizations@0.4.0
40
+ - @nuxt-customer-portal/ui@0.4.0
41
+
42
+ ## 0.3.3
43
+
44
+ ### Patch Changes
45
+
46
+ - @nuxt-customer-portal/clients@0.3.3
47
+ - @nuxt-customer-portal/core@0.3.3
48
+ - @nuxt-customer-portal/organizations@0.3.3
49
+ - @nuxt-customer-portal/ui@0.3.3
50
+
3
51
  ## 0.3.2
4
52
 
5
53
  ### 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) / 100)
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) * 100)
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
- !client.contacts.length && t('features.invoices.admin.contactPersons')
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 name="contactId" :label="t('features.invoices.admin.contact')">
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 / 100"
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">{{ t('features.invoices.clientAccess.contactsExplanation') }}</p>
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, { default: () => [] })
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">{{ t('features.invoices.save') }}</UButton>
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<{ invoice: InvoiceDto | ClientInvoiceDto; refresh: () => Promise<unknown>; mode?: 'admin' | 'client' }>(),
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({ paidOn: today, amount: props.invoice.outstandingMinor / 100, reference: '', note: '' })
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 / 100),
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(minor / 100)
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 * 100),
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 (!isClient.value && props.invoice.status === 'ISSUED' && !props.invoice.isOverdue) {
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
- onMounted(() => {
359
- if (!isClient.value) {
360
- void refreshEmailStatuses()
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
- {{ t('features.invoices.admin.invoiceTitle', { number: invoice.number }) }}
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="!isClient && invoice.status === 'ISSUED' && !invoice.isOverdue"
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">{{ t('features.invoices.save') }}</UButton>
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 / 100"
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<{ open: boolean; invoiceId: string; mode: EmailMode; refresh: () => Promise<unknown> }>()
8
- const emit = defineEmits<{ 'update:open': [value: boolean]; sent: [] }>()
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
- emit('update:open', false)
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
- watch(
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
- emit('update:open', false)
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)" @update:open="emit('update:open', $event)">
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="emit('update:open', false)">
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
- <UButton type="submit" block icon="i-lucide-save" :loading="busy">
225
- {{ t('features.invoices.save') }}
226
- </UButton>
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`, { query: { locale } }),
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 type="submit" icon="i-lucide-save" :loading="busy">{{ t('features.invoices.save') }}</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 v-else-if="invoice" :invoice="invoice" :refresh="refresh" mode="client" />
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 / 100)
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="!listing.items.value.length && !listing.pending.value"
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(