@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.
- package/dist/FinancialContext.d.ts +44 -0
- package/dist/FinancialContext.d.ts.map +1 -0
- package/dist/FinancialPage.d.ts +18 -0
- package/dist/FinancialPage.d.ts.map +1 -0
- package/dist/components/FinancialGeneralSettings.d.ts +3 -0
- package/dist/components/FinancialGeneralSettings.d.ts.map +1 -0
- package/dist/components/FinancialOnboarding.d.ts +5 -0
- package/dist/components/FinancialOnboarding.d.ts.map +1 -0
- package/dist/components/FinancialSettings.d.ts +8 -0
- package/dist/components/FinancialSettings.d.ts.map +1 -0
- package/dist/components/PaymentModal.d.ts +8 -0
- package/dist/components/PaymentModal.d.ts.map +1 -0
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +9 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +27 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/index.cjs +4937 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4930 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/tenant.d.ts +3 -0
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/registries.d.ts +3 -0
- package/dist/registries.d.ts.map +1 -0
- package/dist/store.d.ts +38 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +302 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/CardsView.d.ts +3 -0
- package/dist/views/CardsView.d.ts.map +1 -0
- package/dist/views/CashRegistersView.d.ts +3 -0
- package/dist/views/CashRegistersView.d.ts.map +1 -0
- package/dist/views/CommissionsView.d.ts +3 -0
- package/dist/views/CommissionsView.d.ts.map +1 -0
- package/dist/views/InvoiceDetailView.d.ts +9 -0
- package/dist/views/InvoiceDetailView.d.ts.map +1 -0
- package/dist/views/InvoiceFormView.d.ts +13 -0
- package/dist/views/InvoiceFormView.d.ts.map +1 -0
- package/dist/views/InvoiceListView.d.ts +8 -0
- package/dist/views/InvoiceListView.d.ts.map +1 -0
- package/dist/views/PayablesView.d.ts +7 -0
- package/dist/views/PayablesView.d.ts.map +1 -0
- package/dist/views/ReceivablesView.d.ts +7 -0
- package/dist/views/ReceivablesView.d.ts.map +1 -0
- package/dist/views/StatementsView.d.ts +3 -0
- package/dist/views/StatementsView.d.ts.map +1 -0
- package/dist/views/SummaryView.d.ts +9 -0
- package/dist/views/SummaryView.d.ts.map +1 -0
- package/dist/views/dashboardWidgets.d.ts +11 -0
- package/dist/views/dashboardWidgets.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/FinancialContext.tsx +47 -0
- package/src/FinancialPage.tsx +261 -0
- package/src/README.md +155 -0
- package/src/components/FinancialGeneralSettings.tsx +26 -0
- package/src/components/FinancialOnboarding.tsx +87 -0
- package/src/components/FinancialSettings.tsx +31 -0
- package/src/components/PaymentModal.tsx +322 -0
- package/src/data/index.ts +2 -0
- package/src/data/mock.ts +520 -0
- package/src/data/supabase.ts +509 -0
- package/src/data/types.ts +53 -0
- package/src/index.ts +307 -0
- package/src/lib/tenant.ts +3 -0
- package/src/locales/en.ts +331 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +331 -0
- package/src/migrations/001_financial_base.sql +109 -0
- package/src/migrations/002_chart_of_accounts.sql +28 -0
- package/src/migrations/003_card_brands.sql +12 -0
- package/src/migrations/004_order_to_cash.sql +164 -0
- package/src/migrations/005_seed_defaults.sql +38 -0
- package/src/registries.ts +305 -0
- package/src/store.ts +221 -0
- package/src/types.ts +359 -0
- package/src/views/CardsView.tsx +74 -0
- package/src/views/CashRegistersView.tsx +597 -0
- package/src/views/CommissionsView.tsx +18 -0
- package/src/views/InvoiceDetailView.tsx +595 -0
- package/src/views/InvoiceFormView.tsx +900 -0
- package/src/views/InvoiceListView.tsx +177 -0
- package/src/views/PayablesView.tsx +37 -0
- package/src/views/ReceivablesView.tsx +37 -0
- package/src/views/StatementsView.tsx +123 -0
- package/src/views/SummaryView.tsx +12 -0
- 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>·</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>·</span>}
|
|
493
|
+
<Building2 className="h-3 w-3 shrink-0" />
|
|
494
|
+
<span>{account.name}</span>
|
|
495
|
+
</>
|
|
496
|
+
)}
|
|
497
|
+
{mov.paidAmount > 0 && (
|
|
498
|
+
<>
|
|
499
|
+
<span>·</span>
|
|
500
|
+
<span className="font-medium text-success">{formatCurrency(mov.paidAmount, currency)}</span>
|
|
501
|
+
</>
|
|
502
|
+
)}
|
|
503
|
+
{mov.paymentDate && (
|
|
504
|
+
<>
|
|
505
|
+
<span>·</span>
|
|
506
|
+
<span>{mov.paymentDate}</span>
|
|
507
|
+
</>
|
|
508
|
+
)}
|
|
509
|
+
{mov.notes && (
|
|
510
|
+
<>
|
|
511
|
+
<span>·</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
|
+
}
|