@fayz-ai/plugin-financial 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/FinancialContext.d.ts +2 -0
  2. package/dist/FinancialContext.d.ts.map +1 -1
  3. package/dist/FinancialPage.d.ts.map +1 -1
  4. package/dist/data/index.d.ts +1 -0
  5. package/dist/data/index.d.ts.map +1 -1
  6. package/dist/data/mock.d.ts +15 -0
  7. package/dist/data/mock.d.ts.map +1 -1
  8. package/dist/data/supabase.d.ts.map +1 -1
  9. package/dist/data/types.d.ts +2 -1
  10. package/dist/data/types.d.ts.map +1 -1
  11. package/dist/index.cjs +1531 -201
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.ts +13 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1393 -80
  16. package/dist/index.js.map +1 -1
  17. package/dist/locales/en.d.ts.map +1 -1
  18. package/dist/locales/pt-BR.d.ts.map +1 -1
  19. package/dist/quick-add.d.ts +13 -0
  20. package/dist/quick-add.d.ts.map +1 -0
  21. package/dist/store.d.ts +3 -1
  22. package/dist/store.d.ts.map +1 -1
  23. package/dist/types.d.ts +42 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/views/CardsView.d.ts.map +1 -1
  26. package/dist/views/InvoiceListView.d.ts.map +1 -1
  27. package/dist/views/QuickTransactionForm.d.ts +16 -0
  28. package/dist/views/QuickTransactionForm.d.ts.map +1 -0
  29. package/dist/views/SummaryView.d.ts +5 -4
  30. package/dist/views/SummaryView.d.ts.map +1 -1
  31. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  32. package/package.json +4 -7
  33. package/src/FinancialContext.tsx +2 -0
  34. package/src/FinancialPage.tsx +86 -7
  35. package/src/data/capability.test.ts +45 -0
  36. package/src/data/index.ts +1 -0
  37. package/src/data/mock.ts +50 -10
  38. package/src/data/supabase.ts +46 -3
  39. package/src/data/types.ts +2 -1
  40. package/src/index.ts +24 -4
  41. package/src/locales/en.ts +75 -0
  42. package/src/locales/pt-BR.ts +75 -0
  43. package/src/quick-add.ts +58 -0
  44. package/src/store.ts +88 -0
  45. package/src/types.ts +45 -0
  46. package/src/views/CardsView.tsx +186 -50
  47. package/src/views/InvoiceListView.tsx +60 -3
  48. package/src/views/QuickTransactionForm.tsx +537 -0
  49. package/src/views/SummaryView.tsx +197 -6
  50. package/src/views/dashboardWidgets.tsx +251 -5
@@ -1,74 +1,210 @@
1
- import React, { useEffect } from 'react'
2
- import { CreditCard, Check } from 'lucide-react'
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, labels } = useFinancialConfig()
10
- const cardTransactions = useFinancialStore((s) => s.cardTransactions)
11
- const cardsLoading = useFinancialStore((s) => s.cardsLoading)
12
- const fetchCardTransactions = useFinancialStore((s) => s.fetchCardTransactions)
13
-
14
- useEffect(() => { fetchCardTransactions() }, [])
15
-
16
- // Group by card brand
17
- const byBrand: Record<string, typeof cardTransactions> = {}
18
- for (const tx of cardTransactions) {
19
- const brand = tx.cardBrand || 'Other'
20
- if (!byBrand[brand]) byBrand[brand] = []
21
- byBrand[brand].push(tx)
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
- {cardsLoading ? (
65
+
66
+ {bankAccountsLoading && cards.length === 0 ? (
28
67
  <div className="text-center py-12 text-sm text-muted-foreground">Loading...</div>
29
- ) : cardTransactions.length === 0 ? (
30
- <div className="flex flex-col items-center justify-center py-12 text-center rounded-lg border-2 border-dashed border-muted">
31
- <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-500/20 mb-2">
32
- <CreditCard className="h-5 w-5 text-indigo-500" />
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 text-muted-foreground">{t('financial.cards.noTransactions')}</p>
35
- <p className="text-xs text-muted-foreground mt-0.5">{t('financial.cards.transactionsAppear')}</p>
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
- Object.entries(byBrand).map(([brand, txs]) => (
39
- <div key={brand} className="rounded-lg border bg-card shadow-sm">
40
- <div className="flex items-center gap-2 px-4 py-3 border-b">
41
- <CreditCard className="h-4 w-4 text-indigo-500" />
42
- <h3 className="text-sm font-semibold">{brand}</h3>
43
- <span className="text-xs text-muted-foreground">({txs.length} transactions)</span>
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
- <div className="divide-y">
46
- {txs.map((tx) => (
47
- <div key={tx.id} className="px-4 py-3 flex items-center justify-between">
48
- <div>
49
- <p className="text-sm font-medium">
50
- Installment {tx.currentInstallment}/{tx.installmentCount}
51
- </p>
52
- <p className="text-xs text-muted-foreground">Due: {tx.expectedDate}</p>
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
- <div className="flex items-center gap-3">
55
- <span className="text-sm font-medium">{formatCurrency(tx.installmentAmount, currency)}</span>
56
- {tx.status === 'received' ? (
57
- <span className="inline-flex items-center gap-1 rounded-full bg-success/15 px-2 py-0.5 text-[10px] font-medium text-success dark:bg-success/20">
58
- <Check className="h-2.5 w-2.5" /> Received
59
- </span>
60
- ) : (
61
- <span className="inline-flex items-center rounded-full bg-warning/15 px-2 py-0.5 text-[10px] font-medium text-warning dark:bg-warning/20">
62
- Pending
63
- </span>
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
- </div>
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}