@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,19 @@
1
+ <script setup lang="ts">
2
+ defineOptions({ inheritAttrs: false })
3
+ const props = defineProps<{
4
+ filters: Array<{ key: string; placeholder: string; items: Array<{ label: string; value: string | undefined }> }>
5
+ filterValues: Record<string, string | undefined>
6
+ sortOptions: Array<{ label: string; value: string }>
7
+ sortBy: string
8
+ sortDir: 'asc' | 'desc'
9
+ }>()
10
+ const search = defineModel<string>('search', { required: true })
11
+ </script>
12
+
13
+ <template>
14
+ <PortalListToolbar
15
+ v-bind="{ ...props, ...$attrs }"
16
+ v-model:search="search"
17
+ :search-placeholder="$t('features.invoices.admin.list.search')"
18
+ />
19
+ </template>
@@ -0,0 +1,95 @@
1
+ <script setup lang="ts">
2
+ import type { InvoicesListPagination } from '@nuxt-customer-portal/invoices/shared/types/invoice'
3
+
4
+ const props = defineProps<{
5
+ pagination: InvoicesListPagination
6
+ pending: boolean
7
+ loadingNext: boolean
8
+ loadingPrevious: boolean
9
+ hasNext: boolean
10
+ hasPrevious: boolean
11
+ initialScrollTop?: number
12
+ }>()
13
+ const emit = defineEmits<{ next: []; previous: []; page: [page: number]; scroll: [scrollTop: number] }>()
14
+ const scrollContainer = ref<HTMLElement | null>(null)
15
+ const previousSentinel = ref<HTMLElement | null>(null)
16
+ const nextSentinel = ref<HTMLElement | null>(null)
17
+ let previousScrollHeight = 0
18
+ const loadPrevious = () => {
19
+ previousScrollHeight = scrollContainer.value?.scrollHeight ?? 0
20
+ emit('previous')
21
+ }
22
+ watch(
23
+ () => props.loadingPrevious,
24
+ async (loading, wasLoading) => {
25
+ if (loading || !wasLoading || !scrollContainer.value) {
26
+ return
27
+ }
28
+ await nextTick()
29
+ scrollContainer.value.scrollTop += scrollContainer.value.scrollHeight - previousScrollHeight
30
+ }
31
+ )
32
+ useAutoPagination({
33
+ sentinel: previousSentinel,
34
+ scrollContainer,
35
+ canLoadMore: () => props.hasPrevious,
36
+ loading: () => props.pending,
37
+ loadMore: loadPrevious,
38
+ rootMargin: '240px 0px 0px 0px'
39
+ })
40
+ useAutoPagination({
41
+ sentinel: nextSentinel,
42
+ scrollContainer,
43
+ canLoadMore: () => props.hasNext,
44
+ loading: () => props.pending,
45
+ loadMore: () => emit('next')
46
+ })
47
+ onMounted(async () => {
48
+ if (!props.initialScrollTop) {
49
+ return
50
+ }
51
+ await nextTick()
52
+ if (scrollContainer.value) {
53
+ scrollContainer.value.scrollTop = props.initialScrollTop
54
+ }
55
+ })
56
+ </script>
57
+
58
+ <template>
59
+ <div class="flex h-full min-h-0 flex-col">
60
+ <div
61
+ ref="scrollContainer"
62
+ class="invoices-list-scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
63
+ @scroll="emit('scroll', scrollContainer?.scrollTop ?? 0)"
64
+ >
65
+ <div v-if="hasPrevious" ref="previousSentinel" class="h-px" aria-hidden="true" />
66
+ <div v-if="loadingPrevious" class="space-y-2 pb-3">
67
+ <USkeleton v-for="index in 2" :key="index" class="h-24 w-full" />
68
+ </div>
69
+ <slot />
70
+ <div v-if="hasNext" ref="nextSentinel" class="h-px" aria-hidden="true" />
71
+ <div v-if="loadingNext" class="space-y-2 pt-3">
72
+ <USkeleton v-for="index in 2" :key="index" class="h-24 w-full" />
73
+ </div>
74
+ </div>
75
+ <footer
76
+ class="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 border-t border-default px-1 pt-3 text-sm text-muted"
77
+ >
78
+ <span>{{ $t('features.invoices.admin.list.totalRecords', pagination.total) }}</span>
79
+ <UPagination
80
+ v-if="pagination.pageCount > 1"
81
+ :page="pagination.page"
82
+ :total="pagination.total"
83
+ :items-per-page="pagination.pageSize"
84
+ :disabled="pending"
85
+ @update:page="emit('page', $event)"
86
+ />
87
+ </footer>
88
+ </div>
89
+ </template>
90
+
91
+ <style scoped>
92
+ .invoices-list-scroll {
93
+ padding: 2px 2px 16px;
94
+ }
95
+ </style>
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ import type { GenericClientDto } from '@nuxt-customer-portal/clients/types'
3
+
4
+ const props = defineProps<{ client: GenericClientDto }>()
5
+ const { t } = useI18n()
6
+ const invoices = useInvoices()
7
+ const { data: access, error } = await useAsyncData(
8
+ `invoice-client-access-${props.client.organizationId}`,
9
+ () => invoices.clientAccessOverview(props.client.organizationId),
10
+ { watch: [() => props.client] }
11
+ )
12
+ </script>
13
+
14
+ <template>
15
+ <UCard>
16
+ <template #header>
17
+ <h2 class="font-semibold">{{ t('features.invoices.clientAccess.title') }}</h2>
18
+ <p class="mt-1 text-sm text-muted">{{ t('features.invoices.clientAccess.description') }}</p>
19
+ </template>
20
+ <UAlert v-if="error" color="error" :title="t('features.invoices.clientAccess.error')" />
21
+ <div v-else-if="access" class="space-y-4">
22
+ <UAlert
23
+ v-if="!access.moduleEnabled || !access.configured || !access.enabled"
24
+ color="info"
25
+ variant="subtle"
26
+ icon="i-lucide-info"
27
+ :title="
28
+ t(
29
+ `features.invoices.clientAccess.${!access.moduleEnabled ? 'moduleDisabled' : !access.configured ? 'notConfigured' : 'disabled'}`
30
+ )
31
+ "
32
+ />
33
+ <p v-if="!access.members.length" class="text-sm text-muted">
34
+ {{ t('features.invoices.clientAccess.noMembers') }}
35
+ </p>
36
+ <div
37
+ v-for="member in access.members"
38
+ :key="member.id"
39
+ class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-default p-3"
40
+ >
41
+ <div class="flex min-w-0 items-center gap-3">
42
+ <UAvatar :src="member.image || undefined" :alt="member.name" size="sm" />
43
+ <div class="min-w-0">
44
+ <p class="font-medium">{{ member.name }}</p>
45
+ <p class="break-all text-sm text-muted">{{ member.email }}</p>
46
+ <p class="text-sm text-muted">
47
+ {{ t(`features.invoices.roles.${member.role}`) }} ·
48
+ {{
49
+ t(
50
+ `features.invoices.clientAccess.${member.fixedAccess ? 'automatic' : member.assigned ? 'assigned' : 'notAssigned'}`
51
+ )
52
+ }}
53
+ </p>
54
+ </div>
55
+ </div>
56
+ <UBadge :color="member.canView ? 'success' : 'neutral'" variant="subtle">{{
57
+ t(`features.invoices.clientAccess.${member.canView ? 'granted' : 'denied'}`)
58
+ }}</UBadge>
59
+ </div>
60
+ <p class="text-sm text-muted">{{ t('features.invoices.clientAccess.contactsExplanation') }}</p>
61
+ </div>
62
+ </UCard>
63
+ </template>
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { GenericClientDto } from '@nuxt-customer-portal/clients/types'
4
+ import type { InvoiceContactDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
5
+
6
+ const props = defineProps<{ client: GenericClientDto }>()
7
+ const { t } = useI18n()
8
+ const toast = useToast()
9
+ const busy = ref(false)
10
+ const editingId = ref('')
11
+ const formOpen = ref(false)
12
+ const state = reactive({ name: '', email: '', phone: '', jobTitle: '' })
13
+ const schema = computed(() =>
14
+ z.object({
15
+ name: z
16
+ .string()
17
+ .trim()
18
+ .min(1, t('features.invoices.validation.required'))
19
+ .max(200, t('features.invoices.validation.maxLength', { count: 200 })),
20
+ email: z
21
+ .string()
22
+ .trim()
23
+ .toLowerCase()
24
+ .min(1, t('features.invoices.validation.required'))
25
+ .email(t('features.invoices.validation.validEmail'))
26
+ .max(320, t('features.invoices.validation.maxLength', { count: 320 })),
27
+ phone: z
28
+ .string()
29
+ .trim()
30
+ .max(80, t('features.invoices.validation.maxLength', { count: 80 })),
31
+ jobTitle: z
32
+ .string()
33
+ .trim()
34
+ .max(160, t('features.invoices.validation.maxLength', { count: 160 }))
35
+ })
36
+ )
37
+ const endpoint = `/api/invoices/admin/clients/${props.client.organizationId}/contacts`
38
+ const { data: contacts, refresh } = await useFetch<InvoiceContactDto[]>(endpoint, { default: () => [] })
39
+ const reset = () => {
40
+ editingId.value = ''
41
+ formOpen.value = false
42
+ Object.assign(state, { name: '', email: '', phone: '', jobTitle: '' })
43
+ }
44
+ const edit = (contact: InvoiceContactDto) => {
45
+ editingId.value = contact.id
46
+ formOpen.value = true
47
+ Object.assign(state, {
48
+ name: contact.name,
49
+ email: contact.email,
50
+ phone: contact.phone ?? '',
51
+ jobTitle: contact.jobTitle ?? ''
52
+ })
53
+ }
54
+ const save = async () => {
55
+ busy.value = true
56
+ try {
57
+ const validated = schema.value.parse(state)
58
+ const body = { ...validated, phone: validated.phone || null, jobTitle: validated.jobTitle || null }
59
+ if (editingId.value) {
60
+ await $fetch(`${endpoint}/${editingId.value}`, { method: 'PATCH', body })
61
+ } else {
62
+ await $fetch(endpoint, { method: 'POST', body })
63
+ }
64
+ await refresh()
65
+ reset()
66
+ toast.add({ title: t('features.invoices.messages.saved'), color: 'success' })
67
+ } catch (error) {
68
+ toast.add({ title: t('features.invoices.messages.saveError'), description: String(error), color: 'error' })
69
+ } finally {
70
+ busy.value = false
71
+ }
72
+ }
73
+ const remove = async (contact: InvoiceContactDto) => {
74
+ await $fetch(`${endpoint}/${contact.id}`, { method: 'DELETE' })
75
+ await refresh()
76
+ }
77
+ </script>
78
+
79
+ <template>
80
+ <div class="space-y-6">
81
+ <InvoicesClientAccessPanel :client="client" />
82
+ <UCard>
83
+ <template #header>
84
+ <div class="flex items-center justify-between gap-3">
85
+ <div>
86
+ <h2 class="font-semibold">{{ t('features.invoices.admin.contactPersons') }}</h2>
87
+ <p class="text-sm text-muted">{{ t('features.invoices.admin.noContactsDescription') }}</p>
88
+ </div>
89
+ <UButton
90
+ v-if="!formOpen"
91
+ type="button"
92
+ size="sm"
93
+ variant="outline"
94
+ icon="i-lucide-plus"
95
+ @click="formOpen = true"
96
+ >
97
+ {{ t('features.invoices.admin.addContact') }}
98
+ </UButton>
99
+ </div>
100
+ </template>
101
+ <div class="grid gap-2">
102
+ <div
103
+ v-for="contact in contacts"
104
+ :key="contact.id"
105
+ class="flex items-center justify-between gap-3 rounded-md border border-default p-3"
106
+ >
107
+ <div>
108
+ <p class="font-medium">{{ contact.name }}</p>
109
+ <p class="text-sm text-muted">{{ contact.email }}</p>
110
+ </div>
111
+ <div class="flex gap-1">
112
+ <UButton type="button" variant="ghost" icon="i-lucide-pencil" @click="edit(contact)" /><UButton
113
+ type="button"
114
+ color="error"
115
+ variant="ghost"
116
+ icon="i-lucide-trash-2"
117
+ @click="remove(contact)"
118
+ />
119
+ </div>
120
+ </div>
121
+ <p v-if="!contacts.length && !formOpen" class="text-sm text-muted">
122
+ {{ t('features.invoices.admin.noContactsTitle') }}
123
+ </p>
124
+ <UForm
125
+ v-if="formOpen"
126
+ :state="state"
127
+ :schema="schema"
128
+ novalidate
129
+ class="grid gap-3 md:grid-cols-2"
130
+ @submit="save"
131
+ >
132
+ <UFormField name="name" :label="t('features.invoices.admin.name')" required>
133
+ <UInput v-model="state.name" class="w-full" /> </UFormField
134
+ ><UFormField name="email" :label="t('features.invoices.admin.email')" required>
135
+ <UInput v-model="state.email" type="email" class="w-full" /> </UFormField
136
+ ><UFormField name="phone" :label="t('features.invoices.admin.phone')">
137
+ <UInput v-model="state.phone" class="w-full" /> </UFormField
138
+ ><UFormField name="jobTitle" :label="t('features.invoices.admin.jobTitle')">
139
+ <UInput v-model="state.jobTitle" class="w-full" />
140
+ </UFormField>
141
+ <div class="flex justify-end gap-2 md:col-span-2">
142
+ <UButton type="button" color="neutral" variant="outline" @click="reset">
143
+ {{ t('features.invoices.cancel') }} </UButton
144
+ ><UButton type="submit" :loading="busy">{{ t('features.invoices.save') }}</UButton>
145
+ </div>
146
+ </UForm>
147
+ </div>
148
+ </UCard>
149
+ </div>
150
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import type { InvoicesListResponse } from '@nuxt-customer-portal/invoices/shared/types/invoice'
3
+
4
+ const props = defineProps<{ section: 'salesInvoices' | 'receivedInvoices' }>()
5
+ const { t } = useI18n()
6
+ const sales = props.section === 'salesInvoices'
7
+ const endpoint = sales ? '/api/invoices/admin/invoices' : '/api/invoices/client/invoices'
8
+ const { data, pending } = await useFetch<InvoicesListResponse<unknown>>(endpoint, { query: { pageSize: 1 } })
9
+ const total = computed(() => data.value?.pagination.total ?? 0)
10
+ </script>
11
+
12
+ <template>
13
+ <UCard class="h-full">
14
+ <template #header>
15
+ <div class="flex items-center justify-between gap-3">
16
+ <h2 class="font-semibold">
17
+ {{ t(sales ? 'features.invoices.salesInvoices' : 'features.invoices.receivedInvoices') }}
18
+ </h2>
19
+ <UIcon :name="sales ? 'i-lucide-receipt-text' : 'i-lucide-inbox'" class="size-5 text-primary" />
20
+ </div>
21
+ </template>
22
+ <div class="flex items-end justify-between gap-4">
23
+ <p class="text-3xl font-semibold tabular-nums">{{ pending ? '…' : total }}</p>
24
+ <UButton :to="sales ? '/admin/invoices' : '/invoices'" variant="outline" icon="i-lucide-arrow-right">
25
+ {{ t('features.invoices.dashboard.open') }}
26
+ </UButton>
27
+ </div>
28
+ </UCard>
29
+ </template>
@@ -0,0 +1 @@
1
+ <template><InvoicesDashboardCard section="receivedInvoices" /></template>
@@ -0,0 +1 @@
1
+ <template><InvoicesDashboardCard section="salesInvoices" /></template>