@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,36 @@
1
+ <script setup lang="ts">
2
+ defineOptions({ name: 'AdminInvoiceDetailPage' })
3
+
4
+ const route = useRoute()
5
+ const { t } = useI18n()
6
+ const api = useInvoices()
7
+ const invoiceId = computed(() => String(route.params.id))
8
+ const {
9
+ data: invoice,
10
+ pending,
11
+ error,
12
+ refresh
13
+ } = await useAsyncData(`invoice-${invoiceId.value}`, () => api.getInvoice(invoiceId.value))
14
+ useSeoMeta({
15
+ title: computed(() =>
16
+ invoice.value
17
+ ? t('features.invoices.admin.invoiceTitle', { number: invoice.value.number })
18
+ : t('features.invoices.salesInvoices')
19
+ )
20
+ })
21
+ </script>
22
+
23
+ <template>
24
+ <section class="h-full min-h-0 overflow-y-auto p-4 sm:p-6 lg:p-8">
25
+ <div v-if="pending" class="flex justify-center py-12">
26
+ <UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" />
27
+ </div>
28
+ <UAlert
29
+ v-else-if="error"
30
+ color="error"
31
+ icon="i-lucide-circle-alert"
32
+ :title="t('features.invoices.admin.invoiceNotFound')"
33
+ />
34
+ <InvoicesInvoiceDetail v-else-if="invoice" :invoice="invoice" :refresh="refresh" />
35
+ </section>
36
+ </template>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ const invoices = useInvoices()
3
+ const child = ref<{
4
+ openCreate: () => void
5
+ refreshList: () => Promise<unknown>
6
+ canCreate: boolean
7
+ showCreate: boolean
8
+ } | null>(null)
9
+ const { data, pending, refresh } = await useAsyncData('invoices-admin', () => invoices.adminBootstrap())
10
+ const { t } = useI18n()
11
+ useSeoMeta({ title: () => t('features.invoices.salesInvoices') })
12
+ </script>
13
+
14
+ <template>
15
+ <section
16
+ class="invoice-admin mx-auto flex h-full min-h-0 w-full max-w-[1440px] flex-col overflow-hidden p-4 sm:p-6 lg:p-8"
17
+ >
18
+ <header class="mb-4 flex shrink-0 items-center justify-between gap-3 border-b border-default pb-4 sm:items-end">
19
+ <div class="flex min-w-0 gap-3">
20
+ <UIcon name="i-lucide-receipt-text" class="mt-1 size-6 shrink-0 text-primary" />
21
+ <div class="min-w-0">
22
+ <h1 class="text-2xl font-semibold">{{ t('features.invoices.salesInvoices') }}</h1>
23
+ <p class="mt-1 hidden text-sm text-muted sm:block">
24
+ {{ t('features.invoices.admin.sectionSubtitles.invoices') }}
25
+ </p>
26
+ </div>
27
+ </div>
28
+ <div class="flex shrink-0 items-center gap-1">
29
+ <UButton
30
+ v-if="child?.showCreate"
31
+ class="rounded-full sm:hidden"
32
+ icon="i-lucide-plus"
33
+ :aria-label="t('features.invoices.admin.newInvoice')"
34
+ :disabled="!child.canCreate"
35
+ @click="child.openCreate()"
36
+ /><UButton
37
+ v-if="child?.showCreate"
38
+ class="hidden sm:inline-flex"
39
+ icon="i-lucide-plus"
40
+ size="sm"
41
+ variant="outline"
42
+ :disabled="!child.canCreate"
43
+ @click="child.openCreate()"
44
+ >
45
+ {{ t('features.invoices.admin.newInvoice') }} </UButton
46
+ ><UButton
47
+ icon="i-lucide-refresh-cw"
48
+ color="neutral"
49
+ variant="ghost"
50
+ size="sm"
51
+ :aria-label="t('common.refresh')"
52
+ @click="() => refresh()"
53
+ />
54
+ </div>
55
+ </header>
56
+ <div v-if="pending && !data" class="flex justify-center py-12">
57
+ <UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" />
58
+ </div>
59
+ <InvoicesAdminInvoices v-if="data" ref="child" :data="data" :refresh="refresh" class="min-h-0 flex-1" />
60
+ </section>
61
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ const invoices = useInvoices()
3
+ const { t } = useI18n()
4
+ const { data, refresh } = await useAsyncData('invoice-create-bootstrap', () => invoices.adminBootstrap())
5
+ useSeoMeta({ title: () => t('features.invoices.admin.newInvoice') })
6
+ </script>
7
+
8
+ <template>
9
+ <section class="invoice-admin flex h-full min-h-0 flex-col overflow-hidden p-4 sm:p-6 lg:p-8">
10
+ <header class="mb-6 shrink-0">
11
+ <UButton to="/admin/invoices" color="neutral" variant="link" icon="i-lucide-arrow-left" class="mb-3 px-0">
12
+ {{ t('features.invoices.admin.backToInvoices') }}
13
+ </UButton>
14
+ <h1 class="text-2xl font-semibold">{{ t('features.invoices.admin.newInvoice') }}</h1>
15
+ </header>
16
+ <InvoicesAdminInvoices v-if="data" :data="data" :refresh="refresh" create-page class="min-h-0 flex-1" />
17
+ </section>
18
+ </template>
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts">
2
+ const api = useInvoices()
3
+ const { data, refresh } = await useAsyncData('invoice-settings', () => api.adminBootstrap())
4
+ const { t } = useI18n()
5
+ const busy = ref(false)
6
+ const general = reactive({ enabled: false, currency: 'EUR', defaultVatRate: 21 })
7
+ watch(
8
+ data,
9
+ (value) => {
10
+ if (value) {
11
+ Object.assign(general, {
12
+ enabled: value.settings.enabled,
13
+ currency: value.settings.currency,
14
+ defaultVatRate: value.settings.defaultVatRateBasisPoints / 100
15
+ })
16
+ }
17
+ },
18
+ { immediate: true }
19
+ )
20
+ const saveGeneral = async () => {
21
+ if (!data.value) {
22
+ return
23
+ }
24
+ busy.value = true
25
+ try {
26
+ const { organizationId: _organizationId, ...settings } = data.value.settings
27
+ await api.updateSettings({
28
+ ...settings,
29
+ enabled: general.enabled,
30
+ currency: general.currency.toUpperCase(),
31
+ defaultVatRateBasisPoints: Math.round(general.defaultVatRate * 100)
32
+ })
33
+ await refresh()
34
+ if (import.meta.client) {
35
+ window.dispatchEvent(new CustomEvent('invoices:capabilities-refresh'))
36
+ }
37
+ } finally {
38
+ busy.value = false
39
+ }
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <div class="flex h-full min-h-0 flex-col overflow-hidden">
45
+ <div class="min-h-0 flex-1 overflow-y-auto">
46
+ <section class="mx-auto flex w-full max-w-[1440px] flex-col gap-4 p-4 sm:p-6 lg:p-8">
47
+ <header class="flex items-center justify-between gap-3 border-b border-default pb-4 sm:items-end">
48
+ <div class="flex min-w-0 gap-3">
49
+ <UIcon name="i-lucide-settings-2" class="mt-1 size-6 shrink-0 text-primary" />
50
+ <div class="min-w-0">
51
+ <h1 class="text-2xl font-semibold text-highlighted">
52
+ {{ t('features.invoices.admin.workspaceSettings') }}
53
+ </h1>
54
+ <p class="hidden text-sm text-muted sm:block">
55
+ {{ t('features.invoices.admin.workspaceSettingsDescription') }}
56
+ </p>
57
+ </div>
58
+ </div>
59
+ </header>
60
+ <UCard v-if="data">
61
+ <template #header>
62
+ <h2 class="font-semibold">{{ t('features.invoices.admin.invoiceConfiguration') }}</h2>
63
+ </template>
64
+ <form class="grid gap-4 md:grid-cols-3" @submit.prevent="saveGeneral">
65
+ <UFormField class="md:col-span-3">
66
+ <USwitch v-model="general.enabled" :label="t('features.invoices.admin.invoicesEnabled')" /> </UFormField
67
+ ><UFormField :label="t('features.invoices.admin.currency')">
68
+ <UInput v-model="general.currency" maxlength="3" class="w-full" /> </UFormField
69
+ ><UFormField :label="t('features.invoices.admin.defaultVatRate')">
70
+ <UInputNumber v-model="general.defaultVatRate" :min="0" :max="100" :step="0.01" class="w-full" />
71
+ </UFormField>
72
+ <div class="flex items-end justify-end">
73
+ <UButton type="submit" icon="i-lucide-save" :loading="busy">{{ t('features.invoices.save') }}</UButton>
74
+ </div>
75
+ </form>
76
+ </UCard>
77
+ <InvoicesSenderDetailsForm
78
+ v-if="data"
79
+ id="sender-invoice-details"
80
+ :profile="data.organizationProfile"
81
+ :refresh="refresh"
82
+ />
83
+ </section>
84
+ </div>
85
+ </div>
86
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const { t } = useI18n()
4
+ const api = useInvoices()
5
+ const invoiceId = computed(() => String(route.params.id))
6
+ const {
7
+ data: invoice,
8
+ pending,
9
+ error,
10
+ refresh
11
+ } = await useAsyncData(`client-invoice-${invoiceId.value}`, () => api.getClientInvoice(invoiceId.value))
12
+ useSeoMeta({
13
+ title: computed(() =>
14
+ invoice.value
15
+ ? t('features.invoices.admin.invoiceTitle', { number: invoice.value.number })
16
+ : t('features.invoices.clientInvoices.title')
17
+ )
18
+ })
19
+ </script>
20
+
21
+ <template>
22
+ <InvoicesPageShell class="h-full min-h-0 overflow-y-auto">
23
+ <div v-if="pending" class="flex justify-center py-12" role="status">
24
+ <UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" /><span class="sr-only">{{
25
+ t('features.invoices.loading')
26
+ }}</span>
27
+ </div>
28
+ <UAlert
29
+ v-else-if="error"
30
+ color="error"
31
+ icon="i-lucide-circle-alert"
32
+ :title="t('features.invoices.admin.invoiceNotFound')"
33
+ variant="outline"
34
+ />
35
+ <InvoicesInvoiceDetail v-else-if="invoice" :invoice="invoice" :refresh="refresh" mode="client" />
36
+ </InvoicesPageShell>
37
+ </template>
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ import type { ClientInvoiceSummaryDto, InvoiceStatus } from '@nuxt-customer-portal/invoices/shared/types/invoice'
3
+
4
+ const { t, locale } = useI18n()
5
+ const route = useRoute()
6
+ const listing = useInvoicesAdminList<ClientInvoiceSummaryDto>({
7
+ endpoint: '/api/invoices/client/invoices',
8
+ filterKeys: ['accessId', 'status', 'overdue'],
9
+ defaultSort: 'issueDate',
10
+ defaultSortDir: 'desc'
11
+ })
12
+ const { data: suppliers } = await useAsyncData('invoice-client-suppliers', useInvoices().clientInvoiceSuppliers)
13
+ const filters = computed(() => [
14
+ {
15
+ key: 'status',
16
+ placeholder: t('features.invoices.clientInvoices.statusFilter'),
17
+ items: [
18
+ { label: t('features.invoices.clientInvoices.allStatuses'), value: undefined },
19
+ { label: t('features.invoices.admin.invoiceStatus.issued'), value: 'ISSUED' },
20
+ { label: t('features.invoices.admin.invoiceStatus.paid'), value: 'PAID' }
21
+ ]
22
+ },
23
+ {
24
+ key: 'accessId',
25
+ placeholder: t('features.invoices.clientInvoices.supplierFilter'),
26
+ items: [
27
+ { label: t('features.invoices.clientInvoices.allSuppliers'), value: undefined },
28
+ ...(suppliers.value ?? []).map((item) => ({ label: item.providerName, value: item.id }))
29
+ ]
30
+ },
31
+ {
32
+ key: 'overdue',
33
+ placeholder: t('features.invoices.clientInvoices.paymentFilter'),
34
+ items: [
35
+ { label: t('features.invoices.clientInvoices.allPayments'), value: undefined },
36
+ { label: t('features.invoices.clientInvoices.overdueOnly'), value: 'true' },
37
+ { label: t('features.invoices.clientInvoices.notOverdue'), value: 'false' }
38
+ ]
39
+ }
40
+ ])
41
+ const sortOptions = computed(() => [
42
+ { label: t('features.invoices.admin.list.sortIssueDate'), value: 'issueDate' },
43
+ { label: t('features.invoices.admin.list.sortDueDate'), value: 'dueDate' },
44
+ { label: t('features.invoices.admin.list.sortNumber'), value: 'number' },
45
+ { label: t('features.invoices.admin.list.sortTotal'), value: 'totalMinor' }
46
+ ])
47
+ const money = (minor: number, currency: string) =>
48
+ new Intl.NumberFormat(locale.value, { style: 'currency', currency }).format(minor / 100)
49
+ const date = (value: string) =>
50
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(`${value}T12:00:00`))
51
+ const statusColor = (status: InvoiceStatus) => (status === 'PAID' ? 'success' : 'info')
52
+ useSeoMeta({ title: () => t('features.invoices.clientInvoices.title') })
53
+ await listing.load()
54
+ </script>
55
+
56
+ <template>
57
+ <NuxtPage v-if="route.path !== '/invoices'" />
58
+ <InvoicesPageShell v-else class="flex h-full min-h-0 flex-col overflow-hidden">
59
+ <header class="shrink-0 border-b border-default pb-5">
60
+ <div class="flex items-center gap-2">
61
+ <UIcon name="i-lucide-receipt-text" class="size-6" />
62
+ <h1 class="text-2xl font-semibold">{{ t('features.invoices.clientInvoices.title') }}</h1>
63
+ </div>
64
+ <p class="mt-1 hidden text-sm text-muted sm:block">{{ t('features.invoices.clientInvoices.subtitle') }}</p>
65
+ </header>
66
+ <section class="flex min-h-0 flex-1 flex-col gap-5 pt-5">
67
+ <InvoicesAdminListToolbar
68
+ v-model:search="listing.search.value"
69
+ :filters="filters"
70
+ :filter-values="listing.filters"
71
+ :sort-options="sortOptions"
72
+ :sort-by="listing.sortBy.value"
73
+ :sort-dir="listing.sortDir.value"
74
+ @filter="listing.setFilter"
75
+ @sort="listing.sortBy.value = $event"
76
+ @toggle-direction="listing.toggleSortDir"
77
+ />
78
+ <InvoicesAdminPaginatedList
79
+ class="min-h-0 flex-1"
80
+ :pagination="listing.pagination.value"
81
+ :pending="listing.pending.value"
82
+ :loading-next="listing.loadingNextPage.value"
83
+ :loading-previous="listing.loadingPreviousPage.value"
84
+ :has-next="listing.hasNextPage.value"
85
+ :has-previous="listing.hasPreviousPage.value"
86
+ @next="listing.loadNext"
87
+ @previous="listing.loadPrevious"
88
+ @page="listing.goToPage"
89
+ >
90
+ <UAlert
91
+ v-if="!listing.items.value.length && !listing.pending.value"
92
+ icon="i-lucide-receipt-text"
93
+ :title="t('features.invoices.clientInvoices.empty')"
94
+ :description="t('features.invoices.clientInvoices.emptyDescription')"
95
+ variant="outline"
96
+ />
97
+ <div v-else class="grid gap-3">
98
+ <UCard v-for="item in listing.items.value" :key="item.id" class="transition-colors hover:ring-primary/50">
99
+ <NuxtLink
100
+ :to="`/invoices/${item.id}`"
101
+ class="grid gap-4 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary sm:grid-cols-[minmax(12rem,1fr)_minmax(10rem,1fr)_9rem_auto] sm:items-center"
102
+ >
103
+ <div class="min-w-0">
104
+ <p class="truncate font-semibold">{{ item.number }}</p>
105
+ <p class="mt-1 truncate text-sm text-muted">{{ item.supplierName }}</p>
106
+ </div>
107
+ <div>
108
+ <p class="text-sm">{{ date(item.issueDate) }}</p>
109
+ <p class="mt-1 text-xs text-muted">
110
+ {{ t('features.invoices.admin.dueDate') }} {{ date(item.dueDate) }}
111
+ </p>
112
+ </div>
113
+ <div>
114
+ <p class="font-medium">{{ money(item.totalMinor, item.currency) }}</p>
115
+ <p v-if="item.outstandingMinor" class="mt-1 text-xs text-muted">
116
+ {{ t('features.invoices.admin.outstanding') }} {{ money(item.outstandingMinor, item.currency) }}
117
+ </p>
118
+ </div>
119
+ <div class="flex items-center justify-end gap-2">
120
+ <UBadge :color="statusColor(item.status)" variant="subtle">{{
121
+ t(`features.invoices.admin.invoiceStatus.${item.status.toLowerCase()}`)
122
+ }}</UBadge
123
+ ><UIcon name="i-lucide-chevron-right" class="size-5 text-muted" />
124
+ </div>
125
+ </NuxtLink>
126
+ </UCard>
127
+ </div>
128
+ </InvoicesAdminPaginatedList>
129
+ </section>
130
+ </InvoicesPageShell>
131
+ </template>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n()
3
+ const api = useInvoices()
4
+ const toast = useToast()
5
+ const { data: suppliers, refresh: refreshSuppliers } = await useAsyncData(
6
+ 'invoice-viewer-suppliers',
7
+ api.clientInvoiceSuppliers
8
+ )
9
+ const supplier = computed(() => suppliers.value?.[0] ?? null)
10
+ const { data: viewers, refresh } = await useAsyncData('invoice-viewers', async () =>
11
+ supplier.value ? api.clientInvoiceViewers(supplier.value.id) : []
12
+ )
13
+ const fixedViewers = computed(() => viewers.value?.filter((person) => person.fixedAccess) ?? [])
14
+ const configurableViewers = computed(() => viewers.value?.filter((person) => !person.fixedAccess) ?? [])
15
+ const viewerCount = computed(() => viewers.value?.filter((person) => person.assigned).length ?? 0)
16
+ const toggle = async (userId: string, assigned: boolean) => {
17
+ if (!supplier.value) {
18
+ return
19
+ }
20
+ try {
21
+ await api.setClientInvoiceViewer(supplier.value.id, userId, assigned)
22
+ await Promise.all([refresh(), refreshSuppliers()])
23
+ window.dispatchEvent(new CustomEvent('invoices:capabilities-refresh'))
24
+ } catch {
25
+ toast.add({
26
+ title: t('features.invoices.messages.saveError'),
27
+ color: 'error'
28
+ })
29
+ }
30
+ }
31
+ useSeoMeta({
32
+ title: () => t('features.invoices.clientInvoices.viewersTitle')
33
+ })
34
+ </script>
35
+
36
+ <template>
37
+ <InvoicesPageShell width="narrow" class="h-full overflow-y-auto">
38
+ <header class="border-b border-default pb-5">
39
+ <h1 class="text-2xl font-semibold">
40
+ {{ t('features.invoices.clientInvoices.viewersTitle') }}
41
+ </h1>
42
+ <p class="mt-1 text-sm text-muted">
43
+ {{ t('features.invoices.clientInvoices.viewersSubtitle') }}
44
+ </p>
45
+ </header>
46
+ <UAlert
47
+ v-if="!supplier"
48
+ class="mt-6"
49
+ icon="i-lucide-receipt-text"
50
+ :title="t('features.invoices.clientInvoices.accessUnavailable')"
51
+ variant="outline"
52
+ />
53
+ <section v-else class="my-6 rounded-lg border border-default bg-default">
54
+ <header class="border-b border-default p-5">
55
+ <div class="flex flex-wrap items-start justify-between gap-3">
56
+ <div>
57
+ <h2 class="text-lg font-semibold">
58
+ {{ t('features.invoices.clientInvoices.chooseViewers') }}
59
+ </h2>
60
+ <p class="mt-1 text-sm text-muted">
61
+ {{ t('features.invoices.clientInvoices.viewerHelp') }}
62
+ </p>
63
+ </div>
64
+ <UBadge color="neutral" variant="subtle">
65
+ {{ t('features.invoices.clientInvoices.viewerCount', viewerCount) }}
66
+ </UBadge>
67
+ </div>
68
+ </header>
69
+ <div v-if="fixedViewers.length" class="border-b border-default">
70
+ <h3 class="bg-elevated/40 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-muted">
71
+ {{ t('features.invoices.clientInvoices.alwaysAccess') }}
72
+ </h3>
73
+ <div class="divide-y divide-default">
74
+ <div v-for="person in fixedViewers" :key="person.id" class="flex items-center justify-between gap-3 p-4">
75
+ <span class="min-w-0"
76
+ ><strong class="block text-sm">{{ person.name }}</strong
77
+ ><span class="block truncate text-xs text-muted">{{ person.email }}</span></span
78
+ >
79
+ <div class="flex items-center gap-2">
80
+ <UBadge color="neutral" variant="soft">
81
+ {{ t(`features.invoices.roles.${person.role}`) }}
82
+ </UBadge>
83
+ <UBadge color="success" variant="soft">
84
+ {{ t('features.invoices.clientInvoices.hasAccess') }}
85
+ </UBadge>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ <div v-if="configurableViewers.length">
91
+ <h3 class="bg-elevated/40 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-muted">
92
+ {{ t('features.invoices.clientInvoices.additionalAccess') }}
93
+ </h3>
94
+ <div class="divide-y divide-default">
95
+ <label
96
+ v-for="person in configurableViewers"
97
+ :key="person.id"
98
+ class="flex items-center gap-3 p-4 hover:bg-elevated/40"
99
+ ><USwitch :model-value="person.assigned" @update:model-value="toggle(person.id, $event)" /><span
100
+ class="min-w-0"
101
+ ><strong class="block text-sm">{{ person.name }}</strong
102
+ ><span class="block truncate text-xs text-muted">{{ person.email }}</span></span
103
+ ></label
104
+ >
105
+ </div>
106
+ </div>
107
+ <p v-if="!fixedViewers.length && !configurableViewers.length" class="p-5 text-sm text-muted">
108
+ {{ t('features.invoices.clientInvoices.noEligibleViewers') }}
109
+ </p>
110
+ <footer class="border-t border-default p-4 text-xs text-muted">
111
+ {{ t('features.invoices.clientInvoices.adminAccessHelp') }}
112
+ </footer>
113
+ </section>
114
+ </InvoicesPageShell>
115
+ </template>
@@ -0,0 +1,60 @@
1
+ import { invoicesFeature } from '@nuxt-customer-portal/invoices/shared/feature'
2
+ import type { InvoiceCapabilitiesDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
3
+
4
+ export default defineNuxtPlugin(() => {
5
+ const { registerFeature } = usePortalFeatures()
6
+ const { activeOrganizationId } = usePortalSession()
7
+ const register = (capabilities: InvoiceCapabilitiesDto) =>
8
+ registerFeature({
9
+ ...invoicesFeature,
10
+ modules: invoicesFeature.modules
11
+ ?.filter(() => capabilities.canConfigureInvoices || capabilities.canViewReceivedInvoices)
12
+ .map((module) => ({
13
+ ...module,
14
+ to: capabilities.canViewReceivedInvoices ? '/invoices' : '/admin/invoices',
15
+ menuItems: module.menuItems?.filter((item) =>
16
+ item.id === 'invoice-settings'
17
+ ? capabilities.canConfigureInvoices
18
+ : item.id === 'sales-invoices'
19
+ ? capabilities.canManageInvoices
20
+ : item.id === 'invoice-viewers'
21
+ ? capabilities.canManageViewers
22
+ : capabilities.canViewReceivedInvoices
23
+ )
24
+ })),
25
+ dashboardWidgets: invoicesFeature.dashboardWidgets?.filter((widget) =>
26
+ widget.id === 'invoices-sales' ? capabilities.canManageInvoices : capabilities.canViewReceivedInvoices
27
+ )
28
+ })
29
+ const empty = {
30
+ canConfigureInvoices: false,
31
+ canManageInvoices: false,
32
+ canViewReceivedInvoices: false,
33
+ canManageViewers: false,
34
+ invoicesEnabled: false
35
+ }
36
+ register(empty)
37
+ watch(
38
+ activeOrganizationId,
39
+ async (id) => {
40
+ if (!id) {
41
+ return register(empty)
42
+ }
43
+ try {
44
+ register(await $fetch<InvoiceCapabilitiesDto>('/api/invoices/capabilities'))
45
+ } catch {
46
+ register(empty)
47
+ }
48
+ },
49
+ { immediate: true }
50
+ )
51
+ if (import.meta.client) {
52
+ window.addEventListener('invoices:capabilities-refresh', async () => {
53
+ try {
54
+ register(await $fetch<InvoiceCapabilitiesDto>('/api/invoices/capabilities'))
55
+ } catch {
56
+ register(empty)
57
+ }
58
+ })
59
+ }
60
+ })