@fayz-ai/plugin-financial 0.1.1

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 (98) hide show
  1. package/dist/FinancialContext.d.ts +44 -0
  2. package/dist/FinancialContext.d.ts.map +1 -0
  3. package/dist/FinancialPage.d.ts +18 -0
  4. package/dist/FinancialPage.d.ts.map +1 -0
  5. package/dist/components/FinancialGeneralSettings.d.ts +3 -0
  6. package/dist/components/FinancialGeneralSettings.d.ts.map +1 -0
  7. package/dist/components/FinancialOnboarding.d.ts +5 -0
  8. package/dist/components/FinancialOnboarding.d.ts.map +1 -0
  9. package/dist/components/FinancialSettings.d.ts +8 -0
  10. package/dist/components/FinancialSettings.d.ts.map +1 -0
  11. package/dist/components/PaymentModal.d.ts +8 -0
  12. package/dist/components/PaymentModal.d.ts.map +1 -0
  13. package/dist/data/index.d.ts +3 -0
  14. package/dist/data/index.d.ts.map +1 -0
  15. package/dist/data/mock.d.ts +9 -0
  16. package/dist/data/mock.d.ts.map +1 -0
  17. package/dist/data/supabase.d.ts +3 -0
  18. package/dist/data/supabase.d.ts.map +1 -0
  19. package/dist/data/types.d.ts +27 -0
  20. package/dist/data/types.d.ts.map +1 -0
  21. package/dist/index.cjs +4937 -0
  22. package/dist/index.cjs.map +1 -0
  23. package/dist/index.d.ts +91 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +4930 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/lib/tenant.d.ts +3 -0
  28. package/dist/lib/tenant.d.ts.map +1 -0
  29. package/dist/locales/en.d.ts +2 -0
  30. package/dist/locales/en.d.ts.map +1 -0
  31. package/dist/locales/index.d.ts +2 -0
  32. package/dist/locales/index.d.ts.map +1 -0
  33. package/dist/locales/pt-BR.d.ts +2 -0
  34. package/dist/locales/pt-BR.d.ts.map +1 -0
  35. package/dist/registries.d.ts +3 -0
  36. package/dist/registries.d.ts.map +1 -0
  37. package/dist/store.d.ts +38 -0
  38. package/dist/store.d.ts.map +1 -0
  39. package/dist/types.d.ts +302 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/views/CardsView.d.ts +3 -0
  42. package/dist/views/CardsView.d.ts.map +1 -0
  43. package/dist/views/CashRegistersView.d.ts +3 -0
  44. package/dist/views/CashRegistersView.d.ts.map +1 -0
  45. package/dist/views/CommissionsView.d.ts +3 -0
  46. package/dist/views/CommissionsView.d.ts.map +1 -0
  47. package/dist/views/InvoiceDetailView.d.ts +9 -0
  48. package/dist/views/InvoiceDetailView.d.ts.map +1 -0
  49. package/dist/views/InvoiceFormView.d.ts +13 -0
  50. package/dist/views/InvoiceFormView.d.ts.map +1 -0
  51. package/dist/views/InvoiceListView.d.ts +8 -0
  52. package/dist/views/InvoiceListView.d.ts.map +1 -0
  53. package/dist/views/PayablesView.d.ts +7 -0
  54. package/dist/views/PayablesView.d.ts.map +1 -0
  55. package/dist/views/ReceivablesView.d.ts +7 -0
  56. package/dist/views/ReceivablesView.d.ts.map +1 -0
  57. package/dist/views/StatementsView.d.ts +3 -0
  58. package/dist/views/StatementsView.d.ts.map +1 -0
  59. package/dist/views/SummaryView.d.ts +9 -0
  60. package/dist/views/SummaryView.d.ts.map +1 -0
  61. package/dist/views/dashboardWidgets.d.ts +11 -0
  62. package/dist/views/dashboardWidgets.d.ts.map +1 -0
  63. package/package.json +55 -0
  64. package/src/FinancialContext.tsx +47 -0
  65. package/src/FinancialPage.tsx +261 -0
  66. package/src/README.md +155 -0
  67. package/src/components/FinancialGeneralSettings.tsx +26 -0
  68. package/src/components/FinancialOnboarding.tsx +87 -0
  69. package/src/components/FinancialSettings.tsx +31 -0
  70. package/src/components/PaymentModal.tsx +322 -0
  71. package/src/data/index.ts +2 -0
  72. package/src/data/mock.ts +520 -0
  73. package/src/data/supabase.ts +509 -0
  74. package/src/data/types.ts +53 -0
  75. package/src/index.ts +307 -0
  76. package/src/lib/tenant.ts +3 -0
  77. package/src/locales/en.ts +331 -0
  78. package/src/locales/index.ts +7 -0
  79. package/src/locales/pt-BR.ts +331 -0
  80. package/src/migrations/001_financial_base.sql +109 -0
  81. package/src/migrations/002_chart_of_accounts.sql +28 -0
  82. package/src/migrations/003_card_brands.sql +12 -0
  83. package/src/migrations/004_order_to_cash.sql +164 -0
  84. package/src/migrations/005_seed_defaults.sql +38 -0
  85. package/src/registries.ts +305 -0
  86. package/src/store.ts +221 -0
  87. package/src/types.ts +359 -0
  88. package/src/views/CardsView.tsx +74 -0
  89. package/src/views/CashRegistersView.tsx +597 -0
  90. package/src/views/CommissionsView.tsx +18 -0
  91. package/src/views/InvoiceDetailView.tsx +595 -0
  92. package/src/views/InvoiceFormView.tsx +900 -0
  93. package/src/views/InvoiceListView.tsx +177 -0
  94. package/src/views/PayablesView.tsx +37 -0
  95. package/src/views/ReceivablesView.tsx +37 -0
  96. package/src/views/StatementsView.tsx +123 -0
  97. package/src/views/SummaryView.tsx +12 -0
  98. package/src/views/dashboardWidgets.tsx +191 -0
@@ -0,0 +1,595 @@
1
+ import React, { useEffect, useState, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Pencil, DollarSign, FileText, Calendar, Hash, User, X, MoreVertical, Ban, ChevronDown, CreditCard, Banknote, Building2, CircleDashed, CircleEllipsis, CircleCheckBig, CircleAlert, CalendarDays, ExternalLink } from 'lucide-react'
4
+ import type { ColumnDef } from '@tanstack/react-table'
5
+ import { PersonLink } from '@fayz-ai/saas'
6
+ import { useFinancialConfig, useFinancialStore, useFinancialProvider, formatCurrency, type ResolvedFinancialConfig } from '../FinancialContext'
7
+ import { SubpageHeader, DataTable } from '@fayz-ai/ui'
8
+ import { PaymentModal } from '../components/PaymentModal'
9
+ import { useTranslation } from '@fayz-ai/core'
10
+ import { Button } from '@fayz-ai/ui'
11
+ import type { Invoice, InvoiceItem, FinancialMovement, TransactionDirection } from '../types'
12
+
13
+ const STATUS_COLORS: Record<string, { bg: string; icon: React.ElementType; labelKey: string }> = {
14
+ open: { bg: 'bg-info-soft text-info-soft-foreground', icon: CircleDashed, labelKey: 'financial.invoice.statusOpen' },
15
+ pending: { bg: 'bg-warning-soft text-warning-soft-foreground', icon: CircleDashed, labelKey: 'financial.invoice.statusPending' },
16
+ partial: { bg: 'bg-warning-soft text-warning-soft-foreground', icon: CircleEllipsis, labelKey: 'financial.invoice.statusPartial' },
17
+ paid: { bg: 'bg-success-soft text-success-soft-foreground', icon: CircleCheckBig, labelKey: 'financial.invoice.statusPaid' },
18
+ overdue: { bg: 'bg-destructive-soft text-destructive-soft-foreground', icon: CircleAlert, labelKey: 'financial.invoice.statusOverdue' },
19
+ draft: { bg: 'bg-muted text-muted-foreground', icon: CircleDashed, labelKey: 'financial.invoice.statusDraft' },
20
+ cancelled: { bg: 'bg-muted text-muted-foreground', icon: Ban, labelKey: 'financial.invoice.statusCancelled' },
21
+ }
22
+
23
+ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
24
+ invoiceId: string
25
+ direction: TransactionDirection
26
+ onBack: () => void
27
+ onEdit: () => void
28
+ }) {
29
+ const t = useTranslation()
30
+ const config = useFinancialConfig()
31
+ const { currency } = config
32
+ const provider = useFinancialProvider()
33
+ const paymentMethods = useFinancialStore((s) => s.paymentMethods)
34
+ const paymentMethodTypes = useFinancialStore((s) => s.paymentMethodTypes)
35
+ const bankAccounts = useFinancialStore((s) => s.bankAccounts)
36
+ const fetchPaymentMethods = useFinancialStore((s) => s.fetchPaymentMethods)
37
+ const fetchBankAccounts = useFinancialStore((s) => s.fetchBankAccounts)
38
+ const [detailsLoaded, setDetailsLoaded] = useState(false)
39
+ const [detailsLoading, setDetailsLoading] = useState(false)
40
+
41
+ const [invoice, setInvoice] = useState<Invoice | null>(null)
42
+ const [items, setItems] = useState<InvoiceItem[]>([])
43
+ const [movements, setMovements] = useState<FinancialMovement[]>([])
44
+ const [loading, setLoading] = useState(true)
45
+ const [payingMovement, setPayingMovement] = useState<FinancialMovement | null>(null)
46
+ const [expandedMovId, setExpandedMovId] = useState<string | null>(null)
47
+ const [menuOpen, setMenuOpen] = useState(false)
48
+ const [confirmCancel, setConfirmCancel] = useState(false)
49
+ const [cancelling, setCancelling] = useState(false)
50
+ const menuRef = useRef<HTMLDivElement>(null)
51
+ const cancelInvoice = useFinancialStore((s) => s.cancelInvoice)
52
+
53
+ async function loadData() {
54
+ setLoading(true)
55
+ const [inv, invItems, movResult] = await Promise.all([
56
+ provider.getInvoiceById(invoiceId),
57
+ provider.getInvoiceItems(invoiceId),
58
+ provider.getMovements({ direction }),
59
+ ])
60
+ setInvoice(inv)
61
+ setItems(invItems)
62
+ setMovements(
63
+ movResult.data
64
+ .filter((m) => m.invoiceId === invoiceId && m.movementKind === 'bill')
65
+ .sort((a, b) => (a.installmentNumber ?? 0) - (b.installmentNumber ?? 0))
66
+ )
67
+ setLoading(false)
68
+ }
69
+
70
+ useEffect(() => { loadData() }, [invoiceId])
71
+
72
+ // Close menu on click outside
73
+ useEffect(() => {
74
+ if (!menuOpen) return
75
+ function handleClick(e: MouseEvent) {
76
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
77
+ }
78
+ document.addEventListener('mousedown', handleClick)
79
+ return () => document.removeEventListener('mousedown', handleClick)
80
+ }, [menuOpen])
81
+
82
+ async function loadPaymentDetails() {
83
+ if (detailsLoaded || detailsLoading) return
84
+ setDetailsLoading(true)
85
+ await Promise.all([fetchPaymentMethods(), fetchBankAccounts()])
86
+ setDetailsLoaded(true)
87
+ setDetailsLoading(false)
88
+ }
89
+
90
+ function handleExpandMov(movId: string) {
91
+ const opening = expandedMovId !== movId
92
+ setExpandedMovId(opening ? movId : null)
93
+ if (opening) loadPaymentDetails()
94
+ }
95
+
96
+ async function handleCancel() {
97
+ setCancelling(true)
98
+ try {
99
+ await cancelInvoice(invoiceId)
100
+ await loadData()
101
+ setConfirmCancel(false)
102
+ setMenuOpen(false)
103
+ } catch {
104
+ // toast handled by store
105
+ } finally {
106
+ setCancelling(false)
107
+ }
108
+ }
109
+
110
+ if (loading) {
111
+ return (
112
+ <div className="space-y-5">
113
+ <SubpageHeader title="" onBack={onBack} parentLabel={direction === 'debit' ? t('financial.nav.payables') : t('financial.nav.receivables')} />
114
+
115
+ {/* Invoice card skeleton */}
116
+ <div className="rounded-xl border bg-card shadow-sm overflow-hidden">
117
+ <div className="h-1 bg-muted animate-pulse" />
118
+ <div className="p-5 pb-4">
119
+ <div className="flex items-start justify-between">
120
+ <div className="flex items-start gap-3">
121
+ <div className="h-10 w-10 rounded-lg bg-muted/40 animate-pulse" />
122
+ <div className="space-y-2">
123
+ <div className="h-3 w-20 rounded bg-muted/40 animate-pulse" />
124
+ <div className="h-6 w-32 rounded bg-muted/40 animate-pulse" />
125
+ </div>
126
+ </div>
127
+ <div className="h-6 w-20 rounded-full bg-muted/40 animate-pulse" />
128
+ </div>
129
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4 pt-4 border-t">
130
+ {[1, 2, 3, 4].map((i) => (
131
+ <div key={i} className="flex items-center gap-2">
132
+ <div className="h-3.5 w-3.5 rounded bg-muted/30 animate-pulse" />
133
+ <div className="space-y-1">
134
+ <div className="h-2 w-10 rounded bg-muted/30 animate-pulse" />
135
+ <div className="h-3 w-16 rounded bg-muted/40 animate-pulse" />
136
+ </div>
137
+ </div>
138
+ ))}
139
+ </div>
140
+ </div>
141
+
142
+ {/* Items table skeleton */}
143
+ <div className="px-5 pt-3 pb-1.5">
144
+ <div className="h-3 w-12 rounded bg-muted/30 animate-pulse" />
145
+ </div>
146
+ <div className="px-5">
147
+ <div className="border-b py-2 flex justify-between">
148
+ <div className="h-2 w-24 rounded bg-muted/20 animate-pulse" />
149
+ <div className="h-2 w-16 rounded bg-muted/20 animate-pulse" />
150
+ </div>
151
+ {[1, 2].map((i) => (
152
+ <div key={i} className="flex items-center justify-between py-2.5 border-b last:border-0">
153
+ <div className="flex items-center gap-2">
154
+ <div className="h-3 w-4 rounded bg-muted/30 animate-pulse" />
155
+ <div className="h-5 w-14 rounded bg-muted/30 animate-pulse" />
156
+ <div className="h-3 w-28 rounded bg-muted/40 animate-pulse" />
157
+ </div>
158
+ <div className="h-3 w-20 rounded bg-muted/40 animate-pulse" />
159
+ </div>
160
+ ))}
161
+ </div>
162
+ <div className="px-5 py-3 border-t bg-muted/10 flex justify-end">
163
+ <div className="h-4 w-32 rounded bg-muted/40 animate-pulse" />
164
+ </div>
165
+ </div>
166
+
167
+ {/* Installments skeleton */}
168
+ <div className="space-y-2">
169
+ <div className="h-4 w-28 rounded bg-muted/30 animate-pulse" />
170
+ <div className="rounded-lg border bg-card shadow-sm divide-y">
171
+ {[1].map((i) => (
172
+ <div key={i} className="flex items-center gap-3 px-4 py-3">
173
+ <div className="h-3 w-5 rounded bg-muted/30 animate-pulse" />
174
+ <div className="flex-1 space-y-1.5">
175
+ <div className="h-4 w-24 rounded bg-muted/40 animate-pulse" />
176
+ <div className="h-2 w-32 rounded bg-muted/30 animate-pulse" />
177
+ </div>
178
+ <div className="h-7 w-16 rounded-lg bg-muted/30 animate-pulse" />
179
+ </div>
180
+ ))}
181
+ </div>
182
+ </div>
183
+ </div>
184
+ )
185
+ }
186
+
187
+ if (!invoice) {
188
+ return (
189
+ <div className="space-y-6">
190
+ <SubpageHeader title={t('financial.invoice.notFound')} onBack={onBack} parentLabel={direction === 'debit' ? t('financial.nav.payables') : t('financial.nav.receivables')} />
191
+ <p className="text-sm text-muted-foreground">{t('financial.invoice.notFoundDesc')}</p>
192
+ </div>
193
+ )
194
+ }
195
+
196
+ const statusConfig = STATUS_COLORS[invoice.status] ?? STATUS_COLORS.pending
197
+ const StatusIcon = statusConfig.icon
198
+ const totalPaid = movements.reduce((sum, m) => sum + m.paidAmount, 0)
199
+ const totalRemaining = invoice.totalAmount - totalPaid
200
+ const subtotal = items.reduce((sum, it) => sum + it.quantity * it.unitPrice, 0)
201
+ const totalDiscount = items.reduce((sum, it) => sum + it.discount, 0)
202
+ const totalSurcharge = items.reduce((sum, it) => sum + it.surcharge, 0)
203
+
204
+ const itemColumns: ColumnDef<InvoiceItem, any>[] = [
205
+ {
206
+ id: 'index', header: t('financial.invoice.columnNumber'),
207
+ cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.index + 1}</span>,
208
+ },
209
+ {
210
+ accessorKey: 'description', header: t('financial.invoice.columnDescription'),
211
+ cell: ({ row }) => (
212
+ <div className="flex items-center gap-1.5">
213
+ <span className="inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-medium bg-muted text-muted-foreground capitalize">{row.original.itemKind}</span>
214
+ <span className="truncate">{row.original.description}</span>
215
+ </div>
216
+ ),
217
+ },
218
+ {
219
+ accessorKey: 'quantity', header: () => <span className="block text-right">{t('financial.invoice.columnQty')}</span>,
220
+ cell: ({ getValue }) => <span className="block text-right text-xs tabular-nums">{getValue() as number}</span>,
221
+ },
222
+ {
223
+ accessorKey: 'unitPrice', header: () => <span className="block text-right">{t('financial.invoice.columnUnitPrice')}</span>,
224
+ cell: ({ getValue }) => <span className="block text-right text-xs tabular-nums">{formatCurrency(getValue() as number, currency)}</span>,
225
+ },
226
+ {
227
+ accessorKey: 'totalAmount', header: () => <span className="block text-right">{t('financial.invoice.columnTotal')}</span>,
228
+ cell: ({ getValue }) => <span className="block text-right font-medium tabular-nums">{formatCurrency(getValue() as number, currency)}</span>,
229
+ },
230
+ ]
231
+
232
+ return (
233
+ <div className="space-y-5">
234
+ <SubpageHeader
235
+ title={invoice.fiscalNumber ? `#${invoice.fiscalNumber}` : invoice.contactName ?? invoice.invoiceDate}
236
+ onBack={onBack}
237
+ parentLabel={direction === 'debit' ? t('financial.nav.payables') : t('financial.nav.receivables')}
238
+ actions={
239
+ <div className="flex items-center gap-1.5">
240
+ {invoice.status !== 'cancelled' && invoice.status !== 'paid' && (
241
+ <Button variant="outline" size="sm" onClick={onEdit}>
242
+ <Pencil className="h-3.5 w-3.5" /> {t('financial.invoice.edit')}
243
+ </Button>
244
+ )}
245
+ {invoice.status !== 'cancelled' && invoice.status !== 'paid' && (
246
+ <div className="relative" ref={menuRef}>
247
+ <Button variant="outline" size="icon" onClick={() => setMenuOpen(!menuOpen)} aria-label="More actions">
248
+ <MoreVertical className="h-4 w-4" />
249
+ </Button>
250
+ {menuOpen && (
251
+ <div className="fayz-glass-surface absolute right-0 top-full mt-1 w-44 rounded-lg border bg-popover shadow-md z-20 py-1" style={{ animation: 'field-slide-in 150ms ease-out' }}>
252
+ <button
253
+ onClick={() => { setConfirmCancel(true); setMenuOpen(false) }}
254
+ className="flex w-full items-center gap-2 px-3 py-2 text-xs text-destructive hover:bg-destructive/10 transition-colors"
255
+ >
256
+ <Ban className="h-3.5 w-3.5" /> {t('financial.invoice.cancelInvoice')}
257
+ </button>
258
+ </div>
259
+ )}
260
+ </div>
261
+ )}
262
+ </div>
263
+ }
264
+ />
265
+
266
+ {/* Invoice document */}
267
+ <div className="rounded-xl border bg-card shadow-sm overflow-hidden">
268
+ {/* Top accent stripe */}
269
+ <div className="h-1 bg-primary" />
270
+
271
+ {/* Invoice header */}
272
+ <div className="p-5 pb-4">
273
+ <div className="flex items-start justify-between gap-4">
274
+ <div className="flex items-start gap-3">
275
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 shrink-0">
276
+ <FileText className="h-5 w-5 text-primary" />
277
+ </div>
278
+ <div>
279
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
280
+ {direction === 'debit' ? t('financial.invoice.accountsPayable') : t('financial.invoice.accountsReceivable')}
281
+ </p>
282
+ <p className={`text-xl font-bold mt-0.5 ${invoice.status === 'cancelled' ? 'line-through text-muted-foreground' : ''}`}>{formatCurrency(invoice.totalAmount, currency)}</p>
283
+ </div>
284
+ </div>
285
+ <span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-semibold capitalize ${statusConfig.bg}`}>
286
+ <StatusIcon className="h-3 w-3" /> {t(statusConfig.labelKey)}
287
+ </span>
288
+ </div>
289
+
290
+ {/* Meta grid */}
291
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4 pt-4 border-t">
292
+ <div className="flex items-center gap-2">
293
+ <User className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
294
+ <div className="min-w-0">
295
+ <p className="text-[10px] text-muted-foreground">{t('financial.invoice.contact')}</p>
296
+ {invoice.contactName ? (
297
+ <PersonLink personId={invoice.contactId} name={invoice.contactName} size="sm" />
298
+ ) : (
299
+ <p className="text-xs font-medium truncate">—</p>
300
+ )}
301
+ </div>
302
+ </div>
303
+ <div className="flex items-center gap-2">
304
+ <Calendar className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
305
+ <div>
306
+ <p className="text-[10px] text-muted-foreground">{t('financial.invoice.date')}</p>
307
+ <p className="text-xs font-medium">{invoice.invoiceDate}</p>
308
+ </div>
309
+ </div>
310
+ <div className="flex items-center gap-2">
311
+ <Hash className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
312
+ <div>
313
+ <p className="text-[10px] text-muted-foreground">{t('financial.invoice.installments')}</p>
314
+ <p className="text-xs font-medium">{invoice.totalInstallments}x</p>
315
+ </div>
316
+ </div>
317
+ <div className="flex items-center gap-2">
318
+ <DollarSign className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
319
+ <div>
320
+ <p className="text-[10px] text-muted-foreground">{t('financial.invoice.columnPaid')}</p>
321
+ <p className="text-xs font-medium">{formatCurrency(totalPaid, currency)}</p>
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ {/* Booking link — shows when this order has a scheduled time */}
327
+ {invoice.bookingStartsAt && (
328
+ <div className="flex items-center gap-2 mt-3 pt-3 border-t">
329
+ <CalendarDays className="h-3.5 w-3.5 text-magic shrink-0" />
330
+ <div className="flex items-center gap-2 min-w-0">
331
+ <p className="text-[10px] text-muted-foreground">{t('financial.invoice.booking')}</p>
332
+ <button
333
+ type="button"
334
+ onClick={() => config.onBookingClick?.(invoice.id)}
335
+ className="inline-flex items-center gap-1 text-xs font-medium text-magic hover:underline"
336
+ >
337
+ {new Date(invoice.bookingStartsAt).toLocaleDateString(undefined, { weekday: 'short', day: '2-digit', month: 'short' })}
338
+ {' '}
339
+ {new Date(invoice.bookingStartsAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
340
+ <ExternalLink className="h-2.5 w-2.5" />
341
+ </button>
342
+ </div>
343
+ </div>
344
+ )}
345
+ </div>
346
+
347
+ {/* Items table */}
348
+ {items.length > 0 && (
349
+ <>
350
+ <div className="px-5 pt-3 pb-1.5">
351
+ <h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{t('financial.invoice.items')}</h3>
352
+ </div>
353
+ <div className="px-5">
354
+ <DataTable columns={itemColumns} data={items} variant="flat" compact />
355
+ </div>
356
+
357
+ {/* Totals */}
358
+ <div className="px-5 py-3 mt-1 border-t bg-muted/10">
359
+ <div className="flex flex-col items-end gap-0.5">
360
+ {(totalDiscount > 0 || totalSurcharge > 0) && (
361
+ <div className="flex items-center gap-6 text-xs">
362
+ <span className="text-muted-foreground">{t('financial.invoice.subtotal')}</span>
363
+ <span className="tabular-nums w-24 text-right">{formatCurrency(subtotal, currency)}</span>
364
+ </div>
365
+ )}
366
+ {totalDiscount > 0 && (
367
+ <div className="flex items-center gap-6 text-xs text-success">
368
+ <span>{t('financial.invoice.discount')}</span>
369
+ <span className="tabular-nums w-24 text-right">-{formatCurrency(totalDiscount, currency)}</span>
370
+ </div>
371
+ )}
372
+ {totalSurcharge > 0 && (
373
+ <div className="flex items-center gap-6 text-xs text-warning">
374
+ <span>{t('financial.invoice.surcharge')}</span>
375
+ <span className="tabular-nums w-24 text-right">+{formatCurrency(totalSurcharge, currency)}</span>
376
+ </div>
377
+ )}
378
+ <div className="flex items-center gap-6 text-sm font-bold pt-1">
379
+ <span>{t('financial.invoice.total')}</span>
380
+ <span className={`tabular-nums w-24 text-right ${invoice.status === 'cancelled' ? 'line-through text-muted-foreground' : ''}`}>{formatCurrency(invoice.totalAmount, currency)}</span>
381
+ </div>
382
+ </div>
383
+ </div>
384
+ </>
385
+ )}
386
+
387
+ {/* Observations inside invoice */}
388
+ {invoice.observations && (
389
+ <div className="px-5 py-3 border-t">
390
+ <p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">{t('financial.invoice.notes')}</p>
391
+ <p className="text-xs text-muted-foreground">{invoice.observations}</p>
392
+ </div>
393
+ )}
394
+ </div>
395
+
396
+ {/* Installments — outside the invoice card */}
397
+ <div className="space-y-2">
398
+ <h3 className="text-sm font-semibold text-muted-foreground">{t('financial.invoice.paymentSchedule')}</h3>
399
+ <div className="rounded-lg border bg-card shadow-sm">
400
+ {movements.length === 0 ? (
401
+ <div className="px-4 py-8 text-center text-sm text-muted-foreground">{t('financial.invoice.noInstallments')}</div>
402
+ ) : (
403
+ <div className="divide-y">
404
+ {movements.map((mov) => {
405
+ const remaining = mov.amount - mov.paidAmount
406
+ const isPaid = mov.status === 'paid'
407
+ const isPartial = mov.status === 'partial'
408
+ const hasPaidDetails = isPaid || isPartial
409
+ const isExpanded = expandedMovId === mov.id
410
+ const movStatus = STATUS_COLORS[mov.status] ?? STATUS_COLORS.pending
411
+ const MovIcon = movStatus.icon
412
+
413
+ // Resolve names for payment details
414
+ const method = paymentMethods.find((m) => m.id === mov.paymentMethodId)
415
+ const methodType = paymentMethodTypes.find((t) => t.id === (method?.paymentMethodTypeId ?? mov.paymentMethodTypeId))
416
+ const account = bankAccounts.find((a) => a.id === mov.bankAccountId)
417
+
418
+ return (
419
+ <div key={mov.id}>
420
+ <div
421
+ className={`flex items-center gap-3 px-4 py-3 ${hasPaidDetails ? 'cursor-pointer hover:bg-muted/20 transition-colors' : ''}`}
422
+ onClick={hasPaidDetails ? () => handleExpandMov(mov.id) : undefined}
423
+ >
424
+ <span className="text-xs font-medium text-muted-foreground w-6 shrink-0">#{mov.installmentNumber}</span>
425
+ <div className="flex-1 min-w-0">
426
+ <div className="flex items-center gap-2">
427
+ <span className="text-sm font-medium tabular-nums">{formatCurrency(mov.amount, currency)}</span>
428
+ <span className={`inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-medium capitalize ${movStatus.bg}`}>
429
+ <MovIcon className="h-2 w-2" /> {t(movStatus.labelKey)}
430
+ </span>
431
+ </div>
432
+ <p className="text-[10px] text-muted-foreground">
433
+ {t('financial.invoice.due')} {mov.dueDate}
434
+ {mov.paidAmount > 0 && !isPaid && ` · ${t('financial.invoice.paidLabel')} ${formatCurrency(mov.paidAmount, currency)}`}
435
+ {mov.paymentDate && ` · ${t('financial.invoice.paidOn')} ${mov.paymentDate}`}
436
+ </p>
437
+ </div>
438
+ {!isPaid && !isPartial && invoice.status !== 'cancelled' && (
439
+ <button
440
+ onClick={(e) => { e.stopPropagation(); setPayingMovement(mov) }}
441
+ className="inline-flex items-center gap-1 rounded-lg bg-primary border border-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors shrink-0"
442
+ >
443
+ <DollarSign className="h-3 w-3" />
444
+ {t('financial.invoice.pay')} {remaining < mov.amount ? formatCurrency(remaining, currency) : ''}
445
+ </button>
446
+ )}
447
+ {isPartial && invoice.status !== 'cancelled' && (
448
+ <button
449
+ onClick={(e) => { e.stopPropagation(); setPayingMovement(mov) }}
450
+ className="inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors shrink-0"
451
+ >
452
+ <DollarSign className="h-3 w-3" />
453
+ {t('financial.invoice.pay')} {formatCurrency(remaining, currency)}
454
+ </button>
455
+ )}
456
+ {hasPaidDetails && (
457
+ <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground shrink-0 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`} />
458
+ )}
459
+ </div>
460
+
461
+ {/* Expanded payment details */}
462
+ <div
463
+ className="grid transition-all duration-200 ease-out"
464
+ style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
465
+ >
466
+ <div className="overflow-hidden">
467
+ <div className="px-4 pb-2.5 pl-14">
468
+ {detailsLoading ? (
469
+ <div className="flex items-center gap-3">
470
+ <div className="h-3 w-20 rounded bg-muted/40 animate-pulse" />
471
+ <div className="h-3 w-16 rounded bg-muted/30 animate-pulse" />
472
+ <div className="h-3 w-24 rounded bg-muted/40 animate-pulse" />
473
+ </div>
474
+ ) : (
475
+ <div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-muted-foreground">
476
+ {methodType && (
477
+ <>
478
+ <span className="font-medium text-foreground">{methodType.name}</span>
479
+ {method && <span>({method.name})</span>}
480
+ </>
481
+ )}
482
+ {mov.cardBrand && (
483
+ <>
484
+ {methodType && <span>&middot;</span>}
485
+ <CreditCard className="h-3 w-3 shrink-0" />
486
+ <span className="font-medium text-foreground">{mov.cardBrand}</span>
487
+ {mov.cardInstallments && <span>{mov.cardInstallments}x</span>}
488
+ </>
489
+ )}
490
+ {account && (
491
+ <>
492
+ {(methodType || mov.cardBrand) && <span>&middot;</span>}
493
+ <Building2 className="h-3 w-3 shrink-0" />
494
+ <span>{account.name}</span>
495
+ </>
496
+ )}
497
+ {mov.paidAmount > 0 && (
498
+ <>
499
+ <span>&middot;</span>
500
+ <span className="font-medium text-success">{formatCurrency(mov.paidAmount, currency)}</span>
501
+ </>
502
+ )}
503
+ {mov.paymentDate && (
504
+ <>
505
+ <span>&middot;</span>
506
+ <span>{mov.paymentDate}</span>
507
+ </>
508
+ )}
509
+ {mov.notes && (
510
+ <>
511
+ <span>&middot;</span>
512
+ <span className="italic truncate max-w-[200px]">{mov.notes}</span>
513
+ </>
514
+ )}
515
+ {!methodType && !mov.cardBrand && !account && !mov.notes && (
516
+ <span>{t('financial.invoice.noDetails')}</span>
517
+ )}
518
+ </div>
519
+ )}
520
+ </div>
521
+ </div>
522
+ </div>
523
+ </div>
524
+ )
525
+ })}
526
+ </div>
527
+ )}
528
+ </div>
529
+
530
+ {/* Payment summary bar */}
531
+ {movements.length > 0 && (
532
+ <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-2.5">
533
+ <div className="flex items-center gap-4 text-xs">
534
+ <span className="text-muted-foreground">{t('financial.invoice.paidLabel')} <span className="font-semibold text-success">{formatCurrency(totalPaid, currency)}</span></span>
535
+ {totalRemaining > 0 && (
536
+ <span className="text-muted-foreground">{t('financial.invoice.remaining')} <span className="font-semibold text-foreground">{formatCurrency(totalRemaining, currency)}</span></span>
537
+ )}
538
+ </div>
539
+ {totalRemaining <= 0 && (
540
+ <span className="inline-flex items-center gap-1 text-xs font-medium text-success">
541
+ <CircleCheckBig className="h-3 w-3" /> {t('financial.invoice.fullyPaid')}
542
+ </span>
543
+ )}
544
+ </div>
545
+ )}
546
+ </div>
547
+
548
+ {/* Payment Modal */}
549
+ {payingMovement && (
550
+ <PaymentModal
551
+ movement={payingMovement}
552
+ onClose={() => setPayingMovement(null)}
553
+ onPaid={() => { setPayingMovement(null); loadData() }}
554
+ />
555
+ )}
556
+
557
+ {/* Cancel confirmation */}
558
+ {confirmCancel && createPortal(
559
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setConfirmCancel(false)}>
560
+ <div className="w-full max-w-sm rounded-modal border bg-card shadow-lg mx-4 p-5" onClick={(e) => e.stopPropagation()} style={{ animation: 'field-slide-in 200ms ease-out' }}>
561
+ <div className="flex items-center gap-3 mb-3">
562
+ <div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 shrink-0">
563
+ <Ban className="h-5 w-5 text-destructive" />
564
+ </div>
565
+ <div>
566
+ <h3 className="text-sm font-semibold">{t('financial.invoice.cancelInvoice')}</h3>
567
+ <p className="text-xs text-muted-foreground">{t('financial.invoice.cancelConfirm')}</p>
568
+ </div>
569
+ </div>
570
+ <p className="text-xs text-muted-foreground mb-4">
571
+ {t('financial.invoice.cancelDesc')}
572
+ </p>
573
+ <div className="flex justify-end gap-2">
574
+ <Button variant="outline" size="sm" onClick={() => setConfirmCancel(false)}>
575
+ {t('financial.invoice.keepInvoice')}
576
+ </Button>
577
+ <Button variant="destructive" size="sm" onClick={handleCancel} disabled={cancelling}>
578
+ <Ban className="h-3.5 w-3.5" />
579
+ {cancelling ? t('financial.invoice.cancelling') : t('financial.invoice.yesCancel')}
580
+ </Button>
581
+ </div>
582
+ </div>
583
+ </div>,
584
+ document.body
585
+ )}
586
+
587
+ <style>{`
588
+ @keyframes field-slide-in {
589
+ from { opacity: 0; transform: translateY(8px); }
590
+ to { opacity: 1; transform: translateY(0); }
591
+ }
592
+ `}</style>
593
+ </div>
594
+ )
595
+ }