@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +4 -0
- package/app/assets/invoices.css +332 -0
- package/app/components/InvoicesAdminEmptyState.vue +28 -0
- package/app/components/InvoicesAdminInvoices.vue +950 -0
- package/app/components/InvoicesAdminListToolbar.vue +19 -0
- package/app/components/InvoicesAdminPaginatedList.vue +95 -0
- package/app/components/InvoicesClientAccessPanel.vue +63 -0
- package/app/components/InvoicesClientSettingsPanel.vue +150 -0
- package/app/components/InvoicesDashboardCard.vue +29 -0
- package/app/components/InvoicesDashboardReceivedInvoices.vue +1 -0
- package/app/components/InvoicesDashboardSalesInvoices.vue +1 -0
- package/app/components/InvoicesInvoiceDetail.vue +1109 -0
- package/app/components/InvoicesInvoiceEmailModal.vue +223 -0
- package/app/components/InvoicesPageShell.vue +7 -0
- package/app/components/InvoicesSenderDetailsForm.vue +229 -0
- package/app/composables/useInvoiceSources.ts +9 -0
- package/app/composables/useInvoices.ts +68 -0
- package/app/composables/useInvoicesAdminList.ts +113 -0
- package/app/pages/admin/invoices/[id].vue +36 -0
- package/app/pages/admin/invoices/index.vue +61 -0
- package/app/pages/admin/invoices/new.vue +18 -0
- package/app/pages/admin/invoices/settings.vue +86 -0
- package/app/pages/invoices/[id].vue +37 -0
- package/app/pages/invoices/index.vue +131 -0
- package/app/pages/invoices/viewers.vue +115 -0
- package/app/plugins/invoices-feature.ts +60 -0
- package/i18n/locales/en.json +306 -0
- package/i18n/locales/nl.json +306 -0
- package/migrations/0000_baseline.sql +235 -0
- package/migrations/meta/0000_snapshot.json +1318 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +15 -0
- package/package.json +60 -0
- package/portal.manifest.mjs +9 -0
- package/server/api/invoices/admin/bootstrap.get.ts +17 -0
- package/server/api/invoices/admin/clients/[clientId]/access.get.ts +11 -0
- package/server/api/invoices/admin/clients/[clientId]/contacts/[id].delete.ts +8 -0
- package/server/api/invoices/admin/clients/[clientId]/contacts/[id].patch.ts +13 -0
- package/server/api/invoices/admin/clients/[clientId]/contacts/index.get.ts +8 -0
- package/server/api/invoices/admin/clients/[clientId]/contacts/index.post.ts +12 -0
- package/server/api/invoices/admin/email-domain.get.ts +17 -0
- package/server/api/invoices/admin/invoices/[id]/attachments/[attachmentId].delete.ts +23 -0
- package/server/api/invoices/admin/invoices/[id]/attachments/[attachmentId].get.ts +30 -0
- package/server/api/invoices/admin/invoices/[id]/attachments.post.ts +27 -0
- package/server/api/invoices/admin/invoices/[id]/email-preview.get.ts +22 -0
- package/server/api/invoices/admin/invoices/[id]/email-status.post.ts +18 -0
- package/server/api/invoices/admin/invoices/[id]/email.post.ts +24 -0
- package/server/api/invoices/admin/invoices/[id]/index.get.ts +17 -0
- package/server/api/invoices/admin/invoices/[id]/issue.post.ts +24 -0
- package/server/api/invoices/admin/invoices/[id]/payments.post.ts +24 -0
- package/server/api/invoices/admin/invoices/[id]/pdf.get.ts +29 -0
- package/server/api/invoices/admin/invoices/[id]/reminder-preview.get.ts +24 -0
- package/server/api/invoices/admin/invoices/[id]/reminder.post.ts +25 -0
- package/server/api/invoices/admin/invoices/[id].patch.ts +24 -0
- package/server/api/invoices/admin/invoices/index.get.ts +20 -0
- package/server/api/invoices/admin/invoices/index.post.ts +19 -0
- package/server/api/invoices/admin/invoices/next-number.get.ts +16 -0
- package/server/api/invoices/admin/settings.put.ts +9 -0
- package/server/api/invoices/capabilities.get.ts +25 -0
- package/server/api/invoices/client/[accessId]/viewers.get.ts +10 -0
- package/server/api/invoices/client/[accessId]/viewers.put.ts +17 -0
- package/server/api/invoices/client/invoices/[id]/attachments/[attachmentId].get.ts +20 -0
- package/server/api/invoices/client/invoices/[id]/index.get.ts +12 -0
- package/server/api/invoices/client/invoices/[id]/pdf.get.ts +23 -0
- package/server/api/invoices/client/invoices/index.get.ts +14 -0
- package/server/api/invoices/client/suppliers.get.ts +8 -0
- package/server/db/schema/invoices.ts +251 -0
- package/server/plugins/openapi-contracts.ts +55 -0
- package/server/utils/invoice-email-status.ts +64 -0
- package/server/utils/invoice-email.ts +240 -0
- package/server/utils/invoice-listing.ts +71 -0
- package/server/utils/invoice-pdf.ts +358 -0
- package/server/utils/invoice-repository.ts +821 -0
- package/server/utils/invoice-validation.ts +112 -0
- package/shared/email-delivery-status.ts +23 -0
- package/shared/feature.ts +148 -0
- package/shared/invoice-introduction.ts +21 -0
- package/shared/invoice-number.ts +24 -0
- package/shared/types/invoice.ts +251 -0
- 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
|
+
})
|