@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,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. KPIs, cash-flow chart, breakdown, overdue alerts and the
6
- * recent-transactions table are registered dashboard widgets (see
7
- * ./dashboardWidgets), rendered through the shared DashboardCanvas; the
8
- * total-balance KPI also surfaces on the global app home.
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 <DashboardCanvas surface="plugin-home" domain="financial" showHeader={false} className="space-y-6" />
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
  }