@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.
- 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/types.d.ts +2 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +1531 -201
- 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 +1393 -80
- 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/quick-add.d.ts +13 -0
- package/dist/quick-add.d.ts.map +1 -0
- 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 +4 -7
- package/src/FinancialContext.tsx +2 -0
- package/src/FinancialPage.tsx +86 -7
- 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 +46 -3
- 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/quick-add.ts +58 -0
- 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
|
@@ -1,12 +1,203 @@
|
|
|
1
|
-
import React from 'react'
|
|
1
|
+
import React, { useEffect, useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ShoppingCart, Car, Home, Utensils, Zap, Film, HeartPulse, Dumbbell,
|
|
4
|
+
Music, ShoppingBag, Landmark, Wallet, CircleDollarSign, Paperclip,
|
|
5
|
+
} from 'lucide-react'
|
|
2
6
|
import { DashboardCanvas } from '@fayz-ai/ui'
|
|
7
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
8
|
+
import { useFinancialConfig, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
9
|
+
import type { StatementEntry } from '../types'
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
|
-
* Financial summary.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
12
|
+
* Financial summary. On desktop (md+) it renders the rich multi-column dashboard
|
|
13
|
+
* (KPI cards + Fluxo de Caixa chart + breakdown + overdue alerts + recent-
|
|
14
|
+
* transactions table) via the shared DashboardCanvas. On mobile (<md) it swaps
|
|
15
|
+
* to a Mobills-grade experience: a compact KPI strip over a date-grouped
|
|
16
|
+
* transaction list. The two paths share the same store — no forked screen.
|
|
9
17
|
*/
|
|
10
18
|
export function SummaryView() {
|
|
11
|
-
return
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
{/* Mobile: Mobills-style transaction-led summary */}
|
|
22
|
+
<div className="md:hidden">
|
|
23
|
+
<MobileSummary />
|
|
24
|
+
</div>
|
|
25
|
+
{/* Desktop: rich dashboard canvas (unchanged) */}
|
|
26
|
+
<DashboardCanvas
|
|
27
|
+
surface="plugin-home"
|
|
28
|
+
domain="financial"
|
|
29
|
+
showHeader={false}
|
|
30
|
+
className="hidden space-y-6 md:block"
|
|
31
|
+
/>
|
|
32
|
+
</>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Mobile summary (Mobills-grade)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
// keyword → category glyph, so each row leads with a recognizable colored circle
|
|
41
|
+
// (Mobills-style). Purely cosmetic and safe while categories aren't modeled yet;
|
|
42
|
+
// falls back to a generic money glyph.
|
|
43
|
+
function categoryIcon(label: string): React.ElementType {
|
|
44
|
+
const s = label.toLowerCase()
|
|
45
|
+
if (/(mercado|super|padaria|extra|p[aã]o|hortifr)/.test(s)) return ShoppingCart
|
|
46
|
+
if (/(uber|99|posto|shell|gasol|combust|carro)/.test(s)) return Car
|
|
47
|
+
if (/(aluguel|casa|condom|im[oó]vel)/.test(s)) return Home
|
|
48
|
+
if (/(ifood|restaurante|outback|lanche|food|bar\b)/.test(s)) return Utensils
|
|
49
|
+
if (/(luz|energia|enel|internet|vivo|conta de|[aá]gua)/.test(s)) return Zap
|
|
50
|
+
if (/(cinema|netflix|cinemark|filme|disney|prime)/.test(s)) return Film
|
|
51
|
+
if (/(farm|drogasil|sa[uú]de|plano|hospital|m[eé]dic)/.test(s)) return HeartPulse
|
|
52
|
+
if (/(academia|smartfit|gym|treino)/.test(s)) return Dumbbell
|
|
53
|
+
if (/(spotify|music|deezer)/.test(s)) return Music
|
|
54
|
+
if (/(amazon|shopping|loja|magaz|americanas|mercado livre)/.test(s)) return ShoppingBag
|
|
55
|
+
if (/(fatura|cart[aã]o|nubank|banco|boleto)/.test(s)) return Landmark
|
|
56
|
+
if (/(sal[aá]rio|freel|pix|reembolso|receb|renda|d[ií]vidend)/.test(s)) return Wallet
|
|
57
|
+
return CircleDollarSign
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function useDateLabel() {
|
|
61
|
+
const t = useTranslation()
|
|
62
|
+
const { currency } = useFinancialConfig()
|
|
63
|
+
return useMemo(() => {
|
|
64
|
+
const todayStr = new Date().toISOString().slice(0, 10)
|
|
65
|
+
const y = new Date(); y.setDate(y.getDate() - 1)
|
|
66
|
+
const yesterdayStr = y.toISOString().slice(0, 10)
|
|
67
|
+
return (dateStr: string): string => {
|
|
68
|
+
if (dateStr === todayStr) return t('financial.summary.today')
|
|
69
|
+
if (dateStr === yesterdayStr) return t('financial.summary.yesterday')
|
|
70
|
+
try {
|
|
71
|
+
return new Date(`${dateStr}T00:00:00`).toLocaleDateString(currency.locale, {
|
|
72
|
+
weekday: 'short', day: '2-digit', month: 'short',
|
|
73
|
+
})
|
|
74
|
+
} catch {
|
|
75
|
+
return dateStr
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, [t, currency.locale])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function MobileSummary() {
|
|
82
|
+
const t = useTranslation()
|
|
83
|
+
const { currency } = useFinancialConfig()
|
|
84
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
85
|
+
const statement = useFinancialStore((s) => s.statement)
|
|
86
|
+
const bankAccounts = useFinancialStore((s) => s.bankAccounts)
|
|
87
|
+
const fetchSummary = useFinancialStore((s) => s.fetchSummary)
|
|
88
|
+
const fetchBankAccounts = useFinancialStore((s) => s.fetchBankAccounts)
|
|
89
|
+
const fetchStatement = useFinancialStore((s) => s.fetchStatement)
|
|
90
|
+
const dateLabel = useDateLabel()
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
void fetchSummary()
|
|
94
|
+
void fetchBankAccounts()
|
|
95
|
+
const to = new Date().toISOString().slice(0, 10)
|
|
96
|
+
const from = new Date(); from.setDate(from.getDate() - 90)
|
|
97
|
+
void fetchStatement({ dateRange: { from: from.toISOString().slice(0, 10), to } })
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
const fmt = (n: number) => formatCurrency(n, currency)
|
|
101
|
+
const inflow = summary?.monthlyInflow ?? 0
|
|
102
|
+
const outflow = summary?.monthlyOutflow ?? 0
|
|
103
|
+
|
|
104
|
+
const accountName = useMemo(() => {
|
|
105
|
+
const map = new Map(bankAccounts.map((a) => [a.id, a.name]))
|
|
106
|
+
return (id?: string) => (id ? map.get(id) ?? '' : '')
|
|
107
|
+
}, [bankAccounts])
|
|
108
|
+
|
|
109
|
+
// Realized cash events grouped by day (most recent first). Transfers are
|
|
110
|
+
// dropped — they're internal and would double-count in a personal feed.
|
|
111
|
+
const groups = useMemo(() => {
|
|
112
|
+
const entries = (statement?.entries ?? []).filter((e) => e.entryKind === 'movement')
|
|
113
|
+
const byDate = new Map<string, StatementEntry[]>()
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
const d = e.movement.paymentDate ?? e.movement.dueDate
|
|
116
|
+
const bucket = byDate.get(d)
|
|
117
|
+
if (bucket) bucket.push(e)
|
|
118
|
+
else byDate.set(d, [e])
|
|
119
|
+
}
|
|
120
|
+
return [...byDate.entries()].sort((a, b) => b[0].localeCompare(a[0]))
|
|
121
|
+
}, [statement])
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="space-y-4">
|
|
125
|
+
{/* Compact KPI strip */}
|
|
126
|
+
<div className="grid grid-cols-2 gap-2">
|
|
127
|
+
<div className="col-span-2 rounded-xl border bg-card p-4">
|
|
128
|
+
<p className="text-xs text-muted-foreground">{t('financial.summary.totalBalance')}</p>
|
|
129
|
+
<p className="mt-0.5 text-2xl font-semibold">{fmt(summary?.totalBalance ?? 0)}</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="rounded-xl border bg-card p-3">
|
|
132
|
+
<p className="text-[11px] text-muted-foreground">{t('financial.summary.income')}</p>
|
|
133
|
+
<p className="mt-0.5 text-base font-semibold text-success">{fmt(inflow)}</p>
|
|
134
|
+
</div>
|
|
135
|
+
<div className="rounded-xl border bg-card p-3">
|
|
136
|
+
<p className="text-[11px] text-muted-foreground">{t('financial.summary.expenses')}</p>
|
|
137
|
+
<p className="mt-0.5 text-base font-semibold text-destructive">{fmt(outflow)}</p>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Date-grouped transaction list */}
|
|
142
|
+
{groups.length === 0 ? (
|
|
143
|
+
<div className="rounded-xl border border-dashed py-12 text-center text-sm text-muted-foreground">
|
|
144
|
+
{t('financial.summary.noTransactions')}
|
|
145
|
+
</div>
|
|
146
|
+
) : (
|
|
147
|
+
<div className="space-y-4">
|
|
148
|
+
{groups.map(([date, rows]) => (
|
|
149
|
+
<div key={date} className="space-y-1.5">
|
|
150
|
+
<p className="px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
151
|
+
{dateLabel(date)}
|
|
152
|
+
</p>
|
|
153
|
+
<div className="divide-y overflow-hidden rounded-xl border bg-card">
|
|
154
|
+
{rows.map((e) => {
|
|
155
|
+
const isCredit = e.movement.direction === 'credit'
|
|
156
|
+
const title = e.invoice?.contactName || e.movement.notes || t('financial.summary.transactionsTitle')
|
|
157
|
+
const Icon = categoryIcon(title)
|
|
158
|
+
const category = isCredit ? t('financial.summary.income') : t('financial.summary.expenses')
|
|
159
|
+
const acct = accountName(e.movement.bankAccountId)
|
|
160
|
+
const subtitle = acct ? `${category} · ${acct}` : category
|
|
161
|
+
// FAY-1226: a snapped receipt lives on the invoice metadata.
|
|
162
|
+
const receiptUrl = (e.invoice?.metadata as { receiptUrl?: string } | undefined)?.receiptUrl
|
|
163
|
+
return (
|
|
164
|
+
<div key={e.movement.id} className="flex items-center gap-3 px-3 py-2.5">
|
|
165
|
+
<span
|
|
166
|
+
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
|
|
167
|
+
isCredit
|
|
168
|
+
? 'bg-success-soft text-success-soft-foreground'
|
|
169
|
+
: 'bg-destructive-soft text-destructive-soft-foreground'
|
|
170
|
+
}`}
|
|
171
|
+
>
|
|
172
|
+
<Icon className="h-4 w-4" />
|
|
173
|
+
</span>
|
|
174
|
+
<div className="min-w-0 flex-1">
|
|
175
|
+
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
|
|
176
|
+
{title}
|
|
177
|
+
{receiptUrl && (
|
|
178
|
+
<Paperclip className="h-3 w-3 shrink-0 text-muted-foreground" aria-label={t('financial.quickTx.receiptAttached')} />
|
|
179
|
+
)}
|
|
180
|
+
</p>
|
|
181
|
+
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
|
|
182
|
+
</div>
|
|
183
|
+
{receiptUrl && (
|
|
184
|
+
<img
|
|
185
|
+
src={receiptUrl}
|
|
186
|
+
alt={t('financial.quickTx.receiptAttached')}
|
|
187
|
+
className="h-9 w-9 shrink-0 rounded-md border object-cover"
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
<span className={`shrink-0 text-sm font-semibold ${isCredit ? 'text-success' : 'text-destructive'}`}>
|
|
191
|
+
{isCredit ? '+' : '-'}{fmt(e.net)}
|
|
192
|
+
</span>
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
})}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
12
203
|
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import React, { useEffect } from 'react'
|
|
2
|
-
import { ArrowUpRight, ArrowDownRight, AlertTriangle } from 'lucide-react'
|
|
2
|
+
import { ArrowUpRight, ArrowDownRight, AlertTriangle, CreditCard as CreditCardIcon, Eye, EyeOff } from 'lucide-react'
|
|
3
3
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
4
|
import type { StoreApi } from 'zustand'
|
|
5
5
|
import type { DashboardWidgetDef } from '@fayz-ai/core'
|
|
6
6
|
import { useTranslation } from '@fayz-ai/core'
|
|
7
7
|
import {
|
|
8
|
-
KpiCard, ChartWidget, TableWidget, Card, CardHeader, CardTitle, CardContent,
|
|
9
|
-
defineKpiWidget, defineChartWidget, defineCustomWidget, defineTableWidget,
|
|
8
|
+
KpiCard, ChartWidget, TableWidget, Card, CardHeader, CardTitle, CardContent, GoldCard,
|
|
9
|
+
defineKpiWidget, defineChartWidget, defineCustomWidget, defineTableWidget, useDashboardNavigate,
|
|
10
10
|
} from '@fayz-ai/ui'
|
|
11
|
-
import { FinancialContextProvider, useFinancialConfig, useFinancialStore, formatCurrency, type ResolvedFinancialConfig } from '../FinancialContext'
|
|
11
|
+
import { FinancialContextProvider, useFinancialConfig, useFinancialProvider, useFinancialStore, formatCurrency, type ResolvedFinancialConfig } from '../FinancialContext'
|
|
12
12
|
import type { FinancialDataProvider } from '../data/types'
|
|
13
13
|
import type { FinancialUIState } from '../store'
|
|
14
|
-
import type { Invoice } from '../types'
|
|
14
|
+
import type { Invoice, FinancialMovement } from '../types'
|
|
15
15
|
|
|
16
16
|
function useEnsureSummary() {
|
|
17
17
|
const fetchSummary = useFinancialStore((s) => s.fetchSummary)
|
|
@@ -157,6 +157,239 @@ function RecentTransactions() {
|
|
|
157
157
|
return <TableWidget title={t('financial.summary.recentTransactions')} icon="CircleDollarSign" columns={columns} data={invoices.slice(0, 5)} emptyMessage={t('financial.summary.noTransactions')} />
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// HOME surface (B2C) — a phone-first money dashboard that reflows into cells on
|
|
162
|
+
// desktop. These render on surface="home" only; the plugin-home Resumo widgets
|
|
163
|
+
// above are untouched. Same store/provider, so figures match the Financeiro
|
|
164
|
+
// module (FAY-1233 mobile-summary logic, generalized into reusable widgets).
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/** Human "due in N days" label from a YYYY-MM-DD due date. */
|
|
168
|
+
function useDueLabel() {
|
|
169
|
+
const t = useTranslation()
|
|
170
|
+
return (dueDate: string): string => {
|
|
171
|
+
const today = new Date()
|
|
172
|
+
today.setHours(0, 0, 0, 0)
|
|
173
|
+
const due = new Date(`${dueDate}T00:00:00`)
|
|
174
|
+
const days = Math.round((due.getTime() - today.getTime()) / 86_400_000)
|
|
175
|
+
if (days < 0) return t('financial.home.overdueDays', { count: String(Math.abs(days)) })
|
|
176
|
+
if (days === 0) return t('financial.home.dueToday')
|
|
177
|
+
if (days === 1) return t('financial.home.dueTomorrow')
|
|
178
|
+
return t('financial.home.dueInDays', { count: String(days) })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Branded balance hero — Saldo + eye-toggle + clickable Entradas/Saídas pills.
|
|
183
|
+
* Compact band (span-4): tight padding so it never becomes a tall empty block on
|
|
184
|
+
* desktop. Pills deep-link into the Financeiro module (receivables / payables). */
|
|
185
|
+
function BalanceHero() {
|
|
186
|
+
const t = useTranslation()
|
|
187
|
+
const { currency } = useFinancialConfig()
|
|
188
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
189
|
+
const navigate = useDashboardNavigate()
|
|
190
|
+
useEnsureSummary()
|
|
191
|
+
const [hidden, setHidden] = React.useState(false)
|
|
192
|
+
const money = (v: number | null | undefined) => (hidden ? '••••••' : formatCurrency(v ?? 0, currency))
|
|
193
|
+
const inflow = summary?.monthlyInflow ?? 0
|
|
194
|
+
const outflow = summary?.monthlyOutflow ?? 0
|
|
195
|
+
return (
|
|
196
|
+
<GoldCard branded className="px-5 py-4 text-center md:px-8 md:py-5">
|
|
197
|
+
<p className="text-xs font-medium uppercase tracking-wide opacity-75">{t('financial.home.balance')}</p>
|
|
198
|
+
<div className="mt-0.5 flex items-center justify-center gap-2.5">
|
|
199
|
+
<span className="text-3xl font-bold tracking-tight tabular-nums md:text-4xl">{money(summary?.totalBalance)}</span>
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={() => setHidden((h) => !h)}
|
|
203
|
+
aria-label={hidden ? t('financial.home.showBalance') : t('financial.home.hideBalance')}
|
|
204
|
+
className="rounded-full p-1.5 text-primary-foreground/80 transition-colors hover:bg-white/10"
|
|
205
|
+
>
|
|
206
|
+
{hidden ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
<div className="mx-auto mt-3 grid max-w-lg grid-cols-2 gap-3">
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => navigate('/financial/receivables-list')}
|
|
213
|
+
aria-label={t('financial.home.inflow')}
|
|
214
|
+
className="flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-3 py-2 text-left transition-colors hover:bg-white/20"
|
|
215
|
+
>
|
|
216
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-500/90">
|
|
217
|
+
<ArrowUpRight className="h-4 w-4 text-white" />
|
|
218
|
+
</span>
|
|
219
|
+
<span className="min-w-0">
|
|
220
|
+
<span className="block text-[11px] uppercase tracking-wide opacity-70">{t('financial.home.inflow')}</span>
|
|
221
|
+
<span className="block truncate text-sm font-semibold tabular-nums">{money(inflow)}</span>
|
|
222
|
+
</span>
|
|
223
|
+
</button>
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onClick={() => navigate('/financial/payables-list')}
|
|
227
|
+
aria-label={t('financial.home.outflow')}
|
|
228
|
+
className="flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-3 py-2 text-left transition-colors hover:bg-white/20"
|
|
229
|
+
>
|
|
230
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-rose-500/90">
|
|
231
|
+
<ArrowDownRight className="h-4 w-4 text-white" />
|
|
232
|
+
</span>
|
|
233
|
+
<span className="min-w-0">
|
|
234
|
+
<span className="block text-[11px] uppercase tracking-wide opacity-70">{t('financial.home.outflow')}</span>
|
|
235
|
+
<span className="block truncate text-sm font-semibold tabular-nums">{money(outflow)}</span>
|
|
236
|
+
</span>
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
</GoldCard>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// HOME charts — spend-by-category (donut) + cash-flow (in vs out). Both read the
|
|
245
|
+
// same provider/store as the module, so figures stay coherent. surface 'home'
|
|
246
|
+
// only (the plugin-home Resumo keeps its own cash-flow chart untouched).
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/** Provider-agnostic expense classifier: maps a movement's label to a spend
|
|
250
|
+
* bucket by keyword (pt-BR + en). Checked in priority order; first hit wins. */
|
|
251
|
+
const SPEND_CATEGORIES: Array<{ key: string; keywords: string[] }> = [
|
|
252
|
+
{ key: 'subscriptions', keywords: ['netflix', 'spotify', 'prime video', 'disney', 'hbo', ' max', 'youtube', 'apple', 'assinatura', 'subscription'] },
|
|
253
|
+
{ key: 'groceries', keywords: ['supermercado', 'mercado', 'padaria', 'hortifruti', 'feira', 'açougue', 'grocery', 'market'] },
|
|
254
|
+
{ key: 'transport', keywords: ['uber', '99', 'taxi', 'posto', 'shell', 'ipiranga', 'combustível', 'gasolina', 'metrô', 'ônibus', 'transport', 'fuel'] },
|
|
255
|
+
{ key: 'dining', keywords: ['ifood', 'rappi', 'restaurante', 'outback', 'lanchonete', 'pizzaria', 'café', 'cafeteria', 'mcdonald', 'burger', 'food'] },
|
|
256
|
+
{ key: 'home', keywords: ['aluguel', 'condomínio', 'luz', 'enel', 'água', 'sabesp', 'internet', 'vivo', 'claro', 'gás', 'iptu', 'rent', 'utilit'] },
|
|
257
|
+
{ key: 'health', keywords: ['farmácia', 'farmacia', 'drogasil', 'drogaria', 'academia', 'smartfit', 'clínica', 'hospital', 'médico', 'saúde', 'gym', 'fitness', 'health', 'pharmac'] },
|
|
258
|
+
{ key: 'leisure', keywords: ['cinema', 'cinemark', 'ingresso', 'show', 'teatro', 'viagem', 'hotel', 'steam', 'leisure'] },
|
|
259
|
+
{ key: 'shopping', keywords: ['amazon', 'magalu', 'shopee', 'aliexpress', 'loja', 'shopping', 'zara', 'renner', 'store'] },
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
function categorizeSpend(notes: string | undefined): string {
|
|
263
|
+
const s = (notes ?? '').toLowerCase()
|
|
264
|
+
for (const c of SPEND_CATEGORIES) if (c.keywords.some((k) => s.includes(k))) return c.key
|
|
265
|
+
return 'other'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** "Gastos por categoria" — realized expenses grouped into a donut. */
|
|
269
|
+
function SpendByCategoryChart() {
|
|
270
|
+
const t = useTranslation()
|
|
271
|
+
const provider = useFinancialProvider()
|
|
272
|
+
const [rows, setRows] = React.useState<Array<{ name: string; value: number }>>([])
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
let alive = true
|
|
275
|
+
void (async () => {
|
|
276
|
+
const res = await provider.getMovements({ status: 'paid', direction: 'debit' })
|
|
277
|
+
if (!alive) return
|
|
278
|
+
const totals = new Map<string, number>()
|
|
279
|
+
for (const m of res.data) {
|
|
280
|
+
if (m.movementKind === 'transfer') continue
|
|
281
|
+
const key = categorizeSpend(m.notes)
|
|
282
|
+
totals.set(key, (totals.get(key) ?? 0) + (m.paidAmount || m.amount))
|
|
283
|
+
}
|
|
284
|
+
setRows(
|
|
285
|
+
[...totals.entries()]
|
|
286
|
+
.sort((a, b) => b[1] - a[1])
|
|
287
|
+
.map(([key, value]) => ({ name: t(`financial.home.category.${key}`), value: Math.round(value * 100) / 100 })),
|
|
288
|
+
)
|
|
289
|
+
})()
|
|
290
|
+
return () => { alive = false }
|
|
291
|
+
}, [provider])
|
|
292
|
+
if (rows.length === 0) {
|
|
293
|
+
return (
|
|
294
|
+
<Card>
|
|
295
|
+
<CardHeader><CardTitle>{t('financial.home.spendByCategory')}</CardTitle></CardHeader>
|
|
296
|
+
<CardContent className="flex h-[200px] items-center justify-center text-sm text-muted-foreground">
|
|
297
|
+
{t('financial.home.noSpend')}
|
|
298
|
+
</CardContent>
|
|
299
|
+
</Card>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
return (
|
|
303
|
+
<ChartWidget
|
|
304
|
+
type="pie" title={t('financial.home.spendByCategory')} icon="PieChart" categoryKey="name" data={rows}
|
|
305
|
+
series={[{ dataKey: 'value', label: t('financial.home.spendByCategory') }]}
|
|
306
|
+
/>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** "Próximas contas" — pending payable bills, soonest due first. */
|
|
311
|
+
function UpcomingBills() {
|
|
312
|
+
const t = useTranslation()
|
|
313
|
+
const { currency } = useFinancialConfig()
|
|
314
|
+
const provider = useFinancialProvider()
|
|
315
|
+
const dueLabel = useDueLabel()
|
|
316
|
+
const [bills, setBills] = React.useState<FinancialMovement[]>([])
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
let alive = true
|
|
319
|
+
void (async () => {
|
|
320
|
+
const pending = await provider.getMovements({ status: 'pending' })
|
|
321
|
+
if (!alive) return
|
|
322
|
+
setBills(
|
|
323
|
+
pending.data
|
|
324
|
+
.filter((m) => m.direction === 'debit' && m.movementKind === 'bill')
|
|
325
|
+
.sort((a, b) => a.dueDate.localeCompare(b.dueDate)),
|
|
326
|
+
)
|
|
327
|
+
})()
|
|
328
|
+
return () => { alive = false }
|
|
329
|
+
}, [provider])
|
|
330
|
+
return (
|
|
331
|
+
<GoldCard className="p-0 overflow-hidden">
|
|
332
|
+
<div className="flex items-center justify-between px-4 pt-4">
|
|
333
|
+
<h2 className="text-sm font-semibold text-foreground">{t('financial.home.upcomingBills')}</h2>
|
|
334
|
+
</div>
|
|
335
|
+
<div className="mt-3">
|
|
336
|
+
{bills.length === 0 ? (
|
|
337
|
+
<div className="px-4 py-6 text-center text-xs text-muted-foreground">{t('financial.home.noBills')}</div>
|
|
338
|
+
) : (
|
|
339
|
+
bills.map((bill, i) => (
|
|
340
|
+
<div key={bill.id} className={'flex items-center justify-between px-4 py-3.5' + (i > 0 ? ' border-t border-border' : '')}>
|
|
341
|
+
<div className="min-w-0">
|
|
342
|
+
<p className="truncate text-sm font-medium text-foreground">{bill.notes ?? t('financial.home.bill')}</p>
|
|
343
|
+
<p className="text-xs text-muted-foreground">{dueLabel(bill.dueDate)}</p>
|
|
344
|
+
</div>
|
|
345
|
+
<span className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
|
|
346
|
+
{formatCurrency(bill.amount - bill.paidAmount, currency)}
|
|
347
|
+
</span>
|
|
348
|
+
</div>
|
|
349
|
+
))
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
</GoldCard>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** "Cartões" — credit-card accounts with current bill + available limit. */
|
|
357
|
+
function CreditCards() {
|
|
358
|
+
const t = useTranslation()
|
|
359
|
+
const { currency } = useFinancialConfig()
|
|
360
|
+
const bankAccounts = useFinancialStore((s) => s.bankAccounts)
|
|
361
|
+
const fetchBankAccounts = useFinancialStore((s) => s.fetchBankAccounts)
|
|
362
|
+
useEffect(() => { void fetchBankAccounts() }, [])
|
|
363
|
+
const cards = bankAccounts.filter((a) => a.accountType === 'credit_card')
|
|
364
|
+
return (
|
|
365
|
+
<GoldCard className="bg-transparent border-0 p-0 shadow-none">
|
|
366
|
+
<h2 className="mb-3 text-sm font-semibold text-foreground">{t('financial.home.cards')}</h2>
|
|
367
|
+
<div className="space-y-3">
|
|
368
|
+
{cards.map((card) => {
|
|
369
|
+
const fatura = Math.abs(Math.min(0, card.currentBalance))
|
|
370
|
+
const limite = Math.max(0, (card.creditLimit ?? 0) - fatura)
|
|
371
|
+
return (
|
|
372
|
+
<div key={card.id} className="rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-700 p-4 text-white shadow-md">
|
|
373
|
+
<div className="flex items-center justify-between">
|
|
374
|
+
<span className="text-sm font-semibold">{card.bankName ?? card.name}</span>
|
|
375
|
+
<CreditCardIcon className="h-5 w-5 opacity-80" />
|
|
376
|
+
</div>
|
|
377
|
+
<p className="mt-6 text-[11px] uppercase tracking-wide opacity-70">{t('financial.home.currentBill')}</p>
|
|
378
|
+
<p className="text-xl font-bold tabular-nums">{formatCurrency(fatura, currency)}</p>
|
|
379
|
+
<p className="mt-2 text-[11px] opacity-70">{t('financial.home.availableLimit')} {formatCurrency(limite, currency)}</p>
|
|
380
|
+
</div>
|
|
381
|
+
)
|
|
382
|
+
})}
|
|
383
|
+
{cards.length === 0 ? (
|
|
384
|
+
<div className="flex items-center justify-center rounded-2xl border border-dashed border-border py-6 text-sm font-medium text-muted-foreground">
|
|
385
|
+
+ {t('financial.home.addCard')}
|
|
386
|
+
</div>
|
|
387
|
+
) : null}
|
|
388
|
+
</div>
|
|
389
|
+
</GoldCard>
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
160
393
|
// ---------------------------------------------------------------------------
|
|
161
394
|
// Registry builder
|
|
162
395
|
// ---------------------------------------------------------------------------
|
|
@@ -187,5 +420,18 @@ export function createFinancialDashboardWidgets(ctx: {
|
|
|
187
420
|
defineCustomWidget({ id: 'financial.panel.breakdown', title: 'financial.summary.cashFlowBreakdown', domain: 'financial', span: 1, defaultOrder: 11, surfaces: ['plugin-home'], component: withCtx(CashFlowBreakdown) }),
|
|
188
421
|
defineCustomWidget({ id: 'financial.panel.overdue', title: 'financial.summary.overdueTitle', domain: 'financial', span: 1, defaultOrder: 12, surfaces: ['plugin-home'], component: withCtx(OverdueAlerts) }),
|
|
189
422
|
defineTableWidget({ id: 'financial.table.recent', title: 'financial.summary.recentTransactions', domain: 'financial', span: 4, defaultOrder: 20, surfaces: ['plugin-home'], component: withCtx(RecentTransactions) }),
|
|
423
|
+
|
|
424
|
+
// FINANCE-HOME surface (B2C phone-first dashboard) — a compact analytical
|
|
425
|
+
// layout: hero band → charts row (spend-by-category + cash-flow) → bills +
|
|
426
|
+
// cards. This is a dedicated consumer surface (norman-ai's money screen), NOT
|
|
427
|
+
// the universal B2B 'home': a full-bleed balance hero and credit-card tiles
|
|
428
|
+
// are personal-finance chrome that would leak into every app that merely
|
|
429
|
+
// enables plugin-financial (e.g. the beauty-saas Painel). Apps that want them
|
|
430
|
+
// opt in explicitly by rendering <DashboardCanvas surface="finance-home">.
|
|
431
|
+
defineCustomWidget({ id: 'financial.hero.balance', title: 'financial.home.balance', domain: 'financial', span: 4, defaultOrder: 0, surfaces: ['finance-home'], component: withCtx(BalanceHero) }),
|
|
432
|
+
defineChartWidget({ id: 'financial.chart.spend-by-category', title: 'financial.home.spendByCategory', domain: 'financial', span: 2, defaultOrder: 1, surfaces: ['finance-home'], component: withCtx(SpendByCategoryChart) }),
|
|
433
|
+
defineChartWidget({ id: 'financial.chart.cash-flow-home', title: 'financial.summary.cashFlow', domain: 'financial', span: 2, defaultOrder: 2, surfaces: ['finance-home'], component: withCtx(CashFlowChart) }),
|
|
434
|
+
defineCustomWidget({ id: 'financial.list.upcoming-bills', title: 'financial.home.upcomingBills', domain: 'financial', span: 2, defaultOrder: 3, surfaces: ['finance-home'], component: withCtx(UpcomingBills) }),
|
|
435
|
+
defineCustomWidget({ id: 'financial.cards', title: 'financial.home.cards', domain: 'financial', span: 2, defaultOrder: 4, surfaces: ['finance-home'], component: withCtx(CreditCards) }),
|
|
190
436
|
]
|
|
191
437
|
}
|