@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,950 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import { createHoursIntroduction } from '@nuxt-customer-portal/invoices/shared/invoice-introduction'
4
+ import type { InvoicesAdminBootstrap } from '@nuxt-customer-portal/invoices/app/composables/useInvoices'
5
+ import type { InvoiceDto, InvoiceableEntryDto } from '@nuxt-customer-portal/invoices/shared/types/invoice'
6
+
7
+ const props = defineProps<{ data: InvoicesAdminBootstrap; refresh: () => Promise<unknown>; createPage?: boolean }>()
8
+ const { t, locale } = useI18n()
9
+ const api = useInvoices()
10
+ const { providers } = useInvoiceSources()
11
+ const availableProviders = ref<typeof providers.value>([])
12
+ const toast = useToast()
13
+ const busy = ref(false)
14
+ const formOpen = ref(false)
15
+ const step = ref(1)
16
+ const source = ref<'FREE' | 'TIME' | ''>('')
17
+ const selectedProjectIds = ref<string[]>([])
18
+ const detailLevel = ref<'SUMMARY' | 'DETAILED'>('SUMMARY')
19
+ const groupBy = ref<'PROJECT' | 'PERSON' | 'ACTIVITY' | 'PERSON_ACTIVITY'>('PROJECT')
20
+ const invoiceableEntries = ref<InvoiceableEntryDto[]>([])
21
+ const listing = useInvoicesAdminList<InvoiceDto>({
22
+ endpoint: '/api/invoices/admin/invoices',
23
+ filterKeys: ['status', 'clientOrganizationId', 'overdue'],
24
+ defaultSort: 'issueDate',
25
+ defaultSortDir: 'desc'
26
+ })
27
+ const invoiceFilters = computed(() => [
28
+ {
29
+ key: 'status',
30
+ placeholder: t('features.invoices.admin.list.statusFilter'),
31
+ items: [
32
+ { label: t('features.invoices.admin.list.allStatuses'), value: undefined },
33
+ ...(['DRAFT', 'ISSUED', 'PAID', 'VOID'] as const).map((status) => ({
34
+ label: t(`features.invoices.admin.invoiceStatus.${status.toLowerCase()}`),
35
+ value: status
36
+ }))
37
+ ]
38
+ },
39
+ {
40
+ key: 'clientOrganizationId',
41
+ placeholder: t('features.invoices.admin.list.clientFilter'),
42
+ items: [
43
+ { label: t('features.invoices.admin.list.allClients'), value: undefined },
44
+ ...props.data.clients.map((client) => ({ label: client.name, value: client.organizationId }))
45
+ ]
46
+ },
47
+ {
48
+ key: 'overdue',
49
+ placeholder: t('features.invoices.admin.list.overdueFilter'),
50
+ items: [
51
+ { label: t('features.invoices.admin.list.allPaymentStates'), value: undefined },
52
+ { label: t('features.invoices.admin.list.overdue'), value: 'true' },
53
+ { label: t('features.invoices.admin.list.notOverdue'), value: 'false' }
54
+ ]
55
+ }
56
+ ])
57
+ const sortOptions = computed(() => [
58
+ { label: t('features.invoices.admin.list.sortIssueDate'), value: 'issueDate' },
59
+ { label: t('features.invoices.admin.list.sortDueDate'), value: 'dueDate' },
60
+ { label: t('features.invoices.admin.list.sortNumber'), value: 'number' },
61
+ { label: t('features.invoices.admin.list.sortTotal'), value: 'totalMinor' }
62
+ ])
63
+ const localDate = (date: Date) =>
64
+ `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
65
+ const now = new Date()
66
+ const periodFrom = ref(localDate(new Date(now.getFullYear(), now.getMonth() - 1, 1)))
67
+ const periodTo = ref(localDate(new Date(now.getFullYear(), now.getMonth(), 0)))
68
+ const today = localDate(now)
69
+ const due = localDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 30))
70
+ const emptyLine = () => ({
71
+ description: '',
72
+ quantityMilli: 1000,
73
+ unit: 'item',
74
+ unitPriceMinor: 0,
75
+ vatRateBasisPoints: props.data.settings.defaultVatRateBasisPoints,
76
+ timeEntryIds: [] as string[]
77
+ })
78
+ const model = reactive({
79
+ clientOrganizationId: '',
80
+ contactId: '',
81
+ number: `${now.getFullYear()}.0001`,
82
+ currency: props.data.settings.currency,
83
+ issueDate: today,
84
+ dueDate: due,
85
+ subject: '',
86
+ notes: '',
87
+ lines: [emptyLine()]
88
+ })
89
+ const invoiceSchema = computed(() =>
90
+ z
91
+ .object({
92
+ clientOrganizationId: z.string().min(1, t('features.invoices.validation.required')),
93
+ contactId: z.string(),
94
+ number: z
95
+ .string()
96
+ .trim()
97
+ .min(1, t('features.invoices.validation.required'))
98
+ .max(60)
99
+ .regex(/\d+$/, t('features.invoices.validation.invoiceNumberSequence')),
100
+ currency: z.string().length(3, t('features.invoices.validation.currencyLength')),
101
+ issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('features.invoices.validation.validDate')),
102
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('features.invoices.validation.validDate')),
103
+ subject: z.string().trim().max(500),
104
+ notes: z.string().trim().max(5000),
105
+ lines: z
106
+ .array(
107
+ z.object({
108
+ description: z.string().trim().min(1, t('features.invoices.validation.required')).max(500),
109
+ quantityMilli: z.number().int().positive(t('features.invoices.validation.positiveQuantity')),
110
+ unit: z.string().min(1),
111
+ unitPriceMinor: z.number().int().min(0),
112
+ vatRateBasisPoints: z.number().int().min(0).max(10_000),
113
+ timeEntryIds: z.array(z.string())
114
+ })
115
+ )
116
+ .min(1, t('features.invoices.validation.invoiceLineRequired'))
117
+ })
118
+ .refine((value) => value.dueDate >= value.issueDate, {
119
+ path: ['dueDate'],
120
+ message: t('features.invoices.validation.dueDateOrder')
121
+ })
122
+ )
123
+ const money = (minor: unknown, currency = props.data.settings.currency) =>
124
+ new Intl.NumberFormat(locale.value, { style: 'currency', currency }).format(Number(minor) / 100)
125
+ const dateTime = (value: string) =>
126
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
127
+ const quantityFormat: Intl.NumberFormatOptions = { minimumFractionDigits: 2, maximumFractionDigits: 3 }
128
+ const currencyFormat = computed<Intl.NumberFormatOptions>(() => ({
129
+ style: 'currency',
130
+ currency: model.currency,
131
+ minimumFractionDigits: 2,
132
+ maximumFractionDigits: 2
133
+ }))
134
+ const percentageFormat: Intl.NumberFormatOptions = {
135
+ style: 'percent',
136
+ minimumFractionDigits: 2,
137
+ maximumFractionDigits: 2
138
+ }
139
+ const updateQuantity = (line: (typeof model.lines)[number], value: number | null | undefined) => {
140
+ line.quantityMilli = Math.round(Number(value ?? 0) * 1000)
141
+ }
142
+ const updateUnitPrice = (line: (typeof model.lines)[number], value: number | null | undefined) => {
143
+ line.unitPriceMinor = Math.round(Number(value ?? 0) * 100)
144
+ }
145
+ const updateVatRate = (line: (typeof model.lines)[number], value: number | null | undefined) => {
146
+ line.vatRateBasisPoints = Math.round(Number(value ?? 0) * 10_000)
147
+ }
148
+ const lineAmount = (line: (typeof model.lines)[number]) => Math.round((line.quantityMilli * line.unitPriceMinor) / 1000)
149
+ const subtotalMinor = computed(() => model.lines.reduce((sum, line) => sum + lineAmount(line), 0))
150
+ const vatMinor = computed(() =>
151
+ model.lines.reduce((sum, line) => sum + Math.round((lineAmount(line) * line.vatRateBasisPoints) / 10_000), 0)
152
+ )
153
+ const totalMinor = computed(() => subtotalMinor.value + vatMinor.value)
154
+ const lastInvoiceNumber = computed(() => listing.items.value[0]?.number ?? '—')
155
+ const periodEntries = computed(() =>
156
+ invoiceableEntries.value.filter((entry) => entry.date >= periodFrom.value && entry.date <= periodTo.value)
157
+ )
158
+ const clientOptions = computed(() =>
159
+ props.data.clients
160
+ .map((client) => {
161
+ const entries = periodEntries.value.filter((entry) => entry.client === client.name)
162
+ return { ...client, amountMinor: entries.reduce((sum, entry) => sum + entry.amountMinor, 0), entries }
163
+ })
164
+ .filter((client) => client.entries.length)
165
+ )
166
+ const selectedClient = computed(() =>
167
+ props.data.clients.find((client) => client.organizationId === model.clientOrganizationId)
168
+ )
169
+ watch(
170
+ () => selectedClient.value?.contacts,
171
+ (contacts) => {
172
+ if (contacts?.some((contact) => contact.id === model.contactId)) {
173
+ return
174
+ }
175
+ model.contactId = contacts?.length === 1 ? contacts[0]!.id : ''
176
+ },
177
+ { immediate: true }
178
+ )
179
+ const missingClientInvoiceDetails = computed(() => {
180
+ const client = selectedClient.value
181
+ if (!client) {
182
+ return []
183
+ }
184
+ const missing = [
185
+ !client.address.trim() && t('features.invoices.admin.address'),
186
+ !client.contacts.length && t('features.invoices.admin.contactPersons')
187
+ ]
188
+ return missing.filter((item): item is string => Boolean(item))
189
+ })
190
+ const selectedClientReady = computed(() => Boolean(selectedClient.value) && !missingClientInvoiceDetails.value.length)
191
+ const senderInvoiceDetailsComplete = computed(() => {
192
+ const profile = props.data.organizationProfile
193
+ return [
194
+ profile.address,
195
+ profile.registrationNumber,
196
+ profile.vatNumber,
197
+ profile.iban,
198
+ profile.bic,
199
+ profile.invoiceEmail
200
+ ].every((value) => Boolean(value?.trim()))
201
+ })
202
+ const clientEntries = computed(() => periodEntries.value.filter((entry) => entry.client === selectedClient.value?.name))
203
+ const availableProjects = computed(() => [
204
+ ...new Map(
205
+ clientEntries.value.map((entry) => [entry.projectId, { id: entry.projectId, name: entry.project }])
206
+ ).values()
207
+ ])
208
+ const selectedEntries = computed(() =>
209
+ clientEntries.value.filter((entry) => selectedProjectIds.value.includes(entry.projectId))
210
+ )
211
+ const reset = () => {
212
+ formOpen.value = false
213
+ step.value = 1
214
+ source.value = ''
215
+ model.clientOrganizationId = ''
216
+ model.contactId = ''
217
+ selectedProjectIds.value = []
218
+ model.lines = [emptyLine()]
219
+ }
220
+ const openWizard = async () => {
221
+ if (!props.createPage) {
222
+ await navigateTo('/admin/invoices/new')
223
+ return
224
+ }
225
+ if (!senderInvoiceDetailsComplete.value) {
226
+ await navigateTo('/admin/invoices/settings#sender-invoice-details')
227
+ return
228
+ }
229
+ reset()
230
+ model.number = (await api.getNextInvoiceNumber()).number
231
+ formOpen.value = true
232
+ }
233
+ const refreshAvailableProviders = async () => {
234
+ const checks = await Promise.all(
235
+ providers.value.map(async (provider) => ({
236
+ provider,
237
+ result: await provider.load({ from: periodFrom.value, to: periodTo.value })
238
+ }))
239
+ )
240
+ availableProviders.value = checks.filter((item) => item.result.enabled).map((item) => item.provider)
241
+ }
242
+ const chooseClient = (id: string) => {
243
+ model.clientOrganizationId = id
244
+ model.contactId = ''
245
+ selectedProjectIds.value = []
246
+ const projects = availableProjects.value
247
+ if (projects.length === 1) {
248
+ selectedProjectIds.value = [projects[0]!.id]
249
+ }
250
+ }
251
+ const editSelectedClient = async () => {
252
+ await navigateTo({
253
+ path: `/clients/${selectedClient.value?.organizationId}`
254
+ })
255
+ }
256
+ const groupEntries = (entries: InvoiceableEntryDto[]) => {
257
+ const groups = new Map<string, InvoiceableEntryDto[]>()
258
+ for (const entry of entries) {
259
+ const label =
260
+ detailLevel.value === 'DETAILED'
261
+ ? `${entry.date} · ${entry.person} · ${entry.project} · ${entry.activity}${entry.note ? ` · ${entry.note}` : ''}`
262
+ : groupBy.value === 'PERSON'
263
+ ? entry.person
264
+ : groupBy.value === 'ACTIVITY'
265
+ ? entry.activity
266
+ : groupBy.value === 'PERSON_ACTIVITY'
267
+ ? `${entry.person} · ${entry.activity}`
268
+ : entry.project
269
+ const key = `${label}|${entry.hourlyRateMinor}`
270
+ groups.set(key, [...(groups.get(key) ?? []), entry])
271
+ }
272
+ model.lines = [...groups.entries()].map(([key, rows]) => ({
273
+ description: key.split('|')[0]!,
274
+ quantityMilli: Math.round((rows.reduce((sum, row) => sum + row.minutes, 0) * 1000) / 60),
275
+ unit: 'hour',
276
+ unitPriceMinor: rows[0]!.hourlyRateMinor,
277
+ vatRateBasisPoints: props.data.settings.defaultVatRateBasisPoints,
278
+ timeEntryIds: rows.map((row) => row.entryId)
279
+ }))
280
+ }
281
+ const next = () => {
282
+ if (source.value === 'FREE' && step.value === 2) {
283
+ step.value = 5
284
+ } else if (source.value === 'TIME' && step.value === 4) {
285
+ groupEntries(selectedEntries.value)
286
+ if (!model.subject.trim()) {
287
+ model.subject = createHoursIntroduction(
288
+ selectedEntries.value,
289
+ selectedClient.value?.preferredLocale === 'en' ? 'en' : 'nl'
290
+ )
291
+ }
292
+ step.value = 5
293
+ } else {
294
+ step.value++
295
+ }
296
+ }
297
+ const back = () => {
298
+ if (source.value === 'FREE' && step.value === 5) {
299
+ step.value = 2
300
+ } else {
301
+ step.value--
302
+ }
303
+ }
304
+ const canContinue = computed(() =>
305
+ step.value === 1
306
+ ? Boolean(source.value)
307
+ : step.value === 2
308
+ ? selectedClientReady.value
309
+ : step.value === 3
310
+ ? selectedProjectIds.value.length > 0
311
+ : true
312
+ )
313
+ const chooseTimeSource = async () => {
314
+ const provider = availableProviders.value[0]
315
+ if (!provider) {
316
+ return
317
+ }
318
+ const result = await provider.load({ from: periodFrom.value, to: periodTo.value })
319
+ if (!result.enabled) {
320
+ await refreshAvailableProviders()
321
+ return
322
+ }
323
+ invoiceableEntries.value = result.entries
324
+ source.value = 'TIME'
325
+ next()
326
+ }
327
+ const chooseFreeSource = () => {
328
+ source.value = 'FREE'
329
+ next()
330
+ }
331
+ const save = async () => {
332
+ busy.value = true
333
+ try {
334
+ const input = { ...model, contactId: model.contactId || null }
335
+ const created =
336
+ source.value === 'TIME' && availableProviders.value[0]
337
+ ? await availableProviders.value[0].create(input)
338
+ : await api.createInvoice(input)
339
+ toast.add({ title: t('features.invoices.admin.invoiceCreated'), color: 'success' })
340
+ await navigateTo(`/admin/invoices/${created.id}`)
341
+ } catch (error) {
342
+ toast.add({ title: t('features.invoices.messages.saveError'), description: String(error), color: 'error' })
343
+ } finally {
344
+ busy.value = false
345
+ }
346
+ }
347
+ const statusColor = (status: string) =>
348
+ status === 'PAID' ? 'success' : status === 'ISSUED' ? 'info' : status === 'VOID' ? 'error' : 'neutral'
349
+ defineExpose({
350
+ canCreate: computed(() => senderInvoiceDetailsComplete.value),
351
+ openCreate: openWizard,
352
+ refreshList: () => listing.refresh(),
353
+ showCreate: computed(
354
+ () => !formOpen.value && Boolean(listing.pagination.value.total) && senderInvoiceDetailsComplete.value
355
+ )
356
+ })
357
+ if (props.createPage) {
358
+ await Promise.all([openWizard(), refreshAvailableProviders()])
359
+ } else {
360
+ await listing.load()
361
+ }
362
+ </script>
363
+
364
+ <template>
365
+ <section
366
+ class="-mx-1 flex h-full min-h-0 flex-col gap-4 px-1"
367
+ :class="formOpen ? 'overflow-y-auto py-1' : 'overflow-hidden'"
368
+ >
369
+ <InvoicesAdminListToolbar
370
+ v-if="!createPage"
371
+ v-model:search="listing.search.value"
372
+ :filters="invoiceFilters"
373
+ :filter-values="listing.filters"
374
+ :sort-options="sortOptions"
375
+ :sort-by="listing.sortBy.value"
376
+ :sort-dir="listing.sortDir.value"
377
+ @filter="listing.setFilter"
378
+ @sort="listing.sortBy.value = $event"
379
+ @toggle-direction="listing.toggleSortDir"
380
+ />
381
+ <InvoicesAdminEmptyState
382
+ v-if="!createPage && !formOpen && !senderInvoiceDetailsComplete"
383
+ icon="i-lucide-file-warning"
384
+ :title="t('features.invoices.admin.senderDetailsRequiredTitle')"
385
+ :description="t('features.invoices.admin.senderDetailsRequiredDescription')"
386
+ :action-label="t('features.invoices.admin.completeSenderDetails')"
387
+ action-icon="i-lucide-settings-2"
388
+ @action="openWizard"
389
+ />
390
+ <InvoicesAdminEmptyState
391
+ v-else-if="!createPage && !formOpen && !listing.items.value.length && !listing.pending.value"
392
+ icon="i-lucide-file-text"
393
+ :title="t('features.invoices.admin.noInvoicesTitle')"
394
+ :description="t('features.invoices.admin.noInvoicesDescription')"
395
+ :action-label="t('features.invoices.admin.createFirstInvoice')"
396
+ @action="openWizard"
397
+ />
398
+
399
+ <UCard v-if="formOpen" class="shrink-0 scroll-mt-6">
400
+ <template #header>
401
+ <div class="flex items-center justify-between gap-4">
402
+ <h2 class="font-semibold">
403
+ {{
404
+ step === 5
405
+ ? t('features.invoices.admin.newInvoiceFor', { name: selectedClient?.name })
406
+ : t('features.invoices.admin.newInvoice')
407
+ }}
408
+ </h2>
409
+ <span class="shrink-0 text-sm text-muted">{{
410
+ t('features.invoices.admin.wizardStep', { step, total: 5 })
411
+ }}</span>
412
+ </div>
413
+ </template>
414
+
415
+ <div v-if="step === 1" class="grid gap-3 md:grid-cols-2">
416
+ <button
417
+ class="invoice-source-card min-w-0 rounded-lg border border-default p-5 text-left hover:border-primary"
418
+ @click="chooseFreeSource"
419
+ >
420
+ <UIcon name="i-lucide-file-pen-line" class="mb-3 size-6" />
421
+ <h3 class="font-medium">{{ t('features.invoices.admin.freeForm') }}</h3>
422
+ <p class="mt-1 text-sm text-muted">{{ t('features.invoices.admin.freeFormDescription') }}</p>
423
+ </button>
424
+ <button
425
+ v-if="availableProviders.length"
426
+ class="invoice-source-card min-w-0 rounded-lg border border-default p-5 text-left hover:border-primary"
427
+ @click="chooseTimeSource"
428
+ >
429
+ <UIcon name="i-lucide-clock-3" class="mb-3 size-6" />
430
+ <h3 class="font-medium">{{ t(availableProviders[0]!.labelKey) }}</h3>
431
+ <p class="mt-1 text-sm text-muted">{{ t(availableProviders[0]!.descriptionKey) }}</p>
432
+ </button>
433
+ </div>
434
+ <div v-else-if="step === 2" class="space-y-4">
435
+ <div v-if="source === 'TIME'" class="grid gap-3 sm:grid-cols-2">
436
+ <UFormField :label="t('features.invoices.admin.periodFrom')">
437
+ <UInput v-model="periodFrom" type="date" class="w-full" />
438
+ </UFormField>
439
+ <UFormField :label="t('features.invoices.admin.periodTo')">
440
+ <UInput v-model="periodTo" type="date" class="w-full" />
441
+ </UFormField>
442
+ </div>
443
+ <h3 class="font-medium">{{ t('features.invoices.admin.selectClient') }}</h3>
444
+ <div class="grid gap-2">
445
+ <button
446
+ v-for="client in source === 'TIME' ? clientOptions : data.clients"
447
+ :key="client.id"
448
+ class="flex items-center justify-between rounded-lg border p-4 text-left"
449
+ :class="
450
+ model.clientOrganizationId === client.organizationId ? 'border-primary bg-primary/5' : 'border-default'
451
+ "
452
+ @click="chooseClient(client.organizationId)"
453
+ >
454
+ <span>{{ client.name }}</span
455
+ ><span v-if="'amountMinor' in client" class="font-medium">{{ money(client.amountMinor) }}</span>
456
+ </button>
457
+ <p v-if="source === 'TIME' && !clientOptions.length" class="text-sm text-muted">
458
+ {{ t('features.invoices.admin.noInvoiceableTimeInPeriod') }}
459
+ </p>
460
+ </div>
461
+ <div
462
+ v-if="selectedClient && !selectedClientReady"
463
+ class="rounded-lg border border-warning/50 bg-warning/10 p-4"
464
+ >
465
+ <div class="flex items-start gap-3">
466
+ <UIcon name="i-lucide-triangle-alert" class="mt-0.5 size-5 shrink-0 text-warning" />
467
+ <div class="min-w-0 flex-1">
468
+ <h4 class="font-medium">{{ t('features.invoices.admin.clientDetailsRequiredTitle') }}</h4>
469
+ <p class="mt-1 text-sm text-muted">
470
+ {{
471
+ t('features.invoices.admin.clientDetailsRequiredDescription', {
472
+ fields: missingClientInvoiceDetails.join(', ')
473
+ })
474
+ }}
475
+ </p>
476
+ <UButton class="mt-3" size="sm" variant="outline" icon="i-lucide-pencil" @click="editSelectedClient">
477
+ {{ t('features.invoices.admin.completeClientDetails') }}
478
+ </UButton>
479
+ </div>
480
+ </div>
481
+ </div>
482
+ </div>
483
+ <div v-else-if="step === 3" class="space-y-3">
484
+ <h3 class="font-medium">{{ t('features.invoices.admin.selectProjects') }}</h3>
485
+ <label
486
+ v-for="project in availableProjects"
487
+ :key="project.id"
488
+ class="flex items-center gap-3 rounded-lg border border-default p-4"
489
+ ><input v-model="selectedProjectIds" type="checkbox" :value="project.id" /><span class="flex-1">{{
490
+ project.name
491
+ }}</span
492
+ ><span>{{
493
+ money(
494
+ clientEntries
495
+ .filter((entry) => entry.project === project.name)
496
+ .reduce((sum, entry) => sum + entry.amountMinor, 0)
497
+ )
498
+ }}</span></label
499
+ >
500
+ </div>
501
+ <div v-else-if="step === 4" class="space-y-4">
502
+ <UFormField :label="t('features.invoices.admin.detailLevel')">
503
+ <USelect
504
+ v-model="detailLevel"
505
+ :items="[
506
+ { label: t('features.invoices.admin.summary'), value: 'SUMMARY' },
507
+ { label: t('features.invoices.admin.detailed'), value: 'DETAILED' }
508
+ ]"
509
+ value-key="value"
510
+ class="w-full"
511
+ /> </UFormField
512
+ ><UFormField v-if="detailLevel === 'SUMMARY'" :label="t('features.invoices.admin.groupBy')">
513
+ <USelect
514
+ v-model="groupBy"
515
+ :items="[
516
+ { label: t('features.invoices.admin.perProject'), value: 'PROJECT' },
517
+ { label: t('features.invoices.admin.perPerson'), value: 'PERSON' },
518
+ { label: t('features.invoices.admin.perActivity'), value: 'ACTIVITY' },
519
+ { label: t('features.invoices.admin.perPersonActivity'), value: 'PERSON_ACTIVITY' }
520
+ ]"
521
+ value-key="value"
522
+ class="w-full"
523
+ />
524
+ </UFormField>
525
+ </div>
526
+
527
+ <UForm v-else :state="model" :schema="invoiceSchema" class="invoice-workbench" @submit="save">
528
+ <div class="invoice-meta-grid">
529
+ <div class="invoice-meta-column">
530
+ <UFormField name="number" :label="t('features.invoices.admin.invoiceNumber')" required>
531
+ <div class="flex items-center gap-3">
532
+ <UInput v-model="model.number" class="min-w-0 flex-1" /><span
533
+ class="hidden whitespace-nowrap text-xs text-muted sm:block"
534
+ >{{ t('features.invoices.admin.lastUsedInvoiceNumber', { number: lastInvoiceNumber }) }}</span
535
+ >
536
+ </div>
537
+ </UFormField>
538
+ <UFormField name="issueDate" :label="t('features.invoices.admin.invoiceDate')">
539
+ <UInput v-model="model.issueDate" type="date" class="w-full" />
540
+ </UFormField>
541
+ <UFormField name="dueDate" :label="t('features.invoices.admin.dueDate')">
542
+ <UInput v-model="model.dueDate" type="date" class="w-full" />
543
+ </UFormField>
544
+ </div>
545
+ <div class="invoice-meta-column">
546
+ <UFormField :label="t('features.invoices.admin.client')">
547
+ <UInput :model-value="selectedClient?.name" disabled class="w-full" />
548
+ </UFormField>
549
+ <UFormField name="contactId" :label="t('features.invoices.admin.contact')">
550
+ <USelect
551
+ v-model="model.contactId"
552
+ :items="
553
+ selectedClient?.contacts.map((item) => ({ label: `${item.name} · ${item.email}`, value: item.id })) ??
554
+ []
555
+ "
556
+ value-key="value"
557
+ class="w-full"
558
+ />
559
+ </UFormField>
560
+ <UFormField :label="t('features.invoices.admin.currency')">
561
+ <UInput v-model="model.currency" disabled class="w-full" />
562
+ </UFormField>
563
+ </div>
564
+ </div>
565
+
566
+ <UFormField name="subject" :label="t('features.invoices.admin.subject')">
567
+ <UTextarea v-model="model.subject" :rows="3" class="w-full" />
568
+ </UFormField>
569
+
570
+ <section class="invoice-ledger" :aria-label="t('features.invoices.admin.invoiceDetails')">
571
+ <div class="invoice-ledger-head" aria-hidden="true">
572
+ <span>{{ t('features.invoices.admin.description') }}</span
573
+ ><span>{{ t('features.invoices.admin.quantity') }}</span
574
+ ><span>{{ t('features.invoices.admin.unitPrice') }}</span
575
+ ><span>{{ t('features.invoices.admin.amount') }}</span
576
+ ><span>{{ t('features.invoices.admin.vat') }}</span
577
+ ><span />
578
+ </div>
579
+ <div v-for="(line, index) in model.lines" :key="index" class="invoice-line">
580
+ <UFormField
581
+ class="invoice-line-description"
582
+ :name="`lines.${index}.description`"
583
+ :label="t('features.invoices.admin.description')"
584
+ >
585
+ <UTextarea v-model="line.description" :rows="1" autoresize class="w-full" />
586
+ </UFormField>
587
+ <UFormField
588
+ class="invoice-line-quantity"
589
+ :name="`lines.${index}.quantityMilli`"
590
+ :label="t('features.invoices.admin.quantity')"
591
+ >
592
+ <UInputNumber
593
+ :model-value="line.quantityMilli / 1000"
594
+ :min="0.001"
595
+ :step="0.001"
596
+ :format-options="quantityFormat"
597
+ :increment="false"
598
+ :decrement="false"
599
+ :ui="{ base: 'text-right' }"
600
+ class="w-full"
601
+ @update:model-value="updateQuantity(line, $event)"
602
+ />
603
+ </UFormField>
604
+ <UFormField
605
+ class="invoice-line-price"
606
+ :name="`lines.${index}.unitPriceMinor`"
607
+ :label="t('features.invoices.admin.unitPrice')"
608
+ >
609
+ <UInputNumber
610
+ :model-value="line.unitPriceMinor / 100"
611
+ :min="0"
612
+ :step="0.01"
613
+ :format-options="currencyFormat"
614
+ :increment="false"
615
+ :decrement="false"
616
+ :ui="{ base: 'text-right' }"
617
+ class="w-full"
618
+ @update:model-value="updateUnitPrice(line, $event)"
619
+ />
620
+ </UFormField>
621
+ <div class="invoice-line-amount">
622
+ <span class="invoice-mobile-label">{{ t('features.invoices.admin.amount') }}</span
623
+ ><strong>{{ money(lineAmount(line), model.currency) }}</strong>
624
+ </div>
625
+ <UFormField
626
+ class="invoice-line-vat"
627
+ :name="`lines.${index}.vatRateBasisPoints`"
628
+ :label="t('features.invoices.admin.vat')"
629
+ >
630
+ <UInputNumber
631
+ :model-value="line.vatRateBasisPoints / 10_000"
632
+ :min="0"
633
+ :max="1"
634
+ :step="0.0001"
635
+ :format-options="percentageFormat"
636
+ :increment="false"
637
+ :decrement="false"
638
+ :ui="{ base: 'text-right' }"
639
+ class="w-full"
640
+ @update:model-value="updateVatRate(line, $event)"
641
+ />
642
+ </UFormField>
643
+ <UButton
644
+ v-if="source === 'FREE' || !line.timeEntryIds.length"
645
+ type="button"
646
+ class="invoice-line-remove self-start justify-self-end"
647
+ style="transform: translateY(8px)"
648
+ size="xs"
649
+ color="neutral"
650
+ variant="ghost"
651
+ icon="i-lucide-trash-2"
652
+ :aria-label="t('features.invoices.admin.removeInvoiceLine', { number: index + 1 })"
653
+ @click="model.lines.splice(index, 1)"
654
+ />
655
+ </div>
656
+ <div class="invoice-ledger-footer">
657
+ <div class="invoice-add-line">
658
+ <UButton
659
+ type="button"
660
+ size="sm"
661
+ variant="outline"
662
+ icon="i-lucide-plus"
663
+ @click="model.lines.push(emptyLine())"
664
+ >
665
+ {{ t('features.invoices.admin.addLine') }}
666
+ </UButton>
667
+ </div>
668
+ <dl class="invoice-totals">
669
+ <div>
670
+ <dt>{{ t('features.invoices.admin.subtotal') }}</dt>
671
+ <dd>{{ money(subtotalMinor, model.currency) }}</dd>
672
+ </div>
673
+ <div>
674
+ <dt>{{ t('features.invoices.admin.vat') }}</dt>
675
+ <dd>{{ money(vatMinor, model.currency) }}</dd>
676
+ </div>
677
+ <div class="invoice-total">
678
+ <dt>{{ t('features.invoices.admin.totalPayable') }}</dt>
679
+ <dd>{{ money(totalMinor, model.currency) }}</dd>
680
+ </div>
681
+ </dl>
682
+ </div>
683
+ </section>
684
+
685
+ <UFormField name="notes" :label="t('features.invoices.admin.notes')">
686
+ <UTextarea v-model="model.notes" :rows="5" class="w-full" />
687
+ </UFormField>
688
+ <footer class="invoice-actions">
689
+ <UButton type="button" color="neutral" variant="outline" @click="back">
690
+ {{ t('features.invoices.admin.previous') }}
691
+ </UButton>
692
+ <UButton type="submit" icon="i-lucide-file-check-2" :loading="busy">
693
+ {{ t('features.invoices.admin.createDraftInvoice') }}
694
+ </UButton>
695
+ </footer>
696
+ </UForm>
697
+
698
+ <div v-if="step < 5" class="mt-6 flex justify-between">
699
+ <UButton color="neutral" variant="outline" @click="step === 1 ? navigateTo('/admin/invoices') : back()">
700
+ {{ t(step === 1 ? 'features.invoices.cancel' : 'features.invoices.admin.previous') }} </UButton
701
+ ><UButton v-if="step > 1" :disabled="!canContinue" @click="next">
702
+ {{ t('features.invoices.admin.next') }}
703
+ </UButton>
704
+ </div>
705
+ </UCard>
706
+
707
+ <InvoicesAdminPaginatedList
708
+ v-if="!formOpen && listing.items.value.length"
709
+ class="min-h-0 flex-1"
710
+ :pagination="listing.pagination.value"
711
+ :pending="listing.pending.value"
712
+ :loading-next="listing.loadingNextPage.value"
713
+ :loading-previous="listing.loadingPreviousPage.value"
714
+ :has-next="listing.hasNextPage.value"
715
+ :has-previous="listing.hasPreviousPage.value"
716
+ @next="listing.loadNext"
717
+ @previous="listing.loadPrevious"
718
+ @page="listing.goToPage"
719
+ >
720
+ <div class="grid gap-3">
721
+ <UCard
722
+ v-for="invoice in listing.items.value"
723
+ :key="invoice.id"
724
+ :class="invoice.isOverdue ? 'ring-1 ring-warning/60' : ''"
725
+ class="transition-colors hover:ring-1 hover:ring-primary/50"
726
+ >
727
+ <div class="grid grid-cols-[minmax(0,1fr)_2.75rem] items-center gap-3">
728
+ <NuxtLink
729
+ :to="`/admin/invoices/${invoice.id}`"
730
+ class="group min-w-0 flex-1 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
731
+ >
732
+ <div class="flex flex-wrap items-center gap-2">
733
+ <p class="min-w-0 break-words font-semibold">{{ invoice.number }} · {{ invoice.recipientName }}</p>
734
+ <UBadge :color="statusColor(invoice.status)" variant="subtle">{{
735
+ t(`features.invoices.admin.invoiceStatus.${invoice.status.toLowerCase()}`)
736
+ }}</UBadge
737
+ ><UBadge v-if="invoice.isOverdue" color="warning" variant="subtle">{{
738
+ t('features.invoices.admin.overdueDays', { count: invoice.daysOverdue })
739
+ }}</UBadge>
740
+ </div>
741
+ <p class="mt-1 whitespace-normal break-words text-sm text-muted">
742
+ {{ t('features.invoices.admin.dueDate') }} {{ invoice.dueDate }} ·
743
+ {{ money(invoice.totalMinor, invoice.currency) }} · {{ t('features.invoices.admin.outstanding') }}
744
+ {{ money(invoice.outstandingMinor, invoice.currency) }}
745
+ </p>
746
+ <p v-if="invoice.isOverdue" class="mt-1 text-xs text-warning">
747
+ {{
748
+ t('features.invoices.admin.reminderSummary', {
749
+ count: invoice.reminderCount,
750
+ date: invoice.lastReminderSentAt
751
+ ? dateTime(invoice.lastReminderSentAt)
752
+ : t('features.invoices.admin.never')
753
+ })
754
+ }}
755
+ </p>
756
+ </NuxtLink>
757
+ <NuxtLink
758
+ :to="`/admin/invoices/${invoice.id}`"
759
+ :aria-label="t('features.invoices.admin.openInvoice', { number: invoice.number })"
760
+ class="grid size-11 shrink-0 place-items-center justify-self-end rounded focus-visible:outline-2 focus-visible:outline-primary"
761
+ ><UIcon name="i-lucide-chevron-right" class="size-5 text-muted"
762
+ /></NuxtLink>
763
+ </div>
764
+ </UCard>
765
+ </div>
766
+ </InvoicesAdminPaginatedList>
767
+ </section>
768
+ </template>
769
+
770
+ <style scoped>
771
+ /* Hallmark · pre-emit critique: P4 H5 E4 S5 R5 V4
772
+ * Hallmark · genre: modern-minimal · macrostructure: Workbench · tone: utilitarian · designed-as-app
773
+ * contrast: pass (40–41) · tokens: pass (48) · responsive: pass (34, 49–57) · icons: pass (30)
774
+ */
775
+ .invoice-workbench {
776
+ display: grid;
777
+ gap: 1.5rem;
778
+ }
779
+ .invoice-source-card,
780
+ .invoice-source-card p {
781
+ white-space: normal !important;
782
+ overflow-wrap: anywhere;
783
+ }
784
+ .invoice-meta-grid {
785
+ display: grid;
786
+ gap: 1.5rem;
787
+ padding-block-end: 1.5rem;
788
+ border-block-end: 1px solid var(--invoices-rule);
789
+ }
790
+ .invoice-meta-column {
791
+ display: grid;
792
+ gap: 1rem;
793
+ align-content: start;
794
+ }
795
+ .invoice-ledger {
796
+ overflow: clip;
797
+ border: 1px solid var(--invoices-rule);
798
+ border-radius: var(--invoices-radius);
799
+ }
800
+ .invoice-ledger-head {
801
+ display: none;
802
+ }
803
+ .invoice-line {
804
+ display: grid;
805
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
806
+ gap: 0.75rem;
807
+ padding: 1rem;
808
+ border-block-end: 1px solid var(--invoices-rule);
809
+ }
810
+ .invoice-line-remove {
811
+ grid-column: 2;
812
+ justify-self: end;
813
+ min-width: 2.75rem;
814
+ min-height: 2.75rem;
815
+ }
816
+ .invoice-line-amount {
817
+ display: flex;
818
+ flex-direction: column;
819
+ gap: 0.5rem;
820
+ justify-content: end;
821
+ min-height: 2.75rem;
822
+ }
823
+ .invoice-line-description {
824
+ grid-column: 1 / -1;
825
+ }
826
+ .invoice-line-amount {
827
+ text-align: end;
828
+ }
829
+ .invoice-mobile-label {
830
+ font-size: 0.75rem;
831
+ color: var(--ui-text-muted);
832
+ }
833
+ .invoice-ledger-footer {
834
+ display: grid;
835
+ gap: 1.5rem;
836
+ align-items: start;
837
+ padding: 1rem;
838
+ }
839
+ .invoice-add-line {
840
+ width: fit-content;
841
+ justify-self: start;
842
+ }
843
+ .invoice-totals {
844
+ display: grid;
845
+ gap: 0.75rem;
846
+ width: min(100%, 22rem);
847
+ }
848
+ .invoice-totals > div {
849
+ display: grid;
850
+ grid-template-columns: minmax(0, 1fr) auto;
851
+ gap: 2rem;
852
+ }
853
+ .invoice-totals dd {
854
+ width: 12ch;
855
+ white-space: nowrap;
856
+ font-variant-numeric: tabular-nums;
857
+ text-align: end;
858
+ }
859
+ .invoice-total {
860
+ padding-block-start: 0.75rem;
861
+ border-block-start: 1px solid var(--invoices-rule);
862
+ font-weight: 700;
863
+ }
864
+ .invoice-actions {
865
+ display: flex;
866
+ flex-wrap: wrap;
867
+ justify-content: flex-end;
868
+ gap: 0.75rem;
869
+ padding-block-start: 1rem;
870
+ border-block-start: 1px solid var(--invoices-rule);
871
+ }
872
+ @media (min-width: 75rem) {
873
+ .invoice-meta-grid {
874
+ grid-template-columns: repeat(2, minmax(0, 1fr));
875
+ gap: clamp(2rem, 6vw, 6rem);
876
+ }
877
+ .invoice-ledger-head,
878
+ .invoice-line {
879
+ grid-template-columns: minmax(10rem, 1fr) 6.5rem 8rem 7.5rem 7rem 1.5rem;
880
+ }
881
+ .invoice-ledger-head {
882
+ display: grid;
883
+ gap: 0.75rem;
884
+ padding: 0.75rem 1rem;
885
+ border-block-end: 1px solid var(--invoices-rule);
886
+ font-size: 0.75rem;
887
+ color: var(--ui-text-muted);
888
+ }
889
+ .invoice-line {
890
+ align-items: start;
891
+ }
892
+ .invoice-line-description {
893
+ grid-column: 1;
894
+ grid-row: 1;
895
+ }
896
+ .invoice-line-quantity {
897
+ grid-column: 2;
898
+ grid-row: 1;
899
+ }
900
+ .invoice-line-price {
901
+ grid-column: 3;
902
+ grid-row: 1;
903
+ }
904
+ .invoice-line-amount {
905
+ grid-column: 4;
906
+ grid-row: 1;
907
+ min-height: 2rem;
908
+ margin-block-start: 0.25rem;
909
+ padding-block: 0;
910
+ align-items: flex-end;
911
+ justify-content: center;
912
+ }
913
+ .invoice-line-amount strong {
914
+ line-height: 1.25rem;
915
+ }
916
+ .invoice-line-vat {
917
+ grid-column: 5;
918
+ grid-row: 1;
919
+ }
920
+ .invoice-line-remove {
921
+ grid-column: 6;
922
+ grid-row: 1;
923
+ align-self: center;
924
+ justify-self: end;
925
+ width: 1.5rem;
926
+ min-width: 1.5rem;
927
+ min-height: 1.5rem;
928
+ padding: 0;
929
+ }
930
+ .invoice-line :deep(label) {
931
+ position: absolute;
932
+ inline-size: 1px;
933
+ block-size: 1px;
934
+ overflow: hidden;
935
+ clip: rect(0 0 0 0);
936
+ white-space: nowrap;
937
+ }
938
+ .invoice-mobile-label {
939
+ display: none;
940
+ }
941
+ .invoice-ledger-footer {
942
+ grid-template-columns: minmax(0, 1fr) auto;
943
+ }
944
+ }
945
+ @media (prefers-reduced-motion: reduce) {
946
+ .invoice-workbench * {
947
+ scroll-behavior: auto !important;
948
+ }
949
+ }
950
+ </style>