@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,1109 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type {
4
+ ClientInvoiceDto,
5
+ InvoiceDto,
6
+ InvoiceHistoryDto
7
+ } from '@nuxt-customer-portal/invoices/shared/types/invoice'
8
+ import { isKnownEmailProviderEvent } from '@nuxt-customer-portal/invoices/shared/email-delivery-status'
9
+
10
+ const props = withDefaults(
11
+ defineProps<{ invoice: InvoiceDto | ClientInvoiceDto; refresh: () => Promise<unknown>; mode?: 'admin' | 'client' }>(),
12
+ { mode: 'admin' }
13
+ )
14
+ const isClient = computed(() => props.mode === 'client')
15
+ const { t, locale } = useI18n()
16
+ const portalRuntimeSettings = useState<{
17
+ branding?: { portalName?: string; markLight?: string; markDark?: string }
18
+ } | null>('portal-runtime-settings', () => null)
19
+ const colorMode = useColorMode()
20
+ const senderLogo = computed(() => {
21
+ if (props.invoice.senderLogo) {
22
+ return props.invoice.senderLogo
23
+ }
24
+ const branding = portalRuntimeSettings.value?.branding
25
+ if (branding?.portalName?.trim().toLowerCase() !== props.invoice.senderName.trim().toLowerCase()) {
26
+ return ''
27
+ }
28
+ return colorMode.value === 'dark'
29
+ ? branding?.markDark || branding?.markLight || ''
30
+ : branding?.markLight || branding?.markDark || ''
31
+ })
32
+ const api = useInvoices()
33
+ const toast = useToast()
34
+ const busy = ref(false)
35
+ const refreshingEmailStatuses = ref(false)
36
+ const emailDeliveries = ref(props.invoice.emailDeliveries ?? [])
37
+ const paymentOpen = ref(false)
38
+ const editOpen = ref(false)
39
+ const emailOpen = ref(false)
40
+ const emailMode = ref<'issue' | 'resend' | 'reminder'>('issue')
41
+ const attachment = ref<File | null>(null)
42
+ const attachmentDeletion = ref<{ id: string; name: string } | null>(null)
43
+ const attachmentDeleteOpen = ref(false)
44
+ const statusConfirmation = ref<'VOID' | 'UNVOID' | null>(null)
45
+ const statusConfirmationOpen = ref(false)
46
+ const today = new Date().toISOString().slice(0, 10)
47
+ const payment = reactive({ paidOn: today, amount: props.invoice.outstandingMinor / 100, reference: '', note: '' })
48
+ const edit = reactive({
49
+ number: props.invoice.number,
50
+ issueDate: props.invoice.issueDate,
51
+ dueDate: props.invoice.dueDate,
52
+ subject: props.invoice.subject ?? '',
53
+ notes: props.invoice.notes ?? ''
54
+ })
55
+ watch(
56
+ () => props.invoice.emailDeliveries,
57
+ (deliveries) => {
58
+ emailDeliveries.value = deliveries ?? []
59
+ }
60
+ )
61
+ const paymentSchema = computed(() =>
62
+ z.object({
63
+ paidOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('features.invoices.validation.validDate')),
64
+ amount: z
65
+ .number()
66
+ .positive(t('features.invoices.validation.positiveAmount'))
67
+ .max(props.invoice.outstandingMinor / 100),
68
+ reference: z.string().trim().max(200),
69
+ note: z.string().trim().max(1000)
70
+ })
71
+ )
72
+ const editSchema = computed(() =>
73
+ z
74
+ .object({
75
+ number: z
76
+ .string()
77
+ .trim()
78
+ .min(1, t('features.invoices.validation.required'))
79
+ .max(60)
80
+ .regex(/\d+$/, t('features.invoices.validation.invoiceNumberSequence')),
81
+ issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('features.invoices.validation.validDate')),
82
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('features.invoices.validation.validDate')),
83
+ subject: z.string().trim().max(500),
84
+ notes: z.string().trim().max(5000)
85
+ })
86
+ .refine((value) => value.dueDate >= value.issueDate, {
87
+ path: ['dueDate'],
88
+ message: t('features.invoices.validation.dueDateOrder')
89
+ })
90
+ )
91
+ const money = (minor: number) =>
92
+ new Intl.NumberFormat(locale.value, { style: 'currency', currency: props.invoice.currency }).format(minor / 100)
93
+ const number = (value: number) =>
94
+ new Intl.NumberFormat(locale.value, { minimumFractionDigits: 2, maximumFractionDigits: 3 }).format(value / 1000)
95
+ const percentage = (basisPoints: number) =>
96
+ new Intl.NumberFormat(locale.value, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(basisPoints / 100)
97
+ const addressLines = (value: string) =>
98
+ value
99
+ .replace(/\s+(?=\d{4}\s?[A-Z]{2}\b)/g, '\n')
100
+ .replace(/\b(B\.?V\.?|N\.?V\.?|V\.?O\.?F\.?)\s+(?=\S)/gi, '$1\n')
101
+ .split(/\r?\n/)
102
+ .map((line) => line.trim())
103
+ .filter(Boolean)
104
+ const noteLines = (value: string) =>
105
+ value
106
+ .replace(/\s+(?=(?:IBAN|BIC):)/gi, '\n')
107
+ .split(/\r?\n/)
108
+ .map((line) => line.trim())
109
+ .filter(Boolean)
110
+ const date = (value: string) =>
111
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(`${value}T12:00:00`))
112
+ const dateTime = (value: string) =>
113
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
114
+ const statusColor = computed(() =>
115
+ props.invoice.status === 'PAID'
116
+ ? 'success'
117
+ : props.invoice.status === 'ISSUED'
118
+ ? 'info'
119
+ : props.invoice.status === 'VOID'
120
+ ? 'error'
121
+ : 'neutral'
122
+ )
123
+ const providerStatusColor = (status: string) => {
124
+ if (['delivered', 'opened', 'clicked'].includes(status)) {
125
+ return 'success'
126
+ }
127
+ if (['bounced', 'failed', 'suppressed', 'complained'].includes(status)) {
128
+ return 'error'
129
+ }
130
+ if (status === 'delivery_delayed') {
131
+ return 'warning'
132
+ }
133
+ return 'neutral'
134
+ }
135
+ const providerStatusLabel = (status: string) =>
136
+ isKnownEmailProviderEvent(status) ? t(`features.invoices.admin.emailProviderStatus.${status}`) : status
137
+ const historyText = (entry: InvoiceHistoryDto) => {
138
+ if (entry.action === 'PAYMENT_REGISTERED') {
139
+ return t('features.invoices.admin.invoiceHistory.payment', { amount: money(entry.amountMinor ?? 0) })
140
+ }
141
+ if (entry.action === 'ATTACHMENT_ADDED') {
142
+ return t('features.invoices.admin.invoiceHistory.attachment', { name: entry.attachmentName })
143
+ }
144
+ if (entry.action === 'ATTACHMENT_REMOVED') {
145
+ return t('features.invoices.admin.invoiceHistory.attachmentRemoved', { name: entry.attachmentName })
146
+ }
147
+ if (entry.action === 'EMAIL_SENT') {
148
+ return t('features.invoices.admin.invoiceHistory.emailed', { email: entry.attachmentName })
149
+ }
150
+ if (entry.action === 'REMINDER_SENT') {
151
+ return t('features.invoices.admin.invoiceHistory.reminderSent', { email: entry.attachmentName })
152
+ }
153
+ return t(`features.invoices.admin.invoiceHistory.${entry.action.toLowerCase()}`)
154
+ }
155
+ const run = async (operation: () => Promise<unknown>, success: string) => {
156
+ busy.value = true
157
+ try {
158
+ await operation()
159
+ await props.refresh()
160
+ toast.add({ title: success, color: 'success' })
161
+ return true
162
+ } catch (error) {
163
+ toast.add({ title: t('features.invoices.messages.saveError'), description: String(error), color: 'error' })
164
+ return false
165
+ } finally {
166
+ busy.value = false
167
+ }
168
+ }
169
+ const confirmStatusAction = async () => {
170
+ const action = statusConfirmation.value
171
+ if (!action) {
172
+ return
173
+ }
174
+ await run(
175
+ () => api.changeInvoiceStatus(props.invoice.id, action),
176
+ t(action === 'VOID' ? 'features.invoices.admin.invoiceVoided' : 'features.invoices.admin.invoiceUnvoided')
177
+ )
178
+ statusConfirmation.value = null
179
+ }
180
+ const requestStatusAction = (action: 'VOID' | 'UNVOID') => {
181
+ statusConfirmation.value = action
182
+ statusConfirmationOpen.value = true
183
+ }
184
+ const openEmail = (mode: 'issue' | 'resend' | 'reminder') => {
185
+ emailMode.value = mode
186
+ emailOpen.value = true
187
+ }
188
+ const openEdit = () => {
189
+ Object.assign(edit, {
190
+ number: props.invoice.number,
191
+ issueDate: props.invoice.issueDate,
192
+ dueDate: props.invoice.dueDate,
193
+ subject: props.invoice.subject ?? '',
194
+ notes: props.invoice.notes ?? ''
195
+ })
196
+ editOpen.value = true
197
+ }
198
+ const saveEdit = async () => {
199
+ await run(
200
+ () => api.updateInvoice(props.invoice.id, { ...edit, subject: edit.subject || null, notes: edit.notes || null }),
201
+ t('features.invoices.admin.invoiceUpdated')
202
+ )
203
+ editOpen.value = false
204
+ }
205
+ const menuItems = computed(() => {
206
+ if (props.invoice.status === 'VOID') {
207
+ return [
208
+ {
209
+ label: t('features.invoices.admin.unvoid'),
210
+ icon: 'i-lucide-rotate-ccw',
211
+ onSelect: () => requestStatusAction('UNVOID')
212
+ }
213
+ ]
214
+ }
215
+
216
+ const items = []
217
+ if (props.invoice.status === 'DRAFT') {
218
+ items.push({ label: t('features.invoices.admin.editInvoice'), icon: 'i-lucide-pencil', onSelect: openEdit })
219
+ }
220
+ items.push({
221
+ label: t('features.invoices.admin.void'),
222
+ icon: 'i-lucide-ban',
223
+ color: 'error' as const,
224
+ onSelect: () => requestStatusAction('VOID')
225
+ })
226
+ return items
227
+ })
228
+ const savePayment = async () => {
229
+ await run(
230
+ () =>
231
+ api.registerInvoicePayment(props.invoice.id, {
232
+ paidOn: payment.paidOn,
233
+ amountMinor: Math.round(payment.amount * 100),
234
+ reference: payment.reference || null,
235
+ note: payment.note || null
236
+ }),
237
+ t('features.invoices.admin.paymentRegistered')
238
+ )
239
+ paymentOpen.value = false
240
+ }
241
+ const selectAttachment = (event: Event) => {
242
+ attachment.value = (event.target as HTMLInputElement).files?.[0] ?? null
243
+ }
244
+ const uploadAttachment = async () => {
245
+ if (!attachment.value) {
246
+ return
247
+ }
248
+ await run(
249
+ () => api.addInvoiceAttachment(props.invoice.id, attachment.value!),
250
+ t('features.invoices.admin.attachmentAdded')
251
+ )
252
+ attachment.value = null
253
+ }
254
+ const requestAttachmentRemoval = (id: string, name: string) => {
255
+ attachmentDeletion.value = { id, name }
256
+ attachmentDeleteOpen.value = true
257
+ }
258
+ const removeAttachment = async () => {
259
+ if (!attachmentDeletion.value) {
260
+ return
261
+ }
262
+ await run(
263
+ () => api.deleteInvoiceAttachment(props.invoice.id, attachmentDeletion.value!.id),
264
+ t('features.invoices.admin.attachmentRemoved')
265
+ )
266
+ attachmentDeletion.value = null
267
+ }
268
+ const printInvoice = () => {
269
+ const base = isClient.value ? '/api/invoices/client/invoices' : '/api/invoices/admin/invoices'
270
+ window.open(
271
+ `${base}/${props.invoice.id}/pdf?locale=${encodeURIComponent(locale.value)}`,
272
+ '_blank',
273
+ 'noopener,noreferrer'
274
+ )
275
+ }
276
+ const downloadInvoice = () => {
277
+ const base = isClient.value ? '/api/invoices/client/invoices' : '/api/invoices/admin/invoices'
278
+ window.open(
279
+ `${base}/${props.invoice.id}/pdf?locale=${encodeURIComponent(locale.value)}&download=1`,
280
+ '_blank',
281
+ 'noopener,noreferrer'
282
+ )
283
+ }
284
+ const togglePayment = () => {
285
+ paymentOpen.value = !paymentOpen.value
286
+ }
287
+ const mobileMenuItems = computed(() => {
288
+ const items = []
289
+ if (!isClient.value && props.invoice.isOverdue) {
290
+ items.push({
291
+ label: t('features.invoices.admin.sendReminder'),
292
+ icon: 'i-lucide-bell-ring',
293
+ onSelect: () => openEmail('reminder')
294
+ })
295
+ }
296
+ if (!isClient.value && props.invoice.status === 'DRAFT') {
297
+ items.push({
298
+ label: t('features.invoices.admin.issueAndSend'),
299
+ icon: 'i-lucide-send',
300
+ onSelect: () => openEmail('issue')
301
+ })
302
+ }
303
+ if (!isClient.value && props.invoice.status === 'ISSUED' && !props.invoice.isOverdue) {
304
+ items.push({
305
+ label: t('features.invoices.admin.resendInvoice'),
306
+ icon: 'i-lucide-mail',
307
+ onSelect: () => openEmail('resend')
308
+ })
309
+ }
310
+ if (!isClient.value && props.invoice.status === 'ISSUED') {
311
+ items.push({
312
+ label: t('features.invoices.admin.registerPayment'),
313
+ icon: 'i-lucide-circle-dollar-sign',
314
+ onSelect: togglePayment
315
+ })
316
+ }
317
+ items.push(
318
+ { label: t('features.invoices.admin.print'), icon: 'i-lucide-printer', onSelect: printInvoice },
319
+ { label: t('features.invoices.clientInvoices.download'), icon: 'i-lucide-download', onSelect: downloadInvoice }
320
+ )
321
+ if (!isClient.value) {
322
+ items.push(...menuItems.value)
323
+ }
324
+ return items
325
+ })
326
+ const attachmentUrl = (attachmentId: string) =>
327
+ isClient.value
328
+ ? `/api/invoices/client/invoices/${props.invoice.id}/attachments/${attachmentId}`
329
+ : `/api/invoices/admin/invoices/${props.invoice.id}/attachments/${attachmentId}`
330
+ const refreshEmailStatuses = async (forceRefresh = false) => {
331
+ if (!emailDeliveries.value.some((delivery) => delivery.providerMessageId)) {
332
+ return
333
+ }
334
+ refreshingEmailStatuses.value = true
335
+ try {
336
+ const result = await api.refreshInvoiceEmailStatuses(props.invoice.id, forceRefresh)
337
+ emailDeliveries.value = result.deliveries
338
+ if (result.failures.length) {
339
+ toast.add({
340
+ title: t('features.invoices.admin.emailStatusRefreshPartial'),
341
+ description: t('features.invoices.admin.emailStatusRefreshPartialDescription'),
342
+ color: 'warning'
343
+ })
344
+ } else if (forceRefresh) {
345
+ toast.add({ title: t('features.invoices.admin.emailStatusRefreshed'), color: 'success' })
346
+ }
347
+ } catch {
348
+ toast.add({
349
+ title: t('features.invoices.admin.emailStatusRefreshFailed'),
350
+ description: t('features.invoices.admin.emailStatusRefreshFailedDescription'),
351
+ color: 'warning'
352
+ })
353
+ } finally {
354
+ refreshingEmailStatuses.value = false
355
+ }
356
+ }
357
+
358
+ onMounted(() => {
359
+ if (!isClient.value) {
360
+ void refreshEmailStatuses()
361
+ }
362
+ })
363
+ </script>
364
+
365
+ <template>
366
+ <div class="invoice-detail space-y-6">
367
+ <header class="invoice-detail-header">
368
+ <div class="min-w-0">
369
+ <UButton
370
+ :to="isClient ? '/invoices' : '/admin/invoices'"
371
+ variant="link"
372
+ color="neutral"
373
+ icon="i-lucide-arrow-left"
374
+ class="invoice-back-link mb-2 px-0"
375
+ >
376
+ {{ t('features.invoices.admin.backToInvoices') }}
377
+ </UButton>
378
+ <div class="invoice-title-line">
379
+ <h1 class="text-2xl font-semibold text-highlighted">
380
+ {{ t('features.invoices.admin.invoiceTitle', { number: invoice.number }) }}
381
+ </h1>
382
+ <div class="invoice-status-badges">
383
+ <UBadge :color="statusColor" variant="subtle">
384
+ {{ t(`features.invoices.admin.invoiceStatus.${invoice.status.toLowerCase()}`) }} </UBadge
385
+ ><UBadge v-if="invoice.isOverdue" color="warning" variant="subtle">
386
+ {{ t('features.invoices.admin.overdueDays', { count: invoice.daysOverdue }) }}
387
+ </UBadge>
388
+ </div>
389
+ </div>
390
+ </div>
391
+ <div class="invoice-detail-actions print:hidden">
392
+ <UDropdownMenu class="invoice-actions-compact" :items="mobileMenuItems" :content="{ align: 'end' }">
393
+ <UButton
394
+ color="neutral"
395
+ variant="outline"
396
+ icon="i-lucide-ellipsis-vertical"
397
+ :aria-label="t('features.invoices.admin.moreActions')"
398
+ />
399
+ </UDropdownMenu>
400
+ <UButton
401
+ v-if="!isClient && invoice.isOverdue"
402
+ class="invoice-actions-wide"
403
+ color="warning"
404
+ icon="i-lucide-bell-ring"
405
+ @click="openEmail('reminder')"
406
+ >
407
+ {{ t('features.invoices.admin.sendReminder') }}
408
+ </UButton>
409
+ <UButton
410
+ v-if="!isClient && invoice.status === 'DRAFT'"
411
+ class="invoice-actions-wide"
412
+ icon="i-lucide-send"
413
+ :loading="busy"
414
+ @click="openEmail('issue')"
415
+ >
416
+ {{ t('features.invoices.admin.issueAndSend') }}
417
+ </UButton>
418
+ <UButton
419
+ v-if="!isClient && invoice.status === 'ISSUED' && !invoice.isOverdue"
420
+ class="invoice-actions-wide"
421
+ icon="i-lucide-mail"
422
+ variant="outline"
423
+ :loading="busy"
424
+ @click="openEmail('resend')"
425
+ >
426
+ {{ t('features.invoices.admin.resendInvoice') }}
427
+ </UButton>
428
+ <UButton
429
+ v-if="!isClient && invoice.status === 'ISSUED'"
430
+ class="invoice-actions-wide"
431
+ color="success"
432
+ variant="outline"
433
+ icon="i-lucide-circle-dollar-sign"
434
+ @click="togglePayment"
435
+ >
436
+ {{ t('features.invoices.admin.registerPayment') }}
437
+ </UButton>
438
+ <UButton
439
+ class="invoice-actions-wide"
440
+ color="neutral"
441
+ variant="outline"
442
+ icon="i-lucide-printer"
443
+ @click="printInvoice"
444
+ >
445
+ {{ t('features.invoices.admin.print') }}
446
+ </UButton>
447
+ <UButton
448
+ class="invoice-actions-wide"
449
+ color="neutral"
450
+ variant="outline"
451
+ icon="i-lucide-download"
452
+ @click="downloadInvoice"
453
+ >
454
+ {{ t('features.invoices.clientInvoices.download') }}
455
+ </UButton>
456
+ <UDropdownMenu
457
+ v-if="!isClient && ['DRAFT', 'ISSUED', 'VOID'].includes(invoice.status)"
458
+ class="invoice-actions-wide"
459
+ :items="menuItems"
460
+ :content="{ align: 'end' }"
461
+ >
462
+ <UButton
463
+ color="neutral"
464
+ variant="ghost"
465
+ icon="i-lucide-ellipsis-vertical"
466
+ :aria-label="t('features.invoices.admin.moreActions')"
467
+ />
468
+ </UDropdownMenu>
469
+ </div>
470
+ </header>
471
+
472
+ <InvoicesInvoiceEmailModal
473
+ v-if="!isClient"
474
+ v-model:open="emailOpen"
475
+ :invoice-id="invoice.id"
476
+ :mode="emailMode"
477
+ :refresh="refresh"
478
+ />
479
+
480
+ <UAlert
481
+ v-if="!isClient && invoice.isOverdue"
482
+ class="print:hidden"
483
+ color="warning"
484
+ icon="i-lucide-triangle-alert"
485
+ :title="t('features.invoices.admin.overdueAlertTitle', { count: invoice.daysOverdue })"
486
+ variant="outline"
487
+ >
488
+ <template #description>
489
+ <div class="space-y-2">
490
+ <p>
491
+ {{
492
+ t('features.invoices.admin.overdueAlertDescription', {
493
+ dueDate: date(invoice.dueDate),
494
+ amount: money(invoice.outstandingMinor)
495
+ })
496
+ }}
497
+ </p>
498
+ <p>
499
+ {{
500
+ t('features.invoices.admin.reminderSummary', {
501
+ count: invoice.reminderCount ?? 0,
502
+ date: invoice.lastReminderSentAt
503
+ ? dateTime(invoice.lastReminderSentAt)
504
+ : t('features.invoices.admin.never')
505
+ })
506
+ }}
507
+ </p>
508
+ </div>
509
+ </template>
510
+ </UAlert>
511
+
512
+ <UCard v-if="!isClient && editOpen" class="print:hidden">
513
+ <template #header>
514
+ <div class="flex items-center justify-between gap-3">
515
+ <h2 class="font-semibold">{{ t('features.invoices.admin.editInvoice') }}</h2>
516
+ <UButton
517
+ color="neutral"
518
+ variant="ghost"
519
+ icon="i-lucide-x"
520
+ :aria-label="t('features.invoices.cancel')"
521
+ @click="editOpen = false"
522
+ />
523
+ </div>
524
+ </template>
525
+ <UForm :state="edit" :schema="editSchema" class="grid gap-4" @submit="saveEdit">
526
+ <UFormField name="number" :label="t('features.invoices.admin.invoiceNumber')">
527
+ <UInput v-model="edit.number" class="w-full" />
528
+ </UFormField>
529
+ <div class="grid gap-3 sm:grid-cols-2">
530
+ <UFormField name="issueDate" :label="t('features.invoices.admin.invoiceDate')">
531
+ <UInput v-model="edit.issueDate" type="date" class="w-full" /> </UFormField
532
+ ><UFormField name="dueDate" :label="t('features.invoices.admin.dueDate')">
533
+ <UInput v-model="edit.dueDate" type="date" class="w-full" />
534
+ </UFormField>
535
+ </div>
536
+ <UFormField name="subject" :label="t('features.invoices.admin.subject')">
537
+ <UTextarea v-model="edit.subject" :rows="3" class="w-full" /> </UFormField
538
+ ><UFormField name="notes" :label="t('features.invoices.admin.notes')">
539
+ <UTextarea v-model="edit.notes" :rows="5" class="w-full" />
540
+ </UFormField>
541
+ <div class="flex justify-end gap-2">
542
+ <UButton type="button" color="neutral" variant="ghost" @click="editOpen = false">
543
+ {{ t('features.invoices.cancel') }} </UButton
544
+ ><UButton type="submit" icon="i-lucide-save" :loading="busy">{{ t('features.invoices.save') }}</UButton>
545
+ </div>
546
+ </UForm>
547
+ </UCard>
548
+
549
+ <UCard v-if="!isClient && paymentOpen" class="print:hidden">
550
+ <UForm
551
+ :state="payment"
552
+ :schema="paymentSchema"
553
+ class="grid gap-3 md:grid-cols-2 xl:grid-cols-4"
554
+ @submit="savePayment"
555
+ >
556
+ <UFormField name="paidOn" :label="t('features.invoices.admin.paidOn')">
557
+ <UInput v-model="payment.paidOn" type="date" class="w-full" />
558
+ </UFormField>
559
+ <UFormField name="amount" :label="t('features.invoices.admin.paymentAmount')">
560
+ <UInputNumber
561
+ v-model="payment.amount"
562
+ :min="0.01"
563
+ :max="invoice.outstandingMinor / 100"
564
+ :step="0.01"
565
+ :increment="false"
566
+ :decrement="false"
567
+ :ui="{ base: 'text-right' }"
568
+ class="w-full"
569
+ />
570
+ </UFormField>
571
+ <UFormField name="reference" :label="t('features.invoices.admin.reference')">
572
+ <UInput v-model="payment.reference" class="w-full" />
573
+ </UFormField>
574
+ <UButton type="submit" color="success" icon="i-lucide-check" :loading="busy" class="self-end">
575
+ {{ t('features.invoices.admin.registerPayment') }}
576
+ </UButton>
577
+ </UForm>
578
+ </UCard>
579
+
580
+ <article class="invoice-paper">
581
+ <div class="invoice-paper-heading">
582
+ <div class="invoice-brand">
583
+ <img v-if="senderLogo" :src="senderLogo" :alt="invoice.senderName" class="invoice-logo" />
584
+ <p v-else class="invoice-wordmark">{{ invoice.senderName }}</p>
585
+ <dl class="invoice-sender-details mt-3 text-sm text-muted">
586
+ <div v-if="invoice.senderIban">
587
+ <dt class="mr-1 inline">IBAN:</dt>
588
+ <dd class="inline">{{ invoice.senderIban }}</dd>
589
+ </div>
590
+ <div v-if="invoice.senderBic">
591
+ <dt class="mr-1 inline">BIC:</dt>
592
+ <dd class="inline">{{ invoice.senderBic }}</dd>
593
+ </div>
594
+ <div v-if="invoice.senderRegistration">
595
+ <dt>{{ t('features.invoices.admin.registrationShort') }}:</dt>
596
+ <dd class="inline">{{ invoice.senderRegistration }}</dd>
597
+ </div>
598
+ <div v-if="invoice.senderVatNumber">
599
+ <dt>{{ t('features.invoices.admin.vatNumberShort') }}:</dt>
600
+ <dd class="inline">{{ invoice.senderVatNumber }}</dd>
601
+ </div>
602
+ </dl>
603
+ </div>
604
+ <div class="text-right">
605
+ <p class="text-xl font-semibold">{{ t('features.invoices.admin.invoiceDocumentTitle') }}</p>
606
+ <p class="invoice-label mt-3">{{ t('features.invoices.admin.from') }}</p>
607
+ <p class="font-medium">{{ invoice.senderName }}</p>
608
+ <address class="not-italic text-sm text-muted">
609
+ <span v-for="(line, index) in addressLines(invoice.senderAddress)" :key="index" class="block">{{
610
+ line
611
+ }}</span>
612
+ </address>
613
+ </div>
614
+ </div>
615
+ <div class="invoice-parties">
616
+ <section>
617
+ <p class="invoice-label">{{ t('features.invoices.admin.recipient') }}</p>
618
+ <p class="font-semibold">{{ invoice.recipientName }}</p>
619
+ <address class="not-italic text-sm">
620
+ <span v-for="(line, index) in addressLines(invoice.recipientAddress)" :key="index" class="block">{{
621
+ line
622
+ }}</span>
623
+ </address>
624
+ <p v-if="invoice.recipientContactName" class="mt-2 text-sm">
625
+ {{ invoice.recipientContactName }} · {{ invoice.recipientEmail }}
626
+ </p>
627
+ </section>
628
+ <dl class="invoice-metadata">
629
+ <div>
630
+ <dt>{{ t('features.invoices.admin.invoiceNumber') }}</dt>
631
+ <dd>{{ invoice.number }}</dd>
632
+ </div>
633
+ <div>
634
+ <dt>{{ t('features.invoices.admin.invoiceDate') }}</dt>
635
+ <dd>{{ date(invoice.issueDate) }}</dd>
636
+ </div>
637
+ <div>
638
+ <dt>{{ t('features.invoices.admin.dueDate') }}</dt>
639
+ <dd>{{ date(invoice.dueDate) }}</dd>
640
+ </div>
641
+ </dl>
642
+ </div>
643
+ <div v-if="invoice.subject" class="invoice-subject">
644
+ <span class="invoice-label">{{ t('features.invoices.admin.subject') }}</span>
645
+ <p class="whitespace-pre-line">{{ invoice.subject }}</p>
646
+ </div>
647
+ <div class="invoice-table-wrap">
648
+ <table class="invoice-table">
649
+ <thead>
650
+ <tr>
651
+ <th>{{ t('features.invoices.admin.description') }}</th>
652
+ <th>{{ t('features.invoices.admin.quantity') }}</th>
653
+ <th>{{ t('features.invoices.admin.unitPrice') }}</th>
654
+ <th>{{ t('features.invoices.admin.vat') }}</th>
655
+ <th>{{ t('features.invoices.admin.amount') }}</th>
656
+ </tr>
657
+ </thead>
658
+ <tbody>
659
+ <tr v-for="line in invoice.lines" :key="line.id">
660
+ <td>{{ line.description }}</td>
661
+ <td>{{ number(line.quantityMilli) }}</td>
662
+ <td>{{ money(line.unitPriceMinor) }}</td>
663
+ <td>{{ percentage(line.vatRateBasisPoints) }}%</td>
664
+ <td>{{ money(line.amountMinor) }}</td>
665
+ </tr>
666
+ </tbody>
667
+ </table>
668
+ </div>
669
+ <div class="invoice-summary">
670
+ <dl>
671
+ <div>
672
+ <dt>{{ t('features.invoices.admin.subtotal') }}</dt>
673
+ <dd>{{ money(invoice.subtotalMinor) }}</dd>
674
+ </div>
675
+ <div>
676
+ <dt>{{ t('features.invoices.admin.vat') }}</dt>
677
+ <dd>{{ money(invoice.vatMinor) }}</dd>
678
+ </div>
679
+ <div v-if="invoice.paidMinor">
680
+ <dt>{{ t('features.invoices.admin.paid') }}</dt>
681
+ <dd>− {{ money(invoice.paidMinor) }}</dd>
682
+ </div>
683
+ <div class="invoice-summary-total">
684
+ <dt>{{ t('features.invoices.admin.totalPayable') }}</dt>
685
+ <dd>{{ money(invoice.outstandingMinor) }}</dd>
686
+ </div>
687
+ </dl>
688
+ </div>
689
+ <section v-if="invoice.notes" class="invoice-notes">
690
+ <h2 class="invoice-label">{{ t('features.invoices.admin.notes') }}</h2>
691
+ <p class="text-sm">
692
+ <span v-for="(line, index) in noteLines(invoice.notes)" :key="index" class="block">{{ line }}</span>
693
+ </p>
694
+ </section>
695
+ <section class="invoice-attachments print:hidden">
696
+ <div class="flex flex-wrap items-center justify-between gap-3">
697
+ <h2 class="font-semibold">{{ t('features.invoices.admin.attachments') }}</h2>
698
+ <div v-if="!isClient" class="flex flex-wrap items-center gap-2">
699
+ <input type="file" class="max-w-64 text-sm" @change="selectAttachment" /><UButton
700
+ size="sm"
701
+ variant="outline"
702
+ icon="i-lucide-paperclip"
703
+ :disabled="!attachment"
704
+ :loading="busy"
705
+ @click="uploadAttachment"
706
+ >
707
+ {{ t('features.invoices.admin.attachFile') }}
708
+ </UButton>
709
+ </div>
710
+ </div>
711
+ <div v-if="invoice.attachments?.length" class="mt-3 grid gap-2">
712
+ <div
713
+ v-for="file in invoice.attachments"
714
+ :key="file.id"
715
+ class="flex items-center gap-2 rounded-md border border-default p-3"
716
+ >
717
+ <a :href="attachmentUrl(file.id)" class="min-w-0 flex-1 truncate text-sm font-medium hover:text-primary">{{
718
+ file.fileName
719
+ }}</a
720
+ ><span class="shrink-0 text-xs text-muted">{{ Math.ceil(file.size / 1024) }} KB</span
721
+ ><UButton
722
+ v-if="!isClient"
723
+ size="xs"
724
+ color="error"
725
+ variant="ghost"
726
+ icon="i-lucide-trash-2"
727
+ :aria-label="t('features.invoices.admin.removeAttachment')"
728
+ @click="requestAttachmentRemoval(file.id, file.fileName)"
729
+ />
730
+ </div>
731
+ </div>
732
+ <p v-else class="mt-2 text-sm text-muted">{{ t('features.invoices.admin.noAttachments') }}</p>
733
+ </section>
734
+ </article>
735
+
736
+ <section v-if="!isClient" class="invoice-history print:hidden">
737
+ <h2 class="text-xl font-semibold">{{ t('features.invoices.admin.invoiceHistoryTitle') }}</h2>
738
+ <ol class="mt-4 divide-y divide-default border-y border-default">
739
+ <li v-for="entry in invoice.history" :key="entry.id" class="invoice-history-row">
740
+ <div class="invoice-history-event">
741
+ <span class="invoice-history-dot" />
742
+ <div class="min-w-0">
743
+ <p class="font-medium">{{ historyText(entry) }}</p>
744
+ <p class="mt-0.5 text-sm text-muted">{{ entry.actorName }}</p>
745
+ </div>
746
+ </div>
747
+ <time class="invoice-history-time">{{ dateTime(entry.createdAt) }}</time>
748
+ </li>
749
+ </ol>
750
+ </section>
751
+ <section v-if="!isClient && emailDeliveries.length" class="print:hidden">
752
+ <div class="flex flex-wrap items-center justify-between gap-3">
753
+ <div>
754
+ <h2 class="text-xl font-semibold">{{ t('features.invoices.admin.emailDeliveryHistory') }}</h2>
755
+ <p class="mt-1 max-w-3xl text-sm text-muted">{{ t('features.invoices.admin.emailTrackingExplanation') }}</p>
756
+ </div>
757
+ <UButton
758
+ type="button"
759
+ color="neutral"
760
+ variant="outline"
761
+ size="sm"
762
+ icon="i-lucide-refresh-cw"
763
+ :loading="refreshingEmailStatuses"
764
+ @click="refreshEmailStatuses(true)"
765
+ >
766
+ {{ t('features.invoices.admin.refreshEmailStatus') }}
767
+ </UButton>
768
+ </div>
769
+ <div class="mt-3 divide-y divide-default border-y border-default">
770
+ <div v-for="delivery in emailDeliveries" :key="delivery.id" class="flex items-start justify-between gap-4 py-3">
771
+ <div>
772
+ <p class="font-medium">
773
+ {{ delivery.recipientEmail
774
+ }}<span v-if="delivery.ccEmails.length"> · CC {{ delivery.ccEmails.join(', ') }}</span>
775
+ </p>
776
+ <p class="text-sm text-muted">
777
+ <UBadge v-if="delivery.purpose === 'REMINDER'" color="warning" variant="subtle" size="xs" class="mr-2">
778
+ {{ t('features.invoices.admin.paymentReminder') }} </UBadge
779
+ >{{ delivery.subject }} · {{ delivery.actorName }}
780
+ </p>
781
+ <p v-if="delivery.sentAt" class="text-xs text-muted">
782
+ {{ t('features.invoices.admin.emailSentAt', { date: dateTime(delivery.sentAt) }) }}
783
+ </p>
784
+ <p v-if="delivery.providerStatusCheckedAt" class="text-xs text-muted">
785
+ {{
786
+ t('features.invoices.admin.emailStatusCheckedAt', { date: dateTime(delivery.providerStatusCheckedAt) })
787
+ }}
788
+ </p>
789
+ <p v-if="delivery.errorMessage" class="text-sm text-error">{{ delivery.errorMessage }}</p>
790
+ </div>
791
+ <div class="flex flex-wrap justify-end gap-2">
792
+ <UBadge
793
+ :color="delivery.status === 'SENT' ? 'success' : delivery.status === 'FAILED' ? 'error' : 'warning'"
794
+ variant="subtle"
795
+ >
796
+ {{ t(`features.invoices.admin.emailDeliveryStatus.${delivery.status.toLowerCase()}`) }} </UBadge
797
+ ><UBadge
798
+ v-if="delivery.providerLastEvent"
799
+ :color="providerStatusColor(delivery.providerLastEvent)"
800
+ variant="subtle"
801
+ >
802
+ {{ providerStatusLabel(delivery.providerLastEvent) }}
803
+ </UBadge>
804
+ </div>
805
+ </div>
806
+ </div>
807
+ </section>
808
+ <ConfirmationModal
809
+ v-if="!isClient"
810
+ v-model:open="attachmentDeleteOpen"
811
+ :title="t('features.invoices.admin.removeAttachment')"
812
+ :message="t('features.invoices.admin.removeAttachmentDescription', { name: attachmentDeletion?.name })"
813
+ :confirm-text="t('features.invoices.admin.removeAttachment')"
814
+ :cancel-text="t('features.invoices.cancel')"
815
+ confirm-color="error"
816
+ @confirm="removeAttachment"
817
+ @cancel="attachmentDeletion = null"
818
+ />
819
+ <ConfirmationModal
820
+ v-if="!isClient"
821
+ v-model:open="statusConfirmationOpen"
822
+ :title="
823
+ t(
824
+ statusConfirmation === 'VOID'
825
+ ? 'features.invoices.admin.voidInvoiceTitle'
826
+ : 'features.invoices.admin.unvoidInvoiceTitle'
827
+ )
828
+ "
829
+ :message="
830
+ t(
831
+ statusConfirmation === 'VOID'
832
+ ? 'features.invoices.admin.voidInvoiceDescription'
833
+ : 'features.invoices.admin.unvoidInvoiceDescription',
834
+ { number: invoice.number }
835
+ )
836
+ "
837
+ :confirm-text="
838
+ t(statusConfirmation === 'VOID' ? 'features.invoices.admin.void' : 'features.invoices.admin.unvoid')
839
+ "
840
+ :cancel-text="t('features.invoices.cancel')"
841
+ :confirm-color="statusConfirmation === 'VOID' ? 'error' : 'primary'"
842
+ @confirm="confirmStatusAction"
843
+ @cancel="statusConfirmation = null"
844
+ />
845
+ </div>
846
+ </template>
847
+
848
+ <style scoped>
849
+ /* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4
850
+ * Hallmark · macrostructure: Long Document · tone: professional · anchor hue: existing-primary
851
+ */
852
+ .invoice-detail {
853
+ container-type: inline-size;
854
+ }
855
+ .invoice-back-link {
856
+ min-height: auto;
857
+ border: 0;
858
+ border-radius: 0;
859
+ background: transparent;
860
+ box-shadow: none;
861
+ letter-spacing: normal;
862
+ text-transform: none;
863
+ }
864
+ .invoice-back-link:hover {
865
+ background: transparent;
866
+ box-shadow: none;
867
+ text-decoration: underline;
868
+ text-underline-offset: 0.2em;
869
+ }
870
+ .invoice-detail-header {
871
+ display: grid;
872
+ grid-template-columns: minmax(0, 1fr) auto;
873
+ align-items: end;
874
+ gap: 1rem;
875
+ }
876
+ .invoice-detail h1 {
877
+ min-width: 0;
878
+ overflow-wrap: anywhere;
879
+ }
880
+ .invoice-title-line {
881
+ display: grid;
882
+ gap: 0.6rem;
883
+ }
884
+ .invoice-status-badges {
885
+ display: flex;
886
+ min-width: 0;
887
+ flex-wrap: wrap;
888
+ gap: 0.5rem;
889
+ }
890
+ .invoice-detail-actions {
891
+ display: flex;
892
+ width: auto;
893
+ flex-wrap: wrap;
894
+ justify-content: flex-end;
895
+ gap: 0.5rem;
896
+ }
897
+ .invoice-actions-compact {
898
+ display: inline-flex;
899
+ }
900
+ .invoice-actions-wide {
901
+ display: none;
902
+ }
903
+ .invoice-paper {
904
+ padding: clamp(1.25rem, 4vw, 3rem);
905
+ overflow: clip;
906
+ border: 1px solid var(--invoices-rule);
907
+ border-radius: var(--invoices-radius);
908
+ background: var(--ui-bg);
909
+ }
910
+ .invoice-paper-heading,
911
+ .invoice-parties {
912
+ display: grid;
913
+ gap: 2rem;
914
+ }
915
+ .invoice-wordmark {
916
+ font-size: 1.25rem;
917
+ font-weight: 700;
918
+ color: var(--ui-primary);
919
+ }
920
+ .invoice-brand {
921
+ display: flex;
922
+ flex-direction: column;
923
+ align-items: flex-start;
924
+ gap: 1rem;
925
+ }
926
+ .invoice-sender-details {
927
+ display: grid;
928
+ grid-template-columns: max-content minmax(0, 1fr);
929
+ gap: 0.25rem 0.75rem;
930
+ text-align: left;
931
+ }
932
+ .invoice-sender-details > div {
933
+ display: contents;
934
+ }
935
+ .invoice-sender-details dd {
936
+ margin: 0;
937
+ overflow-wrap: anywhere;
938
+ }
939
+ .invoice-logo {
940
+ width: auto;
941
+ max-width: 12rem;
942
+ height: auto;
943
+ max-height: 5rem;
944
+ object-fit: contain;
945
+ object-position: left top;
946
+ }
947
+ .invoice-parties {
948
+ margin-block: clamp(3rem, 8vw, 7rem) 2rem;
949
+ }
950
+ .invoice-label {
951
+ margin-block-end: 0.35rem;
952
+ font-size: 0.75rem;
953
+ color: var(--ui-text-muted);
954
+ }
955
+ .invoice-metadata {
956
+ display: grid;
957
+ gap: 0.4rem;
958
+ font-size: 0.875rem;
959
+ }
960
+ .invoice-metadata div {
961
+ display: grid;
962
+ grid-template-columns: minmax(0, 1fr) auto;
963
+ gap: 1.5rem;
964
+ }
965
+ .invoice-metadata dt {
966
+ color: var(--ui-text-muted);
967
+ }
968
+ .invoice-subject {
969
+ display: grid;
970
+ gap: 0.5rem;
971
+ padding-block: 1rem;
972
+ border-block: 1px solid var(--invoices-rule);
973
+ }
974
+ .invoice-table-wrap {
975
+ margin-block-start: 2rem;
976
+ overflow-x: auto;
977
+ }
978
+ .invoice-table {
979
+ width: 100%;
980
+ min-width: 42rem;
981
+ border-collapse: collapse;
982
+ font-size: 0.875rem;
983
+ }
984
+ .invoice-table th,
985
+ .invoice-table td {
986
+ padding: 0.75rem;
987
+ border-block-end: 1px solid var(--invoices-rule);
988
+ text-align: right;
989
+ }
990
+ .invoice-table th {
991
+ white-space: nowrap;
992
+ }
993
+ .invoice-table th:first-child,
994
+ .invoice-table td:first-child {
995
+ text-align: left;
996
+ padding-inline-start: 0;
997
+ }
998
+ .invoice-table th:last-child,
999
+ .invoice-table td:last-child {
1000
+ padding-inline-end: 0;
1001
+ }
1002
+ .invoice-summary {
1003
+ display: flex;
1004
+ justify-content: flex-end;
1005
+ padding-block: 1.5rem;
1006
+ }
1007
+ .invoice-summary dl {
1008
+ display: grid;
1009
+ gap: 0.6rem;
1010
+ width: min(100%, 22rem);
1011
+ }
1012
+ .invoice-summary dl > div {
1013
+ display: grid;
1014
+ grid-template-columns: minmax(0, 1fr) 12ch;
1015
+ gap: 2rem;
1016
+ }
1017
+ .invoice-summary dd {
1018
+ font-variant-numeric: tabular-nums;
1019
+ text-align: right;
1020
+ }
1021
+ .invoice-summary-total {
1022
+ padding-block-start: 0.75rem;
1023
+ border-block-start: 1px solid var(--invoices-rule);
1024
+ font-weight: 700;
1025
+ }
1026
+ .invoice-notes,
1027
+ .invoice-attachments {
1028
+ margin-block-start: 2rem;
1029
+ padding-block-start: 1rem;
1030
+ border-block-start: 1px solid var(--invoices-rule);
1031
+ }
1032
+ .invoice-history-row {
1033
+ display: grid;
1034
+ gap: 0.4rem 1.5rem;
1035
+ padding-block: 1rem;
1036
+ }
1037
+ .invoice-history-event {
1038
+ display: flex;
1039
+ min-width: 0;
1040
+ align-items: flex-start;
1041
+ gap: 0.75rem;
1042
+ }
1043
+ .invoice-history-dot {
1044
+ width: 0.5rem;
1045
+ height: 0.5rem;
1046
+ flex: none;
1047
+ margin-block-start: 0.45rem;
1048
+ border-radius: 50%;
1049
+ background: var(--ui-primary);
1050
+ }
1051
+ .invoice-history-time {
1052
+ padding-inline-start: 1.25rem;
1053
+ font-size: 0.875rem;
1054
+ color: var(--ui-text-muted);
1055
+ }
1056
+ @media (min-width: 48rem) {
1057
+ .invoice-title-line {
1058
+ display: flex;
1059
+ flex-wrap: wrap;
1060
+ align-items: center;
1061
+ gap: 0.75rem;
1062
+ }
1063
+ .invoice-paper-heading,
1064
+ .invoice-parties {
1065
+ grid-template-columns: minmax(0, 1fr) minmax(16rem, 0.65fr);
1066
+ }
1067
+ .invoice-parties > :last-child {
1068
+ align-self: end;
1069
+ }
1070
+ }
1071
+ @container (min-width: 80rem) {
1072
+ .invoice-detail-header {
1073
+ display: flex;
1074
+ flex-direction: row;
1075
+ align-items: flex-end;
1076
+ justify-content: space-between;
1077
+ }
1078
+ .invoice-detail-header > :first-child {
1079
+ flex: 1;
1080
+ }
1081
+ .invoice-detail-actions {
1082
+ width: max-content;
1083
+ flex-wrap: nowrap;
1084
+ justify-content: flex-end;
1085
+ }
1086
+ .invoice-actions-compact {
1087
+ display: none;
1088
+ }
1089
+ .invoice-actions-wide {
1090
+ display: inline-flex;
1091
+ }
1092
+ }
1093
+ @media (min-width: 40rem) {
1094
+ .invoice-history-row {
1095
+ grid-template-columns: minmax(0, 1fr) auto;
1096
+ align-items: center;
1097
+ }
1098
+ .invoice-history-time {
1099
+ padding-inline-start: 0;
1100
+ text-align: end;
1101
+ }
1102
+ }
1103
+ @media print {
1104
+ .invoice-paper {
1105
+ border: 0;
1106
+ box-shadow: none;
1107
+ }
1108
+ }
1109
+ </style>