@fayz-ai/plugin-financial 0.1.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/FinancialContext.d.ts +2 -0
- package/dist/FinancialContext.d.ts.map +1 -1
- package/dist/FinancialPage.d.ts.map +1 -1
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/mock.d.ts +15 -0
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +11 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/types.d.ts +2 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +1594 -252
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1456 -131
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +16 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/quick-add.d.ts +13 -0
- package/dist/quick-add.d.ts.map +1 -0
- package/dist/registries.d.ts.map +1 -1
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +42 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/CardsView.d.ts.map +1 -1
- package/dist/views/InvoiceListView.d.ts.map +1 -1
- package/dist/views/QuickTransactionForm.d.ts +16 -0
- package/dist/views/QuickTransactionForm.d.ts.map +1 -0
- package/dist/views/SummaryView.d.ts +5 -4
- package/dist/views/SummaryView.d.ts.map +1 -1
- package/dist/views/dashboardWidgets.d.ts.map +1 -1
- package/package.json +7 -4
- package/src/FinancialContext.tsx +2 -0
- package/src/FinancialPage.tsx +86 -7
- package/src/README.md +5 -5
- package/src/contract.test.ts +1 -1
- package/src/data/capability.test.ts +45 -0
- package/src/data/index.ts +1 -0
- package/src/data/mock.ts +50 -10
- package/src/data/supabase.ts +95 -49
- package/src/data/tables.ts +12 -0
- package/src/data/types.ts +2 -1
- package/src/index.ts +24 -4
- package/src/locales/en.ts +75 -0
- package/src/locales/pt-BR.ts +75 -0
- package/src/migrations/000_plg_rename.sql +30 -0
- package/src/migrations/001_financial_base.sql +30 -30
- package/src/migrations/002_chart_of_accounts.sql +10 -10
- package/src/migrations/003_card_brands.sql +4 -4
- package/src/migrations/004_order_to_cash.sql +28 -28
- package/src/migrations/005_seed_defaults.sql +6 -6
- package/src/migrations/006_rls_policies.sql +5 -5
- package/src/migrations/{006_extract_fee_amount.sql → 006b_extract_fee_amount.sql} +4 -4
- package/src/migrations/007_reconciliation.sql +9 -9
- package/src/migrations/007b_movement_payment_method_type.sql +6 -0
- package/src/migrations/008_split_payment_movements.sql +5 -5
- package/src/migrations/index.ts +544 -0
- package/src/quick-add.ts +58 -0
- package/src/registries.ts +8 -7
- package/src/store.ts +88 -0
- package/src/types.ts +45 -0
- package/src/views/CardsView.tsx +186 -50
- package/src/views/InvoiceListView.tsx +60 -3
- package/src/views/QuickTransactionForm.tsx +537 -0
- package/src/views/SummaryView.tsx +197 -6
- package/src/views/dashboardWidgets.tsx +251 -5
package/src/store.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
InvoiceQuery, MovementQuery, StatementQuery,
|
|
10
10
|
CreateInvoiceInput, PayMovementInput, CreateTransferInput,
|
|
11
11
|
OpenCashSessionInput, CloseCashSessionInput,
|
|
12
|
+
QuickTransactionInput,
|
|
12
13
|
DateRange,
|
|
13
14
|
} from './types'
|
|
14
15
|
|
|
@@ -55,6 +56,8 @@ export interface FinancialUIState {
|
|
|
55
56
|
fetchStatement(query: StatementQuery): Promise<void>
|
|
56
57
|
fetchCardTransactions(dateRange?: DateRange): Promise<void>
|
|
57
58
|
createInvoice(input: CreateInvoiceInput): Promise<Invoice>
|
|
59
|
+
/** FAY-1225 "log money in a few taps": one call → the right provider plumbing + full surface refresh. */
|
|
60
|
+
createQuickTransaction(input: QuickTransactionInput): Promise<void>
|
|
58
61
|
payMovement(input: PayMovementInput): Promise<void>
|
|
59
62
|
createTransfer(input: CreateTransferInput): Promise<void>
|
|
60
63
|
cancelInvoice(invoiceId: string): Promise<void>
|
|
@@ -169,6 +172,91 @@ export function createFinancialStore(provider: FinancialDataProvider): StoreApi<
|
|
|
169
172
|
}
|
|
170
173
|
},
|
|
171
174
|
|
|
175
|
+
async createQuickTransaction(input) {
|
|
176
|
+
try {
|
|
177
|
+
if (input.type === 'transfer') {
|
|
178
|
+
if (!input.bankAccountId || !input.toAccountId) {
|
|
179
|
+
throw new Error('Select source and destination accounts')
|
|
180
|
+
}
|
|
181
|
+
await provider.createTransfer({
|
|
182
|
+
fromAccountId: input.bankAccountId,
|
|
183
|
+
toAccountId: input.toAccountId,
|
|
184
|
+
amount: input.amount,
|
|
185
|
+
date: input.date,
|
|
186
|
+
notes: input.description?.trim() || undefined,
|
|
187
|
+
})
|
|
188
|
+
} else {
|
|
189
|
+
const direction: 'debit' | 'credit' = input.type === 'income' ? 'credit' : 'debit'
|
|
190
|
+
const desc = input.description?.trim() || (input.type === 'income' ? 'Receita' : 'Despesa')
|
|
191
|
+
// 1. The obligation (invoice + bill movement).
|
|
192
|
+
// Persist the recurring flag + the snapped-receipt data URL (FAY-1226)
|
|
193
|
+
// on the invoice metadata so the transaction feed can indicate/preview it.
|
|
194
|
+
const invoiceMeta: Record<string, unknown> = {}
|
|
195
|
+
if (input.recurring) invoiceMeta.recurring = true
|
|
196
|
+
if (input.receiptUrl) invoiceMeta.receiptUrl = input.receiptUrl
|
|
197
|
+
const invoice = await provider.createInvoice({
|
|
198
|
+
direction,
|
|
199
|
+
invoiceDate: input.date,
|
|
200
|
+
contactName: desc,
|
|
201
|
+
metadata: Object.keys(invoiceMeta).length > 0 ? invoiceMeta : undefined,
|
|
202
|
+
items: [{
|
|
203
|
+
itemKind: 'other',
|
|
204
|
+
description: desc,
|
|
205
|
+
quantity: 1,
|
|
206
|
+
unitPrice: input.amount,
|
|
207
|
+
accountId: input.categoryId,
|
|
208
|
+
}],
|
|
209
|
+
installments: [{
|
|
210
|
+
direction,
|
|
211
|
+
movementKind: 'bill',
|
|
212
|
+
amount: input.amount,
|
|
213
|
+
dueDate: input.date,
|
|
214
|
+
installmentNumber: 1,
|
|
215
|
+
bankAccountId: input.bankAccountId,
|
|
216
|
+
notes: desc,
|
|
217
|
+
}],
|
|
218
|
+
})
|
|
219
|
+
// 2. Settle it now when marked paid → a realized cash event that shows in
|
|
220
|
+
// the statement/extract and feeds the monthly cash-flow KPIs.
|
|
221
|
+
if (input.paid) {
|
|
222
|
+
const movements = await provider.getMovements({ direction, status: 'pending' })
|
|
223
|
+
const bill = movements.data.find((m) => m.invoiceId === invoice.id && m.movementKind === 'bill')
|
|
224
|
+
if (bill) {
|
|
225
|
+
await provider.payMovement({
|
|
226
|
+
movementId: bill.id,
|
|
227
|
+
amount: input.amount,
|
|
228
|
+
paymentDate: input.date,
|
|
229
|
+
bankAccountId: input.bankAccountId,
|
|
230
|
+
notes: desc,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Refresh every surface this store feeds: Home/Resumo KPIs (summary),
|
|
236
|
+
// the mobile transaction list (statement), balances, and the payables/
|
|
237
|
+
// receivables tables (invoices).
|
|
238
|
+
const invoiceQuery = get().invoiceQuery
|
|
239
|
+
const statementQuery = get().statementQuery
|
|
240
|
+
const [summary, bankAccounts, invoicesRes, statement] = await Promise.all([
|
|
241
|
+
provider.getSummary(),
|
|
242
|
+
provider.getBankAccounts(),
|
|
243
|
+
provider.getInvoices(invoiceQuery),
|
|
244
|
+
statementQuery ? provider.getStatement(statementQuery) : Promise.resolve(get().statement),
|
|
245
|
+
])
|
|
246
|
+
set({
|
|
247
|
+
summary,
|
|
248
|
+
bankAccounts,
|
|
249
|
+
invoices: invoicesRes.data,
|
|
250
|
+
invoicesTotal: invoicesRes.total,
|
|
251
|
+
statement,
|
|
252
|
+
})
|
|
253
|
+
toast.success('Transaction saved')
|
|
254
|
+
} catch (err: any) {
|
|
255
|
+
toast.error('Failed to save transaction', { description: err?.message })
|
|
256
|
+
throw err
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
|
|
172
260
|
async payMovement(input) {
|
|
173
261
|
try {
|
|
174
262
|
await provider.payMovement(input)
|
package/src/types.ts
CHANGED
|
@@ -199,6 +199,16 @@ export interface ChartOfAccountsNode {
|
|
|
199
199
|
createdAt: string
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
export interface CostCenter {
|
|
203
|
+
id: string
|
|
204
|
+
code: string
|
|
205
|
+
name: string
|
|
206
|
+
isActive: boolean
|
|
207
|
+
tenantId: string
|
|
208
|
+
createdAt: string
|
|
209
|
+
updatedAt: string
|
|
210
|
+
}
|
|
211
|
+
|
|
202
212
|
export interface CardTransaction {
|
|
203
213
|
id: string
|
|
204
214
|
movementId: string
|
|
@@ -316,6 +326,8 @@ export interface InvoiceQuery {
|
|
|
316
326
|
direction?: TransactionDirection
|
|
317
327
|
status?: InvoiceStatus | InvoiceStatus[]
|
|
318
328
|
contactId?: string
|
|
329
|
+
accountId?: string
|
|
330
|
+
costCenterId?: string
|
|
319
331
|
dateRange?: DateRange
|
|
320
332
|
search?: string
|
|
321
333
|
page?: number
|
|
@@ -347,6 +359,39 @@ export interface CreateTransferInput {
|
|
|
347
359
|
notes?: string
|
|
348
360
|
}
|
|
349
361
|
|
|
362
|
+
/**
|
|
363
|
+
* "Log money in a few taps" (FAY-1225). A single-transaction quick-add that the
|
|
364
|
+
* store expands into the right provider calls: expense/income → createInvoice
|
|
365
|
+
* (+ payMovement when marked paid); transfer → createTransfer. Keeps the
|
|
366
|
+
* Mobills-style form dumb — it only collects fields, the store does the plumbing.
|
|
367
|
+
*/
|
|
368
|
+
export type QuickTransactionType = 'expense' | 'income' | 'transfer'
|
|
369
|
+
|
|
370
|
+
export interface QuickTransactionInput {
|
|
371
|
+
type: QuickTransactionType
|
|
372
|
+
/** Positive amount in the account currency */
|
|
373
|
+
amount: number
|
|
374
|
+
/** YYYY-MM-DD */
|
|
375
|
+
date: string
|
|
376
|
+
/** When true, the movement is settled immediately (creates a paid cash event). */
|
|
377
|
+
paid: boolean
|
|
378
|
+
description?: string
|
|
379
|
+
/** Chart-of-accounts / cost-center id (category chip) */
|
|
380
|
+
categoryId?: string
|
|
381
|
+
/** expense/income: bank account or card. transfer: source account. */
|
|
382
|
+
bankAccountId?: string
|
|
383
|
+
/** transfer: destination account */
|
|
384
|
+
toAccountId?: string
|
|
385
|
+
/** Marks the transaction as recurring (stored in metadata; UI affordance). */
|
|
386
|
+
recurring?: boolean
|
|
387
|
+
/**
|
|
388
|
+
* FAY-1226 "snap a receipt": a captured/attached receipt image as a data URL
|
|
389
|
+
* (mock mode has no Supabase bucket). Persisted on the created invoice's
|
|
390
|
+
* metadata so the transaction feed can show a 📎 indicator + preview.
|
|
391
|
+
*/
|
|
392
|
+
receiptUrl?: string
|
|
393
|
+
}
|
|
394
|
+
|
|
350
395
|
export interface TransferResult {
|
|
351
396
|
/** Shared correlation id stored in both legs' metadata */
|
|
352
397
|
transferId: string
|
package/src/views/CardsView.tsx
CHANGED
|
@@ -1,74 +1,210 @@
|
|
|
1
|
-
import React, { useEffect } from 'react'
|
|
2
|
-
import { CreditCard,
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { CreditCard, Plus, CalendarClock } from 'lucide-react'
|
|
3
3
|
import { useFinancialConfig, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
4
4
|
import { SubpageHeader } from '@fayz-ai/ui'
|
|
5
5
|
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import type { BankAccount } from '../types'
|
|
7
|
+
import { QuickTransactionForm } from './QuickTransactionForm'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Per-card money math. Credit-card balances are stored as debt (negative), so
|
|
11
|
+
// the current bill (fatura) is the magnitude owed; the available limit is what
|
|
12
|
+
// remains of the credit line.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function cardMath(acc: BankAccount) {
|
|
16
|
+
const limit = acc.creditLimit ?? 0
|
|
17
|
+
const used = Math.max(0, -acc.currentBalance)
|
|
18
|
+
const available = Math.max(0, limit - used)
|
|
19
|
+
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0
|
|
20
|
+
return { limit, used, available, pct }
|
|
21
|
+
}
|
|
6
22
|
|
|
7
23
|
export function CardsView() {
|
|
8
24
|
const t = useTranslation()
|
|
9
|
-
const { currency
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
const { currency } = useFinancialConfig()
|
|
26
|
+
const bankAccounts = useFinancialStore((s) => s.bankAccounts)
|
|
27
|
+
const bankAccountsLoading = useFinancialStore((s) => s.bankAccountsLoading)
|
|
28
|
+
const fetchBankAccounts = useFinancialStore((s) => s.fetchBankAccounts)
|
|
29
|
+
|
|
30
|
+
const [tab, setTab] = useState<'open' | 'closed'>('open')
|
|
31
|
+
const [addOpen, setAddOpen] = useState(false)
|
|
32
|
+
const [addCardId, setAddCardId] = useState<string | undefined>()
|
|
33
|
+
|
|
34
|
+
useEffect(() => { fetchBankAccounts() }, [])
|
|
35
|
+
|
|
36
|
+
const cards = useMemo(
|
|
37
|
+
() => bankAccounts.filter((a) => a.accountType === 'credit_card'),
|
|
38
|
+
[bankAccounts],
|
|
39
|
+
)
|
|
40
|
+
const openCards = useMemo(() => cards.filter((c) => c.isActive), [cards])
|
|
41
|
+
const closedCards = useMemo(() => cards.filter((c) => !c.isActive), [cards])
|
|
42
|
+
const visibleCards = tab === 'open' ? openCards : closedCards
|
|
43
|
+
|
|
44
|
+
// Summary strip is computed over the active (open) cards — the money that is
|
|
45
|
+
// actually in play this cycle.
|
|
46
|
+
const summary = useMemo(() => {
|
|
47
|
+
let totalAvailable = 0
|
|
48
|
+
let totalBills = 0
|
|
49
|
+
for (const c of openCards) {
|
|
50
|
+
const { available, used } = cardMath(c)
|
|
51
|
+
totalAvailable += available
|
|
52
|
+
totalBills += used
|
|
53
|
+
}
|
|
54
|
+
return { totalAvailable, totalBills }
|
|
55
|
+
}, [openCards])
|
|
56
|
+
|
|
57
|
+
function openAddExpense(cardId: string) {
|
|
58
|
+
setAddCardId(cardId)
|
|
59
|
+
setAddOpen(true)
|
|
22
60
|
}
|
|
23
61
|
|
|
24
62
|
return (
|
|
25
63
|
<div className="space-y-4">
|
|
26
64
|
<SubpageHeader title={t('financial.cards.title')} subtitle={t('financial.cards.subtitle')} />
|
|
27
|
-
|
|
65
|
+
|
|
66
|
+
{bankAccountsLoading && cards.length === 0 ? (
|
|
28
67
|
<div className="text-center py-12 text-sm text-muted-foreground">Loading...</div>
|
|
29
|
-
) :
|
|
30
|
-
<div className="flex flex-col items-center justify-center py-12 text-center rounded-
|
|
31
|
-
<div className="flex h-
|
|
32
|
-
<CreditCard className="h-
|
|
68
|
+
) : cards.length === 0 ? (
|
|
69
|
+
<div className="flex flex-col items-center justify-center py-12 text-center rounded-2xl border-2 border-dashed border-muted">
|
|
70
|
+
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-amber-300 to-yellow-600 mb-3 text-white shadow-sm">
|
|
71
|
+
<CreditCard className="h-6 w-6" />
|
|
33
72
|
</div>
|
|
34
|
-
<p className="text-sm
|
|
35
|
-
<p className="text-xs text-muted-foreground mt-0.5">{t('financial.cards.
|
|
73
|
+
<p className="text-sm font-medium">{t('financial.cards.noCards')}</p>
|
|
74
|
+
<p className="text-xs text-muted-foreground mt-0.5 max-w-xs">{t('financial.cards.noCardsHint')}</p>
|
|
36
75
|
</div>
|
|
37
76
|
) : (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<
|
|
43
|
-
<
|
|
77
|
+
<>
|
|
78
|
+
{/* Summary strip — available limit + total bills across open cards */}
|
|
79
|
+
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border bg-border shadow-sm">
|
|
80
|
+
<div className="bg-card px-4 py-3">
|
|
81
|
+
<p className="text-xs text-muted-foreground">{t('financial.cards.availableLimit')}</p>
|
|
82
|
+
<p className="mt-0.5 text-lg font-bold tabular-nums text-success">
|
|
83
|
+
{formatCurrency(summary.totalAvailable, currency)}
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="bg-card px-4 py-3">
|
|
87
|
+
<p className="text-xs text-muted-foreground">{t('financial.cards.allBillsValue')}</p>
|
|
88
|
+
<p className="mt-0.5 text-lg font-bold tabular-nums">
|
|
89
|
+
{formatCurrency(summary.totalBills, currency)}
|
|
90
|
+
</p>
|
|
44
91
|
</div>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Open / Closed tabs (only when there are closed cards to switch to) */}
|
|
95
|
+
{closedCards.length > 0 && (
|
|
96
|
+
<div className="flex gap-1 rounded-full bg-muted p-1 w-full max-w-xs">
|
|
97
|
+
{(['open', 'closed'] as const).map((k) => (
|
|
98
|
+
<button
|
|
99
|
+
key={k}
|
|
100
|
+
onClick={() => setTab(k)}
|
|
101
|
+
className={`flex-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
102
|
+
tab === k ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
|
103
|
+
}`}
|
|
104
|
+
>
|
|
105
|
+
{k === 'open' ? t('financial.cards.tabOpen') : t('financial.cards.tabClosed')}
|
|
106
|
+
</button>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{/* Card tiles — stacked on mobile, grid on desktop */}
|
|
112
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
113
|
+
{visibleCards.map((card) => {
|
|
114
|
+
const { limit, used, available, pct } = cardMath(card)
|
|
115
|
+
const over = pct >= 90
|
|
116
|
+
// Per-brand color so cards match the bank (and the Home card), not a uniform gold.
|
|
117
|
+
const brand = `${card.bankName ?? ''} ${card.name ?? ''}`.toLowerCase()
|
|
118
|
+
const headerGrad =
|
|
119
|
+
brand.includes('nubank') || brand.includes('nu ') ? 'from-purple-500 via-purple-600 to-indigo-700'
|
|
120
|
+
: brand.includes('ita') ? 'from-orange-400 via-orange-500 to-amber-600'
|
|
121
|
+
: brand.includes('inter') ? 'from-orange-500 via-orange-600 to-orange-700'
|
|
122
|
+
: 'from-indigo-500 via-indigo-600 to-violet-700'
|
|
123
|
+
const barGrad =
|
|
124
|
+
brand.includes('nubank') || brand.includes('nu ') ? 'from-purple-500 to-indigo-600'
|
|
125
|
+
: brand.includes('ita') ? 'from-orange-400 to-amber-500'
|
|
126
|
+
: brand.includes('inter') ? 'from-orange-500 to-orange-600'
|
|
127
|
+
: 'from-indigo-500 to-violet-600'
|
|
128
|
+
return (
|
|
129
|
+
<div key={card.id} className="overflow-hidden rounded-2xl border bg-card shadow-sm">
|
|
130
|
+
{/* Per-brand header */}
|
|
131
|
+
<div className={`bg-gradient-to-br ${headerGrad} px-4 py-4 text-white`}>
|
|
132
|
+
<div className="flex items-start justify-between">
|
|
133
|
+
<div className="flex items-center gap-2">
|
|
134
|
+
<CreditCard className="h-5 w-5" />
|
|
135
|
+
<span className="text-sm font-semibold">{card.name}</span>
|
|
136
|
+
</div>
|
|
137
|
+
{card.bankName && (
|
|
138
|
+
<span className="text-[11px] font-medium uppercase tracking-wide opacity-80">
|
|
139
|
+
{card.bankName}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
<div className="mt-4">
|
|
144
|
+
<p className="text-[11px] uppercase tracking-wide opacity-80">
|
|
145
|
+
{t('financial.cards.currentBill')}
|
|
146
|
+
</p>
|
|
147
|
+
<p className="text-2xl font-bold tabular-nums leading-tight">
|
|
148
|
+
{formatCurrency(used, currency)}
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
53
151
|
</div>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
152
|
+
|
|
153
|
+
{/* Body */}
|
|
154
|
+
<div className="space-y-3 px-4 py-3">
|
|
155
|
+
{/* Usage progress bar */}
|
|
156
|
+
<div>
|
|
157
|
+
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
|
158
|
+
<span>{t('financial.cards.usedOfLimit', { percent: String(Math.round(pct)) })}</span>
|
|
159
|
+
<span className="tabular-nums">{formatCurrency(limit, currency)}</span>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="mt-1 h-2 overflow-hidden rounded-full bg-muted">
|
|
162
|
+
<div
|
|
163
|
+
className={`h-full rounded-full ${
|
|
164
|
+
over ? 'bg-destructive' : `bg-gradient-to-r ${barGrad}`
|
|
165
|
+
}`}
|
|
166
|
+
style={{ width: `${pct}%` }}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Available limit + due date */}
|
|
172
|
+
<div className="flex items-center justify-between">
|
|
173
|
+
<div>
|
|
174
|
+
<p className="text-[11px] text-muted-foreground">{t('financial.cards.availableLimit')}</p>
|
|
175
|
+
<p className="text-sm font-semibold tabular-nums">{formatCurrency(available, currency)}</p>
|
|
176
|
+
</div>
|
|
177
|
+
{card.dueDay != null && (
|
|
178
|
+
<div className="flex items-center gap-1.5 text-right text-xs text-muted-foreground">
|
|
179
|
+
<CalendarClock className="h-3.5 w-3.5" />
|
|
180
|
+
<span>{t('financial.cards.dueOn', { day: String(card.dueDay) })}</span>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{/* Add expense against this card */}
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={() => openAddExpense(card.id)}
|
|
189
|
+
className="flex w-full items-center justify-center gap-1.5 rounded-input border border-input bg-card px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted/40"
|
|
190
|
+
>
|
|
191
|
+
<Plus className="h-3.5 w-3.5" />
|
|
192
|
+
{t('financial.cards.addExpense')}
|
|
193
|
+
</button>
|
|
65
194
|
</div>
|
|
66
195
|
</div>
|
|
67
|
-
)
|
|
68
|
-
|
|
196
|
+
)
|
|
197
|
+
})}
|
|
69
198
|
</div>
|
|
70
|
-
|
|
199
|
+
</>
|
|
71
200
|
)}
|
|
201
|
+
|
|
202
|
+
<QuickTransactionForm
|
|
203
|
+
open={addOpen}
|
|
204
|
+
onOpenChange={setAddOpen}
|
|
205
|
+
defaultType="expense"
|
|
206
|
+
defaultAccountId={addCardId}
|
|
207
|
+
/>
|
|
72
208
|
</div>
|
|
73
209
|
)
|
|
74
210
|
}
|
|
@@ -2,11 +2,11 @@ import React, { useEffect, useState, useMemo } from 'react'
|
|
|
2
2
|
import { FileText, CircleDashed, CircleEllipsis, CircleCheckBig, CircleAlert, Ban } from 'lucide-react'
|
|
3
3
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
4
|
import { ListView } from '@fayz-ai/ui'
|
|
5
|
-
import { useFinancialConfig, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
5
|
+
import { useFinancialConfig, useFinancialProvider, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
6
6
|
import { SubpageHeader } from '@fayz-ai/ui'
|
|
7
7
|
import { PersonLink } from '@fayz-ai/saas'
|
|
8
8
|
import { useTranslation } from '@fayz-ai/core'
|
|
9
|
-
import type { TransactionDirection, InvoiceStatus, Invoice } from '../types'
|
|
9
|
+
import type { TransactionDirection, InvoiceStatus, Invoice, ChartOfAccountsNode, CostCenter } from '../types'
|
|
10
10
|
|
|
11
11
|
const STATUS_OPTIONS: { value: InvoiceStatus; labelKey: string; color: string; icon: React.ElementType }[] = [
|
|
12
12
|
{ value: 'open', labelKey: 'financial.invoice.statusOpen', color: 'bg-info-soft text-info-soft-foreground', icon: CircleDashed },
|
|
@@ -88,6 +88,7 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
88
88
|
}) {
|
|
89
89
|
const t = useTranslation()
|
|
90
90
|
const { currency } = useFinancialConfig()
|
|
91
|
+
const provider = useFinancialProvider()
|
|
91
92
|
const invoices = useFinancialStore((s) => s.invoices)
|
|
92
93
|
const invoicesTotal = useFinancialStore((s) => s.invoicesTotal)
|
|
93
94
|
const invoicesLoading = useFinancialStore((s) => s.invoicesLoading)
|
|
@@ -95,14 +96,36 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
95
96
|
|
|
96
97
|
const [search, setSearch] = useState('')
|
|
97
98
|
const [statusFilter, setStatusFilter] = useState<InvoiceStatus[]>([])
|
|
99
|
+
const [accountId, setAccountId] = useState('')
|
|
100
|
+
const [costCenterId, setCostCenterId] = useState('')
|
|
101
|
+
const [accounts, setAccounts] = useState<ChartOfAccountsNode[]>([])
|
|
102
|
+
const [costCenters, setCostCenters] = useState<CostCenter[]>([])
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
let active = true
|
|
106
|
+
Promise.all([provider.getChartOfAccounts(), provider.getCostCenters()])
|
|
107
|
+
.then(([accountRows, costCenterRows]) => {
|
|
108
|
+
if (!active) return
|
|
109
|
+
setAccounts(accountRows.filter((account) => account.nodeType === 'leaf'))
|
|
110
|
+
setCostCenters(costCenterRows)
|
|
111
|
+
})
|
|
112
|
+
.catch(() => {
|
|
113
|
+
if (!active) return
|
|
114
|
+
setAccounts([])
|
|
115
|
+
setCostCenters([])
|
|
116
|
+
})
|
|
117
|
+
return () => { active = false }
|
|
118
|
+
}, [provider])
|
|
98
119
|
|
|
99
120
|
useEffect(() => {
|
|
100
121
|
fetchInvoices({
|
|
101
122
|
direction,
|
|
102
123
|
status: statusFilter.length > 0 ? statusFilter : undefined,
|
|
103
124
|
search: search || undefined,
|
|
125
|
+
accountId: accountId || undefined,
|
|
126
|
+
costCenterId: costCenterId || undefined,
|
|
104
127
|
})
|
|
105
|
-
}, [direction, statusFilter, search])
|
|
128
|
+
}, [direction, statusFilter, search, accountId, costCenterId])
|
|
106
129
|
|
|
107
130
|
const filtered = invoices.filter((inv) => inv.direction === direction)
|
|
108
131
|
const listTitle = direction === 'debit' ? t('financial.invoice.accountsPayable') : t('financial.invoice.accountsReceivable')
|
|
@@ -135,6 +158,40 @@ export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
|
135
158
|
title={listTitle}
|
|
136
159
|
subtitle={t('financial.invoice.invoices', { count: filtered.length })}
|
|
137
160
|
/>
|
|
161
|
+
{(accounts.length > 0 || costCenters.length > 0) && (
|
|
162
|
+
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-muted/20 px-4 py-3">
|
|
163
|
+
{accounts.length > 0 && (
|
|
164
|
+
<label className="flex min-w-[220px] flex-col gap-1 text-xs font-medium text-muted-foreground">
|
|
165
|
+
{t('financial.invoice.filterAccount')}
|
|
166
|
+
<select
|
|
167
|
+
className="h-9 rounded-md border bg-background px-3 text-sm text-foreground"
|
|
168
|
+
value={accountId}
|
|
169
|
+
onChange={(event) => setAccountId(event.target.value)}
|
|
170
|
+
>
|
|
171
|
+
<option value="">{t('financial.invoice.filterAllAccounts')}</option>
|
|
172
|
+
{accounts.map((account) => (
|
|
173
|
+
<option key={account.id} value={account.id}>{account.code} - {account.name}</option>
|
|
174
|
+
))}
|
|
175
|
+
</select>
|
|
176
|
+
</label>
|
|
177
|
+
)}
|
|
178
|
+
{costCenters.length > 0 && (
|
|
179
|
+
<label className="flex min-w-[220px] flex-col gap-1 text-xs font-medium text-muted-foreground">
|
|
180
|
+
{t('financial.invoice.filterCostCenter')}
|
|
181
|
+
<select
|
|
182
|
+
className="h-9 rounded-md border bg-background px-3 text-sm text-foreground"
|
|
183
|
+
value={costCenterId}
|
|
184
|
+
onChange={(event) => setCostCenterId(event.target.value)}
|
|
185
|
+
>
|
|
186
|
+
<option value="">{t('financial.invoice.filterAllCostCenters')}</option>
|
|
187
|
+
{costCenters.map((costCenter) => (
|
|
188
|
+
<option key={costCenter.id} value={costCenter.id}>{costCenter.code} - {costCenter.name}</option>
|
|
189
|
+
))}
|
|
190
|
+
</select>
|
|
191
|
+
</label>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
138
195
|
<ListView<Invoice>
|
|
139
196
|
columns={columns}
|
|
140
197
|
data={filtered}
|