@fayz-ai/plugin-financial 0.9.0 → 0.9.2

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