@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
@@ -0,0 +1,537 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, Check, ChevronDown,
4
+ Tag, Wallet, Repeat, Paperclip, CalendarDays, X, Camera,
5
+ } from 'lucide-react'
6
+ import {
7
+ Modal, ModalContent, Button, DatePicker, toast,
8
+ } from '@fayz-ai/ui'
9
+ import { useTranslation } from '@fayz-ai/core'
10
+ import { useFinancialConfig, useFinancialProvider, useFinancialStore, formatCurrency } from '../FinancialContext'
11
+ import type { QuickTransactionType } from '../types'
12
+ import type { ChartOfAccountsNode, BankAccount } from '../types'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Type theme — the header is context-colored so the user always knows what
16
+ // kind of money they're logging (Mobills: red expense / green income / neutral
17
+ // transfer).
18
+ // ---------------------------------------------------------------------------
19
+
20
+ interface TypeTheme {
21
+ header: string // header band background + text
22
+ ring: string // focus/selected accent
23
+ icon: React.ElementType
24
+ }
25
+
26
+ function typeTheme(type: QuickTransactionType): TypeTheme {
27
+ switch (type) {
28
+ case 'income':
29
+ return { header: 'bg-success text-success-foreground', ring: 'ring-success', icon: ArrowDownCircle }
30
+ case 'transfer':
31
+ return { header: 'bg-muted text-foreground', ring: 'ring-ring', icon: ArrowLeftRight }
32
+ case 'expense':
33
+ default:
34
+ return { header: 'bg-destructive text-destructive-foreground', ring: 'ring-destructive', icon: ArrowUpCircle }
35
+ }
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Small popover-chip select (category / account). Thumb-friendly rows.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function ChipSelect({ icon: Icon, label, value, options, onChange, emptyLabel }: {
43
+ icon: React.ElementType
44
+ label: string
45
+ value?: string
46
+ options: Array<{ id: string; name: string; hint?: string }>
47
+ onChange: (id: string | undefined) => void
48
+ emptyLabel: string
49
+ }) {
50
+ const [open, setOpen] = useState(false)
51
+ const ref = useRef<HTMLDivElement>(null)
52
+
53
+ useEffect(() => {
54
+ if (!open) return
55
+ function onDoc(e: MouseEvent) {
56
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
57
+ }
58
+ document.addEventListener('mousedown', onDoc)
59
+ return () => document.removeEventListener('mousedown', onDoc)
60
+ }, [open])
61
+
62
+ const selected = options.find((o) => o.id === value)
63
+
64
+ return (
65
+ <div ref={ref} className="relative">
66
+ <span className="text-xs font-medium text-muted-foreground">{label}</span>
67
+ <button
68
+ type="button"
69
+ onClick={() => setOpen((v) => !v)}
70
+ className="mt-1 flex w-full items-center gap-2 rounded-input border border-input bg-card px-3 py-2.5 text-left text-sm shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] transition-colors hover:bg-muted/30"
71
+ >
72
+ <Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
73
+ <span className={`flex-1 truncate ${selected ? '' : 'text-muted-foreground'}`}>
74
+ {selected?.name ?? emptyLabel}
75
+ </span>
76
+ <ChevronDown className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
77
+ </button>
78
+
79
+ {open && (
80
+ <div className="fayz-glass-surface absolute bottom-full left-0 right-0 z-50 mb-1 max-h-56 overflow-auto rounded-lg border bg-popover py-1 shadow-lg">
81
+ {options.length === 0 ? (
82
+ <p className="px-3 py-4 text-center text-xs text-muted-foreground">{emptyLabel}</p>
83
+ ) : (
84
+ <>
85
+ {value != null && (
86
+ <button
87
+ onClick={() => { onChange(undefined); setOpen(false) }}
88
+ className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs text-muted-foreground hover:bg-muted/50"
89
+ >
90
+ <X className="h-3.5 w-3.5" /> {emptyLabel}
91
+ </button>
92
+ )}
93
+ {options.map((o) => (
94
+ <button
95
+ key={o.id}
96
+ onClick={() => { onChange(o.id); setOpen(false) }}
97
+ className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50 ${o.id === value ? 'bg-muted/30' : ''}`}
98
+ >
99
+ <span className="flex-1 truncate">{o.name}</span>
100
+ {o.hint && <span className="text-[10px] text-muted-foreground">{o.hint}</span>}
101
+ {o.id === value && <Check className="h-3.5 w-3.5 text-primary" />}
102
+ </button>
103
+ ))}
104
+ </>
105
+ )}
106
+ </div>
107
+ )}
108
+ </div>
109
+ )
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Toggle pill
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function TogglePill({ icon: Icon, label, checked, onChange, accent }: {
117
+ icon: React.ElementType
118
+ label: string
119
+ checked: boolean
120
+ onChange: (v: boolean) => void
121
+ accent?: string
122
+ }) {
123
+ return (
124
+ <button
125
+ type="button"
126
+ onClick={() => onChange(!checked)}
127
+ className={`flex items-center gap-2 rounded-full border px-3 py-2 text-xs font-medium transition-colors ${
128
+ checked
129
+ ? `border-transparent ${accent ?? 'bg-primary text-primary-foreground'}`
130
+ : 'border-input bg-card text-muted-foreground hover:bg-muted/40'
131
+ }`}
132
+ >
133
+ <Icon className="h-3.5 w-3.5" />
134
+ {label}
135
+ </button>
136
+ )
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Quick transaction form — responsive (mobile fullscreen sheet / desktop modal)
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export function QuickTransactionForm({ open, onOpenChange, defaultType = 'expense', defaultAccountId, focusReceipt = false }: {
144
+ open: boolean
145
+ onOpenChange: (open: boolean) => void
146
+ defaultType?: QuickTransactionType
147
+ /** Preselect an account (e.g. opening from a specific card tile → log an expense against it). */
148
+ defaultAccountId?: string
149
+ /**
150
+ * FAY-1226 "snap a receipt": open with the attachment step foregrounded — a
151
+ * prominent capture card at the top of the sheet + auto-open the image picker
152
+ * (mobile offers the camera). Used by the "Enviar recibo" entry point.
153
+ */
154
+ focusReceipt?: boolean
155
+ }) {
156
+ const t = useTranslation()
157
+ const { currency } = useFinancialConfig()
158
+ const provider = useFinancialProvider()
159
+ const createQuickTransaction = useFinancialStore((s) => s.createQuickTransaction)
160
+
161
+ const [type, setType] = useState<QuickTransactionType>(defaultType)
162
+ const [amount, setAmount] = useState(0)
163
+ const [amountStr, setAmountStr] = useState('')
164
+ const [paid, setPaid] = useState(true)
165
+ const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10))
166
+ const [description, setDescription] = useState('')
167
+ const [categoryId, setCategoryId] = useState<string | undefined>()
168
+ const [accountId, setAccountId] = useState<string | undefined>()
169
+ const [toAccountId, setToAccountId] = useState<string | undefined>()
170
+ const [recurring, setRecurring] = useState(false)
171
+ const [saving, setSaving] = useState(false)
172
+ // FAY-1226: the snapped receipt image, held in-memory as a data URL.
173
+ const [receiptUrl, setReceiptUrl] = useState<string | undefined>()
174
+ const fileInputRef = useRef<HTMLInputElement>(null)
175
+
176
+ const [accounts, setAccounts] = useState<BankAccount[]>([])
177
+ const [categories, setCategories] = useState<ChartOfAccountsNode[]>([])
178
+
179
+ // Reset to a clean slate each time the sheet opens.
180
+ useEffect(() => {
181
+ if (!open) return
182
+ setType(defaultType)
183
+ setAmount(0)
184
+ setAmountStr('')
185
+ setPaid(true)
186
+ setDate(new Date().toISOString().slice(0, 10))
187
+ setDescription('')
188
+ setCategoryId(undefined)
189
+ setAccountId(defaultAccountId)
190
+ setToAccountId(undefined)
191
+ setRecurring(false)
192
+ setReceiptUrl(undefined)
193
+ }, [open, defaultType, defaultAccountId])
194
+
195
+ // "Enviar recibo" flow: auto-open the picker so the user goes straight to the
196
+ // camera/gallery. Fires once per open; harmless if the browser needs a tap.
197
+ useEffect(() => {
198
+ if (open && focusReceipt) {
199
+ const id = window.setTimeout(() => fileInputRef.current?.click(), 250)
200
+ return () => window.clearTimeout(id)
201
+ }
202
+ }, [open, focusReceipt])
203
+
204
+ // Read the selected image into a data URL (mock has no Supabase bucket).
205
+ function onPickReceipt(e: React.ChangeEvent<HTMLInputElement>) {
206
+ const file = e.target.files?.[0]
207
+ // Reset the input so re-picking the same file still fires onChange.
208
+ e.target.value = ''
209
+ if (!file) return
210
+ if (!file.type.startsWith('image/')) {
211
+ toast.error(t('financial.quickTx.receiptInvalid'))
212
+ return
213
+ }
214
+ const reader = new FileReader()
215
+ reader.onload = () => {
216
+ setReceiptUrl(typeof reader.result === 'string' ? reader.result : undefined)
217
+ // TODO(FAY-1230): Claude-vision OCR auto-fill from the receipt image —
218
+ // prefill amount / merchant (description) / category from `reader.result`.
219
+ }
220
+ reader.onerror = () => toast.error(t('financial.quickTx.receiptInvalid'))
221
+ reader.readAsDataURL(file)
222
+ }
223
+
224
+ // Load pickers once when opened.
225
+ useEffect(() => {
226
+ if (!open) return
227
+ let cancelled = false
228
+ void (async () => {
229
+ const [accs, coa] = await Promise.all([
230
+ provider.getBankAccounts(),
231
+ provider.getChartOfAccounts().catch(() => [] as ChartOfAccountsNode[]),
232
+ ])
233
+ if (cancelled) return
234
+ setAccounts(accs)
235
+ setCategories(coa)
236
+ setAccountId((prev) => prev ?? accs[0]?.id)
237
+ })()
238
+ return () => { cancelled = true }
239
+ }, [open, provider])
240
+
241
+ const theme = typeTheme(type)
242
+ const HeaderIcon = theme.icon
243
+
244
+ const accountOptions = useMemo(
245
+ () => accounts.map((a) => ({ id: a.id, name: a.name, hint: a.accountType === 'credit_card' ? t('financial.quickTx.card') : undefined })),
246
+ [accounts, t],
247
+ )
248
+ const categoryOptions = useMemo(
249
+ () => categories.filter((c) => c.isActive).map((c) => ({ id: c.id, name: c.name, hint: c.code })),
250
+ [categories],
251
+ )
252
+
253
+ function onAmountChange(raw: string) {
254
+ // Keep digits + a single separator; store the numeric value.
255
+ const cleaned = raw.replace(/[^\d.,]/g, '').replace(',', '.')
256
+ setAmountStr(raw.replace(/[^\d.,]/g, ''))
257
+ const n = Number.parseFloat(cleaned)
258
+ setAmount(Number.isFinite(n) ? n : 0)
259
+ }
260
+
261
+ const canSave =
262
+ amount > 0 &&
263
+ !saving &&
264
+ (type === 'transfer'
265
+ ? !!accountId && !!toAccountId && accountId !== toAccountId
266
+ : true)
267
+
268
+ async function handleSave() {
269
+ if (!canSave) {
270
+ if (type === 'transfer' && accountId && accountId === toAccountId) {
271
+ toast.error(t('financial.transfer.sameAccount'))
272
+ }
273
+ return
274
+ }
275
+ setSaving(true)
276
+ try {
277
+ await createQuickTransaction({
278
+ type,
279
+ amount,
280
+ date,
281
+ paid: type === 'transfer' ? true : paid,
282
+ description: description || undefined,
283
+ categoryId: type === 'transfer' ? undefined : categoryId,
284
+ bankAccountId: accountId,
285
+ toAccountId: type === 'transfer' ? toAccountId : undefined,
286
+ recurring: type === 'transfer' ? false : recurring,
287
+ receiptUrl: type === 'transfer' ? undefined : receiptUrl,
288
+ })
289
+ onOpenChange(false)
290
+ } catch {
291
+ // toast handled by store
292
+ } finally {
293
+ setSaving(false)
294
+ }
295
+ }
296
+
297
+ const TYPES: Array<{ id: QuickTransactionType; label: string }> = [
298
+ { id: 'expense', label: t('financial.quickTx.expense') },
299
+ { id: 'income', label: t('financial.quickTx.income') },
300
+ { id: 'transfer', label: t('financial.quickTx.transfer') },
301
+ ]
302
+
303
+ return (
304
+ <Modal open={open} onOpenChange={onOpenChange}>
305
+ <ModalContent size="md" noPadding hideClose className="overflow-hidden">
306
+ {/* Context-colored header */}
307
+ <div className={`${theme.header} shrink-0 px-5 pb-5 pt-5`}>
308
+ <div className="flex items-center justify-between">
309
+ <div className="flex items-center gap-2">
310
+ <HeaderIcon className="h-5 w-5" />
311
+ <h2 className="text-sm font-semibold">{t('financial.quickTx.title')}</h2>
312
+ </div>
313
+ <button
314
+ onClick={() => onOpenChange(false)}
315
+ className="flex h-7 w-7 items-center justify-center rounded-full opacity-80 transition-colors hover:bg-black/10 hover:opacity-100"
316
+ aria-label={t('financial.quickTx.close')}
317
+ >
318
+ <X className="h-4 w-4" />
319
+ </button>
320
+ </div>
321
+
322
+ {/* Type segmented control */}
323
+ <div className="mt-4 flex gap-1 rounded-full bg-black/15 p-1">
324
+ {TYPES.map((ty) => (
325
+ <button
326
+ key={ty.id}
327
+ onClick={() => setType(ty.id)}
328
+ className={`flex-1 rounded-full px-2 py-1.5 text-xs font-medium transition-colors ${
329
+ type === ty.id ? 'bg-white/90 text-foreground shadow-sm' : 'opacity-90 hover:bg-white/10'
330
+ }`}
331
+ >
332
+ {ty.label}
333
+ </button>
334
+ ))}
335
+ </div>
336
+
337
+ {/* Big amount input */}
338
+ <div className="mt-5 flex items-baseline justify-center gap-2">
339
+ <span className="text-xl font-medium opacity-90">{currency.symbol}</span>
340
+ <input
341
+ type="text"
342
+ inputMode="decimal"
343
+ autoFocus
344
+ value={amountStr}
345
+ onChange={(e) => onAmountChange(e.target.value)}
346
+ onKeyDown={(e) => { if (e.key === 'Enter') void handleSave() }}
347
+ placeholder="0,00"
348
+ className="w-full max-w-[220px] bg-transparent text-center text-4xl font-bold tracking-tight outline-none placeholder:opacity-40"
349
+ />
350
+ </div>
351
+ </div>
352
+
353
+ {/* Body */}
354
+ <div className="flex-1 space-y-4 overflow-y-auto px-5 py-5">
355
+ {/* Paid / not paid (hidden for transfers — always settled) */}
356
+ {type !== 'transfer' && (
357
+ <div className="flex items-center justify-between rounded-input border border-input bg-card px-3 py-2.5 shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)]">
358
+ <span className="text-sm font-medium">
359
+ {type === 'income' ? t('financial.quickTx.received') : t('financial.quickTx.paid')}
360
+ </span>
361
+ <div className="flex gap-1">
362
+ <button
363
+ onClick={() => setPaid(true)}
364
+ className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${paid ? 'bg-success text-success-foreground' : 'bg-muted text-muted-foreground'}`}
365
+ >
366
+ {t('financial.quickTx.yes')}
367
+ </button>
368
+ <button
369
+ onClick={() => setPaid(false)}
370
+ className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${!paid ? 'bg-warning text-warning-foreground' : 'bg-muted text-muted-foreground'}`}
371
+ >
372
+ {t('financial.quickTx.no')}
373
+ </button>
374
+ </div>
375
+ </div>
376
+ )}
377
+
378
+ {/* Date */}
379
+ <div>
380
+ <span className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
381
+ <CalendarDays className="h-3.5 w-3.5" /> {t('financial.invoiceForm.date')}
382
+ </span>
383
+ <DatePicker value={date} onChange={setDate} />
384
+ </div>
385
+
386
+ {/* Description */}
387
+ <div>
388
+ <span className="text-xs font-medium text-muted-foreground">{t('financial.invoiceForm.description')}</span>
389
+ <input
390
+ type="text"
391
+ value={description}
392
+ onChange={(e) => setDescription(e.target.value)}
393
+ onKeyDown={(e) => { if (e.key === 'Enter') void handleSave() }}
394
+ placeholder={t('financial.quickTx.descriptionPlaceholder')}
395
+ className="mt-1 w-full rounded-input border border-input bg-card px-3 py-2.5 text-sm shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] outline-none focus:ring-2 focus:ring-ring"
396
+ />
397
+ </div>
398
+
399
+ {/* FAY-1226 receipt attachment — snap/attach an image (data URL in mock).
400
+ Hidden picker; `capture=environment` makes mobile offer the camera. */}
401
+ {type !== 'transfer' && (
402
+ <div>
403
+ <span className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
404
+ <Paperclip className="h-3.5 w-3.5" /> {t('financial.quickTx.receipt')}
405
+ </span>
406
+ <input
407
+ ref={fileInputRef}
408
+ type="file"
409
+ accept="image/*"
410
+ capture="environment"
411
+ className="hidden"
412
+ onChange={onPickReceipt}
413
+ />
414
+ {receiptUrl ? (
415
+ <div className="flex items-center gap-3 rounded-input border border-input bg-card p-2 shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)]">
416
+ <img
417
+ src={receiptUrl}
418
+ alt={t('financial.quickTx.receipt')}
419
+ className="h-16 w-16 shrink-0 rounded-md border object-cover"
420
+ />
421
+ <div className="min-w-0 flex-1">
422
+ <p className="truncate text-sm font-medium">{t('financial.quickTx.receiptAttached')}</p>
423
+ <button
424
+ type="button"
425
+ onClick={() => fileInputRef.current?.click()}
426
+ className="text-xs text-primary hover:underline"
427
+ >
428
+ {t('financial.quickTx.receiptReplace')}
429
+ </button>
430
+ </div>
431
+ <button
432
+ type="button"
433
+ onClick={() => setReceiptUrl(undefined)}
434
+ aria-label={t('financial.quickTx.receiptRemove')}
435
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted"
436
+ >
437
+ <X className="h-4 w-4" />
438
+ </button>
439
+ </div>
440
+ ) : (
441
+ <button
442
+ type="button"
443
+ onClick={() => fileInputRef.current?.click()}
444
+ className={`flex w-full items-center justify-center gap-2 rounded-input border border-dashed px-3 py-3 text-sm font-medium transition-colors hover:bg-muted/30 ${
445
+ focusReceipt ? 'border-primary text-primary ring-1 ring-primary/30' : 'border-input text-muted-foreground'
446
+ }`}
447
+ >
448
+ <Camera className="h-4 w-4" /> {t('financial.quickTx.receiptCapture')}
449
+ </button>
450
+ )}
451
+ </div>
452
+ )}
453
+
454
+ {type === 'transfer' ? (
455
+ <div className="grid grid-cols-1 gap-3">
456
+ <ChipSelect
457
+ icon={Wallet}
458
+ label={t('financial.quickTx.fromAccount')}
459
+ value={accountId}
460
+ options={accountOptions}
461
+ onChange={setAccountId}
462
+ emptyLabel={t('financial.quickTx.selectAccount')}
463
+ />
464
+ <ChipSelect
465
+ icon={Wallet}
466
+ label={t('financial.quickTx.toAccount')}
467
+ value={toAccountId}
468
+ options={accountOptions.filter((o) => o.id !== accountId)}
469
+ onChange={setToAccountId}
470
+ emptyLabel={t('financial.quickTx.selectAccount')}
471
+ />
472
+ </div>
473
+ ) : (
474
+ <div className="grid grid-cols-1 gap-3">
475
+ <ChipSelect
476
+ icon={Tag}
477
+ label={t('financial.quickTx.category')}
478
+ value={categoryId}
479
+ options={categoryOptions}
480
+ onChange={setCategoryId}
481
+ emptyLabel={t('financial.quickTx.selectCategory')}
482
+ />
483
+ <ChipSelect
484
+ icon={Wallet}
485
+ label={t('financial.quickTx.account')}
486
+ value={accountId}
487
+ options={accountOptions}
488
+ onChange={setAccountId}
489
+ emptyLabel={t('financial.quickTx.selectAccount')}
490
+ />
491
+ </div>
492
+ )}
493
+
494
+ {/* Recurring + attachment (Anexo now opens the receipt picker — FAY-1226) */}
495
+ {type !== 'transfer' && (
496
+ <div className="flex flex-wrap gap-2">
497
+ <TogglePill
498
+ icon={Repeat}
499
+ label={t('financial.quickTx.recurring')}
500
+ checked={recurring}
501
+ onChange={setRecurring}
502
+ />
503
+ <button
504
+ type="button"
505
+ onClick={() => fileInputRef.current?.click()}
506
+ title={t('financial.quickTx.attachment')}
507
+ className={`flex items-center gap-2 rounded-full border px-3 py-2 text-xs font-medium transition-colors ${
508
+ receiptUrl
509
+ ? 'border-transparent bg-primary text-primary-foreground'
510
+ : 'border-input bg-card text-muted-foreground hover:bg-muted/40'
511
+ }`}
512
+ >
513
+ <Paperclip className="h-3.5 w-3.5" /> {t('financial.quickTx.attachment')}
514
+ {receiptUrl && <Check className="h-3.5 w-3.5" />}
515
+ </button>
516
+ </div>
517
+ )}
518
+ </div>
519
+
520
+ {/* Footer */}
521
+ <div className="flex shrink-0 items-center gap-3 border-t px-5 py-3">
522
+ <span className="text-xs text-muted-foreground">
523
+ {amount > 0 ? formatCurrency(amount, currency) : t('financial.quickTx.enterAmount')}
524
+ </span>
525
+ <Button
526
+ className="ml-auto"
527
+ onClick={() => void handleSave()}
528
+ disabled={!canSave}
529
+ >
530
+ <Check className="h-4 w-4" />
531
+ {saving ? t('financial.quickTx.saving') : t('financial.quickTx.save')}
532
+ </Button>
533
+ </div>
534
+ </ModalContent>
535
+ </Modal>
536
+ )
537
+ }