@fayz-ai/plugin-financial 0.1.2 → 0.1.4
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/LICENSE +21 -0
- 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/components/PaymentModal.d.ts.map +1 -1
- package/dist/components/PersonStatementWidget.d.ts +18 -0
- package/dist/components/PersonStatementWidget.d.ts.map +1 -0
- package/dist/components/TransferModal.d.ts +7 -0
- package/dist/components/TransferModal.d.ts.map +1 -0
- package/dist/contract.test.d.ts +2 -0
- package/dist/contract.test.d.ts.map +1 -0
- package/dist/data/capability.test.d.ts +2 -0
- package/dist/data/capability.test.d.ts.map +1 -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 +13 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +1529 -263
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1437 -171
- 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/registries.d.ts.map +1 -1
- package/dist/store.d.ts +4 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +111 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/InvoiceDetailView.d.ts +3 -1
- package/dist/views/InvoiceDetailView.d.ts.map +1 -1
- package/dist/views/InvoiceFormView.d.ts.map +1 -1
- package/dist/views/PayablesView.d.ts +5 -1
- package/dist/views/PayablesView.d.ts.map +1 -1
- package/dist/views/ReceivablesView.d.ts +5 -1
- package/dist/views/ReceivablesView.d.ts.map +1 -1
- package/dist/views/ReconciliationView.d.ts +3 -0
- package/dist/views/ReconciliationView.d.ts.map +1 -0
- package/dist/views/StatementsView.d.ts +3 -1
- package/dist/views/StatementsView.d.ts.map +1 -1
- package/dist/views/navReferrer.d.ts +5 -0
- package/dist/views/navReferrer.d.ts.map +1 -0
- package/package.json +8 -5
- package/src/FinancialContext.tsx +2 -0
- package/src/FinancialPage.tsx +32 -17
- package/src/components/PaymentModal.tsx +58 -11
- package/src/components/PersonStatementWidget.tsx +133 -0
- package/src/components/TransferModal.tsx +150 -0
- package/src/contract.test.ts +58 -0
- package/src/data/capability.test.ts +90 -0
- package/src/data/mock.ts +265 -52
- package/src/data/supabase.ts +359 -30
- package/src/data/types.ts +22 -3
- package/src/index.ts +28 -2
- package/src/locales/en.ts +76 -0
- package/src/locales/pt-BR.ts +76 -0
- package/src/migrations/006_extract_fee_amount.sql +20 -0
- package/src/migrations/006_rls_policies.sql +37 -0
- package/src/migrations/007_reconciliation.sql +32 -0
- package/src/migrations/008_split_payment_movements.sql +29 -0
- package/src/registries.ts +3 -1
- package/src/store.ts +27 -7
- package/src/types.ts +126 -1
- package/src/views/InvoiceDetailView.tsx +70 -51
- package/src/views/InvoiceFormView.tsx +55 -20
- package/src/views/PayablesView.tsx +13 -2
- package/src/views/ReceivablesView.tsx +13 -2
- package/src/views/ReconciliationView.tsx +211 -0
- package/src/views/StatementsView.tsx +241 -29
- package/src/views/navReferrer.ts +24 -0
|
@@ -20,15 +20,19 @@ const STATUS_COLORS: Record<string, { bg: string; icon: React.ElementType; label
|
|
|
20
20
|
cancelled: { bg: 'bg-muted text-muted-foreground', icon: Ban, labelKey: 'financial.invoice.statusCancelled' },
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
|
|
23
|
+
export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel }: {
|
|
24
24
|
invoiceId: string
|
|
25
25
|
direction: TransactionDirection
|
|
26
26
|
onBack: () => void
|
|
27
27
|
onEdit: () => void
|
|
28
|
+
/** Label for the back link — defaults to the invoice's parent list (payables/receivables). */
|
|
29
|
+
backLabel?: string
|
|
28
30
|
}) {
|
|
29
31
|
const t = useTranslation()
|
|
30
32
|
const config = useFinancialConfig()
|
|
31
33
|
const { currency } = config
|
|
34
|
+
// Back link target: where the user actually came from, else the parent list.
|
|
35
|
+
const parentLabel = backLabel ?? (direction === 'debit' ? t('financial.nav.payables') : t('financial.nav.receivables'))
|
|
32
36
|
const provider = useFinancialProvider()
|
|
33
37
|
const paymentMethods = useFinancialStore((s) => s.paymentMethods)
|
|
34
38
|
const paymentMethodTypes = useFinancialStore((s) => s.paymentMethodTypes)
|
|
@@ -41,6 +45,7 @@ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
|
|
|
41
45
|
const [invoice, setInvoice] = useState<Invoice | null>(null)
|
|
42
46
|
const [items, setItems] = useState<InvoiceItem[]>([])
|
|
43
47
|
const [movements, setMovements] = useState<FinancialMovement[]>([])
|
|
48
|
+
const [payments, setPayments] = useState<FinancialMovement[]>([])
|
|
44
49
|
const [loading, setLoading] = useState(true)
|
|
45
50
|
const [payingMovement, setPayingMovement] = useState<FinancialMovement | null>(null)
|
|
46
51
|
const [expandedMovId, setExpandedMovId] = useState<string | null>(null)
|
|
@@ -64,6 +69,12 @@ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
|
|
|
64
69
|
.filter((m) => m.invoiceId === invoiceId && m.movementKind === 'bill')
|
|
65
70
|
.sort((a, b) => (a.installmentNumber ?? 0) - (b.installmentNumber ?? 0))
|
|
66
71
|
)
|
|
72
|
+
// Individual cash events (one row per payment) — listed under each installment.
|
|
73
|
+
setPayments(
|
|
74
|
+
movResult.data
|
|
75
|
+
.filter((m) => m.invoiceId === invoiceId && m.movementKind === 'payment')
|
|
76
|
+
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''))
|
|
77
|
+
)
|
|
67
78
|
setLoading(false)
|
|
68
79
|
}
|
|
69
80
|
|
|
@@ -110,7 +121,7 @@ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
|
|
|
110
121
|
if (loading) {
|
|
111
122
|
return (
|
|
112
123
|
<div className="space-y-5">
|
|
113
|
-
<SubpageHeader title="" onBack={onBack} parentLabel={
|
|
124
|
+
<SubpageHeader title="" onBack={onBack} parentLabel={parentLabel} />
|
|
114
125
|
|
|
115
126
|
{/* Invoice card skeleton */}
|
|
116
127
|
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
|
@@ -187,7 +198,7 @@ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
|
|
|
187
198
|
if (!invoice) {
|
|
188
199
|
return (
|
|
189
200
|
<div className="space-y-6">
|
|
190
|
-
<SubpageHeader title={t('financial.invoice.notFound')} onBack={onBack} parentLabel={
|
|
201
|
+
<SubpageHeader title={t('financial.invoice.notFound')} onBack={onBack} parentLabel={parentLabel} />
|
|
191
202
|
<p className="text-sm text-muted-foreground">{t('financial.invoice.notFoundDesc')}</p>
|
|
192
203
|
</div>
|
|
193
204
|
)
|
|
@@ -234,7 +245,7 @@ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
|
|
|
234
245
|
<SubpageHeader
|
|
235
246
|
title={invoice.fiscalNumber ? `#${invoice.fiscalNumber}` : invoice.contactName ?? invoice.invoiceDate}
|
|
236
247
|
onBack={onBack}
|
|
237
|
-
parentLabel={
|
|
248
|
+
parentLabel={parentLabel}
|
|
238
249
|
actions={
|
|
239
250
|
<div className="flex items-center gap-1.5">
|
|
240
251
|
{invoice.status !== 'cancelled' && invoice.status !== 'paid' && (
|
|
@@ -410,10 +421,8 @@ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
|
|
|
410
421
|
const movStatus = STATUS_COLORS[mov.status] ?? STATUS_COLORS.pending
|
|
411
422
|
const MovIcon = movStatus.icon
|
|
412
423
|
|
|
413
|
-
//
|
|
414
|
-
const
|
|
415
|
-
const methodType = paymentMethodTypes.find((t) => t.id === (method?.paymentMethodTypeId ?? mov.paymentMethodTypeId))
|
|
416
|
-
const account = bankAccounts.find((a) => a.id === mov.bankAccountId)
|
|
424
|
+
// Cash events recorded against this installment (one per payment).
|
|
425
|
+
const instPayments = payments.filter((p) => (p.installmentNumber ?? null) === (mov.installmentNumber ?? null))
|
|
417
426
|
|
|
418
427
|
return (
|
|
419
428
|
<div key={mov.id}>
|
|
@@ -471,50 +480,60 @@ export function InvoiceDetailView({ invoiceId, direction, onBack, onEdit }: {
|
|
|
471
480
|
<div className="h-3 w-16 rounded bg-muted/30 animate-pulse" />
|
|
472
481
|
<div className="h-3 w-24 rounded bg-muted/40 animate-pulse" />
|
|
473
482
|
</div>
|
|
483
|
+
) : instPayments.length === 0 ? (
|
|
484
|
+
<div className="text-[11px] text-muted-foreground">{t('financial.invoice.noDetails')}</div>
|
|
474
485
|
) : (
|
|
475
|
-
<div className="
|
|
476
|
-
{
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
486
|
+
<div className="space-y-1">
|
|
487
|
+
{instPayments.map((p) => {
|
|
488
|
+
const pMethod = paymentMethods.find((m) => m.id === p.paymentMethodId)
|
|
489
|
+
const pType = paymentMethodTypes.find((tp) => tp.id === (pMethod?.paymentMethodTypeId ?? p.paymentMethodTypeId))
|
|
490
|
+
const pAccount = bankAccounts.find((a) => a.id === p.bankAccountId)
|
|
491
|
+
return (
|
|
492
|
+
<div key={p.id} className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-muted-foreground">
|
|
493
|
+
<span className="font-medium text-success">{formatCurrency(p.paidAmount, currency)}</span>
|
|
494
|
+
{pType && (
|
|
495
|
+
<>
|
|
496
|
+
<span>·</span>
|
|
497
|
+
<span className="font-medium text-foreground">{pType.name}</span>
|
|
498
|
+
{pMethod && <span>({pMethod.name})</span>}
|
|
499
|
+
</>
|
|
500
|
+
)}
|
|
501
|
+
{p.cardBrand && (
|
|
502
|
+
<>
|
|
503
|
+
<span>·</span>
|
|
504
|
+
<CreditCard className="h-3 w-3 shrink-0" />
|
|
505
|
+
<span className="font-medium text-foreground">{p.cardBrand}</span>
|
|
506
|
+
{p.cardInstallments && <span>{p.cardInstallments}x</span>}
|
|
507
|
+
</>
|
|
508
|
+
)}
|
|
509
|
+
{pAccount && (
|
|
510
|
+
<>
|
|
511
|
+
<span>·</span>
|
|
512
|
+
<Building2 className="h-3 w-3 shrink-0" />
|
|
513
|
+
<span>{pAccount.name}</span>
|
|
514
|
+
</>
|
|
515
|
+
)}
|
|
516
|
+
{(p.feeAmount ?? 0) > 0 && (
|
|
517
|
+
<>
|
|
518
|
+
<span>·</span>
|
|
519
|
+
<span className="text-warning">{t('financial.statements.columnFee')} {formatCurrency(p.feeAmount ?? 0, currency)}</span>
|
|
520
|
+
</>
|
|
521
|
+
)}
|
|
522
|
+
{p.paymentDate && (
|
|
523
|
+
<>
|
|
524
|
+
<span>·</span>
|
|
525
|
+
<span>{p.paymentDate}</span>
|
|
526
|
+
</>
|
|
527
|
+
)}
|
|
528
|
+
{p.notes && (
|
|
529
|
+
<>
|
|
530
|
+
<span>·</span>
|
|
531
|
+
<span className="italic truncate max-w-[200px]">{p.notes}</span>
|
|
532
|
+
</>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
)
|
|
536
|
+
})}
|
|
518
537
|
</div>
|
|
519
538
|
)}
|
|
520
539
|
</div>
|
|
@@ -122,7 +122,7 @@ interface FormItem extends CreateInvoiceItemInput {
|
|
|
122
122
|
_id: string
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdate, onRemove, entityLookups }: {
|
|
125
|
+
function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdate, onRemove, entityLookups, showTypeSelector }: {
|
|
126
126
|
item: FormItem
|
|
127
127
|
index: number
|
|
128
128
|
itemTypes: Array<{ value: string; label: string; icon?: string }>
|
|
@@ -132,6 +132,8 @@ function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdat
|
|
|
132
132
|
onUpdate: (data: Partial<FormItem>) => void
|
|
133
133
|
onRemove: () => void
|
|
134
134
|
entityLookups: EntityLookupMap
|
|
135
|
+
/** Whether to show the per-row Service/Product/Other type pills. Hidden when no item type is lookup-backed. */
|
|
136
|
+
showTypeSelector: boolean
|
|
135
137
|
}) {
|
|
136
138
|
const t = useTranslation()
|
|
137
139
|
const total = item.quantity * item.unitPrice - (item.discount ?? 0) + (item.surcharge ?? 0)
|
|
@@ -139,6 +141,14 @@ function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdat
|
|
|
139
141
|
const hasAdjustments = (item.discount ?? 0) > 0 || (item.surcharge ?? 0) > 0
|
|
140
142
|
const [showAdjustments, setShowAdjustments] = useState(hasAdjustments)
|
|
141
143
|
|
|
144
|
+
// Enter in any of the row's edit fields commits the row (same as "Done").
|
|
145
|
+
function handleFieldKeyDown(e: React.KeyboardEvent) {
|
|
146
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
147
|
+
e.preventDefault()
|
|
148
|
+
onToggle()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
return (
|
|
143
153
|
<div className="border-b last:border-0">
|
|
144
154
|
{/* Compact row — always visible */}
|
|
@@ -166,22 +176,24 @@ function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdat
|
|
|
166
176
|
{/* Expanded detail — edit fields */}
|
|
167
177
|
{expanded && (
|
|
168
178
|
<div className="px-4 pb-4 pt-1 ml-8 space-y-3 bg-muted/10">
|
|
169
|
-
{/* Type selector */}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
179
|
+
{/* Type selector — hidden when no item type is lookup-backed (manual-only entry) */}
|
|
180
|
+
{showTypeSelector && (
|
|
181
|
+
<div className="flex gap-1.5">
|
|
182
|
+
{itemTypes.map((tp) => (
|
|
183
|
+
<button
|
|
184
|
+
key={tp.value}
|
|
185
|
+
onClick={() => onUpdate({ itemKind: tp.value })}
|
|
186
|
+
className={`rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors ${
|
|
187
|
+
item.itemKind === tp.value
|
|
188
|
+
? 'bg-primary text-primary-foreground'
|
|
189
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
|
190
|
+
}`}
|
|
191
|
+
>
|
|
192
|
+
{tp.label}
|
|
193
|
+
</button>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
185
197
|
|
|
186
198
|
{/* Main fields */}
|
|
187
199
|
<div className="grid gap-3 grid-cols-12">
|
|
@@ -225,6 +237,7 @@ function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdat
|
|
|
225
237
|
type="text"
|
|
226
238
|
value={item.description}
|
|
227
239
|
onChange={(e) => onUpdate({ description: e.target.value })}
|
|
240
|
+
onKeyDown={handleFieldKeyDown}
|
|
228
241
|
placeholder={t('financial.invoiceForm.itemDescription')}
|
|
229
242
|
autoFocus
|
|
230
243
|
className="w-full mt-0.5 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
@@ -239,10 +252,11 @@ function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdat
|
|
|
239
252
|
min={1}
|
|
240
253
|
value={item.quantity}
|
|
241
254
|
onChange={(e) => onUpdate({ quantity: Number(e.target.value) || 1 })}
|
|
255
|
+
onKeyDown={handleFieldKeyDown}
|
|
242
256
|
className="w-full mt-0.5 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2 py-1.5 text-sm text-center focus:outline-none focus:ring-2 focus:ring-ring"
|
|
243
257
|
/>
|
|
244
258
|
</div>
|
|
245
|
-
<div className="col-span-4">
|
|
259
|
+
<div className="col-span-4" onKeyDown={handleFieldKeyDown}>
|
|
246
260
|
<CurrencyInput
|
|
247
261
|
label={t('financial.invoiceForm.unitPrice')}
|
|
248
262
|
value={item.unitPrice}
|
|
@@ -274,6 +288,7 @@ function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdat
|
|
|
274
288
|
step={0.01}
|
|
275
289
|
value={item.discount ?? 0}
|
|
276
290
|
onChange={(e) => onUpdate({ discount: Number(e.target.value) || 0 })}
|
|
291
|
+
onKeyDown={handleFieldKeyDown}
|
|
277
292
|
className="flex-1 bg-transparent px-2 py-1 text-xs outline-none"
|
|
278
293
|
/>
|
|
279
294
|
</div>
|
|
@@ -288,6 +303,7 @@ function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdat
|
|
|
288
303
|
step={0.01}
|
|
289
304
|
value={item.surcharge ?? 0}
|
|
290
305
|
onChange={(e) => onUpdate({ surcharge: Number(e.target.value) || 0 })}
|
|
306
|
+
onKeyDown={handleFieldKeyDown}
|
|
291
307
|
className="flex-1 bg-transparent px-2 py-1 text-xs outline-none"
|
|
292
308
|
/>
|
|
293
309
|
</div>
|
|
@@ -374,6 +390,16 @@ export function InvoiceFormView({ direction, editId, onSaved }: {
|
|
|
374
390
|
const config = useFinancialConfig()
|
|
375
391
|
const { currency, itemTypes, locations } = config
|
|
376
392
|
const provider = useFinancialProvider()
|
|
393
|
+
|
|
394
|
+
// Whether any item type is backed by an entity lookup (product/service catalog).
|
|
395
|
+
// When none are, item entry is manual-only: skip the type-select step and add
|
|
396
|
+
// an "Other" line directly so the user can type the description right away.
|
|
397
|
+
const hasItemLookups = itemTypes.some((tp) => config.entityLookups[tp.value])
|
|
398
|
+
const defaultItemKind =
|
|
399
|
+
itemTypes.find((tp) => tp.value === 'other')?.value ??
|
|
400
|
+
itemTypes.find((tp) => !config.entityLookups[tp.value])?.value ??
|
|
401
|
+
itemTypes[0]?.value ??
|
|
402
|
+
'other'
|
|
377
403
|
const createInvoice = useFinancialStore((s) => s.createInvoice)
|
|
378
404
|
const hasLocations = locations.length > 0
|
|
379
405
|
const isEdit = !!editId
|
|
@@ -504,6 +530,14 @@ export function InvoiceFormView({ direction, editId, onSaved }: {
|
|
|
504
530
|
setAddingItem(false)
|
|
505
531
|
}
|
|
506
532
|
|
|
533
|
+
// Entry point for the "Add Item" / "Add your first item" buttons. With
|
|
534
|
+
// lookup-backed types we ask which type first; otherwise we add an "Other"
|
|
535
|
+
// line straight away so the user types directly.
|
|
536
|
+
function startAddItem() {
|
|
537
|
+
if (hasItemLookups) setAddingItem(true)
|
|
538
|
+
else addItemOfKind(defaultItemKind)
|
|
539
|
+
}
|
|
540
|
+
|
|
507
541
|
function updateItem(id: string, data: Partial<FormItem>) {
|
|
508
542
|
setItems(items.map((it) => it._id === id ? { ...it, ...data } : it))
|
|
509
543
|
}
|
|
@@ -737,7 +771,7 @@ export function InvoiceFormView({ direction, editId, onSaved }: {
|
|
|
737
771
|
<h3 className="text-sm font-semibold">{t('financial.invoiceForm.items')}</h3>
|
|
738
772
|
</div>
|
|
739
773
|
<button
|
|
740
|
-
onClick={
|
|
774
|
+
onClick={startAddItem}
|
|
741
775
|
className="inline-flex items-center gap-1.5 rounded-lg bg-primary border border-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors"
|
|
742
776
|
>
|
|
743
777
|
<Plus className="h-3 w-3" /> {t('financial.invoiceForm.addItem')}
|
|
@@ -760,7 +794,7 @@ export function InvoiceFormView({ direction, editId, onSaved }: {
|
|
|
760
794
|
<div className="py-10 text-center">
|
|
761
795
|
<LayoutList className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
|
|
762
796
|
<p className="text-sm text-muted-foreground">{t('financial.invoiceForm.noItems')}</p>
|
|
763
|
-
<button onClick={
|
|
797
|
+
<button onClick={startAddItem} className="text-xs text-primary hover:underline mt-1">
|
|
764
798
|
{t('financial.invoiceForm.addFirstItem')}
|
|
765
799
|
</button>
|
|
766
800
|
</div>
|
|
@@ -777,6 +811,7 @@ export function InvoiceFormView({ direction, editId, onSaved }: {
|
|
|
777
811
|
onUpdate={(data) => updateItem(item._id, data)}
|
|
778
812
|
onRemove={() => { removeItem(item._id); if (expandedItemId === item._id) setExpandedItemId(null) }}
|
|
779
813
|
entityLookups={config.entityLookups}
|
|
814
|
+
showTypeSelector={hasItemLookups}
|
|
780
815
|
/>
|
|
781
816
|
))
|
|
782
817
|
)}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
2
3
|
import { InvoiceListView } from './InvoiceListView'
|
|
3
4
|
import { InvoiceFormView } from './InvoiceFormView'
|
|
4
5
|
import { InvoiceDetailView } from './InvoiceDetailView'
|
|
6
|
+
import { referrerLabel } from './navReferrer'
|
|
5
7
|
import type { ViewIntent } from '../FinancialPage'
|
|
6
8
|
|
|
7
|
-
export function PayablesView({ intent, onNavigate }: {
|
|
9
|
+
export function PayablesView({ intent, onNavigate, previousView, back }: {
|
|
8
10
|
intent: ViewIntent
|
|
9
11
|
onNavigate: (view: string) => void
|
|
12
|
+
/** The in-module view the user came from — drives the context-aware back link. */
|
|
13
|
+
previousView?: string | null
|
|
14
|
+
/** Return to the actual previous page (falls back to the given view). */
|
|
15
|
+
back?: (fallbackView?: string) => void
|
|
10
16
|
}) {
|
|
17
|
+
const t = useTranslation()
|
|
18
|
+
|
|
11
19
|
if (intent.mode === 'new') {
|
|
12
20
|
return <InvoiceFormView direction="debit" onSaved={(id) => onNavigate(id ? `payables-detail:${id}` : 'payables-list')} />
|
|
13
21
|
}
|
|
@@ -17,11 +25,14 @@ export function PayablesView({ intent, onNavigate }: {
|
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
if (intent.mode === 'detail' && intent.editId) {
|
|
28
|
+
// Back to where the user actually came from (e.g. statements), else the list.
|
|
29
|
+
const prevLabel = referrerLabel(previousView, t)
|
|
20
30
|
return (
|
|
21
31
|
<InvoiceDetailView
|
|
22
32
|
invoiceId={intent.editId}
|
|
23
33
|
direction="debit"
|
|
24
|
-
|
|
34
|
+
backLabel={prevLabel ?? undefined}
|
|
35
|
+
onBack={prevLabel && back ? () => back('payables-list') : () => onNavigate('payables-list')}
|
|
25
36
|
onEdit={() => onNavigate(`payables-edit:${intent.editId}`)}
|
|
26
37
|
/>
|
|
27
38
|
)
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
2
3
|
import { InvoiceListView } from './InvoiceListView'
|
|
3
4
|
import { InvoiceFormView } from './InvoiceFormView'
|
|
4
5
|
import { InvoiceDetailView } from './InvoiceDetailView'
|
|
6
|
+
import { referrerLabel } from './navReferrer'
|
|
5
7
|
import type { ViewIntent } from '../FinancialPage'
|
|
6
8
|
|
|
7
|
-
export function ReceivablesView({ intent, onNavigate }: {
|
|
9
|
+
export function ReceivablesView({ intent, onNavigate, previousView, back }: {
|
|
8
10
|
intent: ViewIntent
|
|
9
11
|
onNavigate: (view: string) => void
|
|
12
|
+
/** The in-module view the user came from — drives the context-aware back link. */
|
|
13
|
+
previousView?: string | null
|
|
14
|
+
/** Return to the actual previous page (falls back to the given view). */
|
|
15
|
+
back?: (fallbackView?: string) => void
|
|
10
16
|
}) {
|
|
17
|
+
const t = useTranslation()
|
|
18
|
+
|
|
11
19
|
if (intent.mode === 'new') {
|
|
12
20
|
return <InvoiceFormView direction="credit" onSaved={(id) => onNavigate(id ? `receivables-detail:${id}` : 'receivables-list')} />
|
|
13
21
|
}
|
|
@@ -17,11 +25,14 @@ export function ReceivablesView({ intent, onNavigate }: {
|
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
if (intent.mode === 'detail' && intent.editId) {
|
|
28
|
+
// Back to where the user actually came from (e.g. statements), else the list.
|
|
29
|
+
const prevLabel = referrerLabel(previousView, t)
|
|
20
30
|
return (
|
|
21
31
|
<InvoiceDetailView
|
|
22
32
|
invoiceId={intent.editId}
|
|
23
33
|
direction="credit"
|
|
24
|
-
|
|
34
|
+
backLabel={prevLabel ?? undefined}
|
|
35
|
+
onBack={prevLabel && back ? () => back('receivables-list') : () => onNavigate('receivables-list')}
|
|
25
36
|
onEdit={() => onNavigate(`receivables-edit:${intent.editId}`)}
|
|
26
37
|
/>
|
|
27
38
|
)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import React, { useEffect, useState, useCallback } from 'react'
|
|
2
|
+
import { Landmark, RefreshCw, Check, Link2, Sparkles, ArrowDownCircle, ArrowUpCircle } from 'lucide-react'
|
|
3
|
+
import { Button, toast } from '@fayz-ai/ui'
|
|
4
|
+
import { useFinancialConfig, useFinancialProvider, formatCurrency } from '../FinancialContext'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
import type { FinancialMovement, ReconciliationCandidate } from '../types'
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Reconciliation (conciliação) — match imported bank lines to internal movements
|
|
10
|
+
//
|
|
11
|
+
// Imported bank-statement lines live in financial_movements tagged with an
|
|
12
|
+
// externalSource. This screen lets the operator link each pending line to the
|
|
13
|
+
// internal movement it settles (suggested by amount + date proximity), or
|
|
14
|
+
// accept it standalone. Reusable across apps — fed by any bank connector that
|
|
15
|
+
// imports through the financial provider's reconciliation methods.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export function ReconciliationView() {
|
|
19
|
+
const t = useTranslation()
|
|
20
|
+
const { currency } = useFinancialConfig()
|
|
21
|
+
const provider = useFinancialProvider()
|
|
22
|
+
const fmt = (n: number) => formatCurrency(n, currency)
|
|
23
|
+
|
|
24
|
+
const [lines, setLines] = useState<FinancialMovement[]>([])
|
|
25
|
+
const [loading, setLoading] = useState(true)
|
|
26
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
27
|
+
const [candidates, setCandidates] = useState<ReconciliationCandidate[]>([])
|
|
28
|
+
const [candidatesLoading, setCandidatesLoading] = useState(false)
|
|
29
|
+
const [busy, setBusy] = useState(false)
|
|
30
|
+
|
|
31
|
+
const supported = typeof provider.getUnreconciled === 'function'
|
|
32
|
+
|
|
33
|
+
const load = useCallback(async () => {
|
|
34
|
+
if (!provider.getUnreconciled) return
|
|
35
|
+
setLoading(true)
|
|
36
|
+
try {
|
|
37
|
+
const data = await provider.getUnreconciled()
|
|
38
|
+
setLines(data)
|
|
39
|
+
// Keep the selection valid; otherwise drop the right panel.
|
|
40
|
+
setSelectedId((prev) => (prev && data.some((l) => l.id === prev) ? prev : null))
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false)
|
|
43
|
+
}
|
|
44
|
+
}, [provider])
|
|
45
|
+
|
|
46
|
+
useEffect(() => { void load() }, [load])
|
|
47
|
+
|
|
48
|
+
// Load suggestions whenever the selected line changes.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!selectedId || !provider.suggestReconciliation) { setCandidates([]); return }
|
|
51
|
+
let cancelled = false
|
|
52
|
+
setCandidatesLoading(true)
|
|
53
|
+
provider.suggestReconciliation(selectedId)
|
|
54
|
+
.then((c) => { if (!cancelled) setCandidates(c) })
|
|
55
|
+
.finally(() => { if (!cancelled) setCandidatesLoading(false) })
|
|
56
|
+
return () => { cancelled = true }
|
|
57
|
+
}, [selectedId, provider])
|
|
58
|
+
|
|
59
|
+
async function match(bankMovementId: string, matchedMovementId?: string) {
|
|
60
|
+
if (!provider.reconcileMovement) return
|
|
61
|
+
setBusy(true)
|
|
62
|
+
try {
|
|
63
|
+
await provider.reconcileMovement({ bankMovementId, matchedMovementId })
|
|
64
|
+
toast.success(t('financial.reconciliation.match'))
|
|
65
|
+
await load()
|
|
66
|
+
} catch {
|
|
67
|
+
toast.error(t('common.error'))
|
|
68
|
+
} finally {
|
|
69
|
+
setBusy(false)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!supported) {
|
|
74
|
+
return (
|
|
75
|
+
<div className="py-16 text-center">
|
|
76
|
+
<Landmark className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
|
|
77
|
+
<p className="text-sm text-muted-foreground">{t('financial.reconciliation.empty')}</p>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const selected = lines.find((l) => l.id === selectedId) ?? null
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="space-y-4">
|
|
86
|
+
{/* Header */}
|
|
87
|
+
<div className="flex items-start justify-between gap-3">
|
|
88
|
+
<div>
|
|
89
|
+
<h2 className="text-lg font-semibold tracking-tight">{t('financial.reconciliation.title')}</h2>
|
|
90
|
+
<p className="text-sm text-muted-foreground">{t('financial.reconciliation.subtitle')}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex items-center gap-2">
|
|
93
|
+
<span className="text-xs text-muted-foreground">
|
|
94
|
+
{lines.length > 0
|
|
95
|
+
? t('financial.reconciliation.pending', { count: lines.length })
|
|
96
|
+
: t('financial.reconciliation.allDone')}
|
|
97
|
+
</span>
|
|
98
|
+
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading || busy}>
|
|
99
|
+
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
100
|
+
{t('financial.reconciliation.refresh')}
|
|
101
|
+
</Button>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{lines.length === 0 && !loading ? (
|
|
106
|
+
<div className="rounded-lg border bg-card shadow-sm py-16 text-center">
|
|
107
|
+
<Check className="h-8 w-8 text-success/40 mx-auto mb-2" />
|
|
108
|
+
<p className="text-sm font-medium">{t('financial.reconciliation.empty')}</p>
|
|
109
|
+
<p className="text-xs text-muted-foreground mt-1">{t('financial.reconciliation.emptyHint')}</p>
|
|
110
|
+
</div>
|
|
111
|
+
) : (
|
|
112
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
113
|
+
{/* Left — pending bank lines */}
|
|
114
|
+
<div className="rounded-lg border bg-card shadow-sm overflow-hidden">
|
|
115
|
+
<div className="flex items-center gap-2 px-4 py-3 border-b">
|
|
116
|
+
<Landmark className="h-4 w-4 text-muted-foreground" />
|
|
117
|
+
<h3 className="text-sm font-semibold">{t('financial.reconciliation.bankLines')}</h3>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="divide-y max-h-[60vh] overflow-auto">
|
|
120
|
+
{lines.map((line) => {
|
|
121
|
+
const isCredit = line.direction === 'credit'
|
|
122
|
+
return (
|
|
123
|
+
<button
|
|
124
|
+
key={line.id}
|
|
125
|
+
onClick={() => setSelectedId(line.id)}
|
|
126
|
+
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/40 transition-colors ${
|
|
127
|
+
selectedId === line.id ? 'bg-muted/50' : ''
|
|
128
|
+
}`}
|
|
129
|
+
>
|
|
130
|
+
{isCredit
|
|
131
|
+
? <ArrowDownCircle className="h-4 w-4 text-success shrink-0" />
|
|
132
|
+
: <ArrowUpCircle className="h-4 w-4 text-destructive shrink-0" />}
|
|
133
|
+
<div className="min-w-0 flex-1">
|
|
134
|
+
<p className="text-sm truncate">{line.notes || line.externalId}</p>
|
|
135
|
+
<p className="text-[10px] text-muted-foreground">
|
|
136
|
+
{line.paymentDate ?? line.dueDate}
|
|
137
|
+
{line.externalSource && <span className="ml-1 uppercase tracking-wide">· {line.externalSource}</span>}
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
<span className={`text-sm font-semibold shrink-0 ${isCredit ? 'text-success' : 'text-destructive'}`}>
|
|
141
|
+
{fmt(line.amount)}
|
|
142
|
+
</span>
|
|
143
|
+
</button>
|
|
144
|
+
)
|
|
145
|
+
})}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Right — suggested matches for the selected line */}
|
|
150
|
+
<div className="rounded-lg border bg-card shadow-sm overflow-hidden">
|
|
151
|
+
<div className="flex items-center gap-2 px-4 py-3 border-b">
|
|
152
|
+
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
|
153
|
+
<h3 className="text-sm font-semibold">{t('financial.reconciliation.candidates')}</h3>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{!selected ? (
|
|
157
|
+
<p className="px-4 py-16 text-center text-sm text-muted-foreground">
|
|
158
|
+
{t('financial.reconciliation.selectLine')}
|
|
159
|
+
</p>
|
|
160
|
+
) : (
|
|
161
|
+
<div className="p-3 space-y-2">
|
|
162
|
+
{candidatesLoading ? (
|
|
163
|
+
<div className="py-10 text-center text-xs text-muted-foreground">…</div>
|
|
164
|
+
) : (
|
|
165
|
+
<>
|
|
166
|
+
{candidates.map((c) => (
|
|
167
|
+
<div key={c.movement.id} className="flex items-center gap-3 rounded-md border p-2.5">
|
|
168
|
+
<div className="min-w-0 flex-1">
|
|
169
|
+
<p className="text-sm truncate">
|
|
170
|
+
{c.invoice?.contactName || c.movement.notes || c.invoice?.fiscalNumber || t('financial.reconciliation.match')}
|
|
171
|
+
</p>
|
|
172
|
+
<p className="text-[10px] text-muted-foreground">
|
|
173
|
+
{c.movement.dueDate}
|
|
174
|
+
<span className="ml-1.5 inline-flex items-center rounded px-1 py-px bg-primary/10 text-primary font-medium">
|
|
175
|
+
{t('financial.reconciliation.matchScore', { score: Math.round(c.score * 100) })}
|
|
176
|
+
</span>
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
<span className="text-sm font-semibold shrink-0">{fmt(c.movement.amount)}</span>
|
|
180
|
+
<Button size="sm" disabled={busy} onClick={() => match(selected.id, c.movement.id)}>
|
|
181
|
+
<Link2 className="h-3.5 w-3.5" />
|
|
182
|
+
{t('financial.reconciliation.match')}
|
|
183
|
+
</Button>
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
186
|
+
{candidates.length === 0 && (
|
|
187
|
+
<p className="py-6 text-center text-xs text-muted-foreground">
|
|
188
|
+
{t('financial.reconciliation.noCandidates')}
|
|
189
|
+
</p>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{/* Accept the line standalone (no internal counterpart) */}
|
|
193
|
+
<div className="pt-2 mt-1 border-t">
|
|
194
|
+
<Button variant="outline" size="sm" className="w-full" disabled={busy} onClick={() => match(selected.id)}>
|
|
195
|
+
<Check className="h-3.5 w-3.5" />
|
|
196
|
+
{t('financial.reconciliation.accept')}
|
|
197
|
+
</Button>
|
|
198
|
+
<p className="text-[10px] text-muted-foreground text-center mt-1">
|
|
199
|
+
{t('financial.reconciliation.acceptHint')}
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|