@fayz-ai/plugin-financial 0.1.6 → 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
|
@@ -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
|
+
}
|