@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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/dist/FinancialContext.d.ts +2 -0
  3. package/dist/FinancialContext.d.ts.map +1 -1
  4. package/dist/FinancialPage.d.ts.map +1 -1
  5. package/dist/components/PaymentModal.d.ts.map +1 -1
  6. package/dist/components/PersonStatementWidget.d.ts +18 -0
  7. package/dist/components/PersonStatementWidget.d.ts.map +1 -0
  8. package/dist/components/TransferModal.d.ts +7 -0
  9. package/dist/components/TransferModal.d.ts.map +1 -0
  10. package/dist/contract.test.d.ts +2 -0
  11. package/dist/contract.test.d.ts.map +1 -0
  12. package/dist/data/capability.test.d.ts +2 -0
  13. package/dist/data/capability.test.d.ts.map +1 -0
  14. package/dist/data/mock.d.ts.map +1 -1
  15. package/dist/data/supabase.d.ts.map +1 -1
  16. package/dist/data/types.d.ts +13 -2
  17. package/dist/data/types.d.ts.map +1 -1
  18. package/dist/index.cjs +1529 -263
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1437 -171
  23. package/dist/index.js.map +1 -1
  24. package/dist/locales/en.d.ts.map +1 -1
  25. package/dist/locales/pt-BR.d.ts.map +1 -1
  26. package/dist/registries.d.ts.map +1 -1
  27. package/dist/store.d.ts +4 -2
  28. package/dist/store.d.ts.map +1 -1
  29. package/dist/types.d.ts +111 -1
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/views/InvoiceDetailView.d.ts +3 -1
  32. package/dist/views/InvoiceDetailView.d.ts.map +1 -1
  33. package/dist/views/InvoiceFormView.d.ts.map +1 -1
  34. package/dist/views/PayablesView.d.ts +5 -1
  35. package/dist/views/PayablesView.d.ts.map +1 -1
  36. package/dist/views/ReceivablesView.d.ts +5 -1
  37. package/dist/views/ReceivablesView.d.ts.map +1 -1
  38. package/dist/views/ReconciliationView.d.ts +3 -0
  39. package/dist/views/ReconciliationView.d.ts.map +1 -0
  40. package/dist/views/StatementsView.d.ts +3 -1
  41. package/dist/views/StatementsView.d.ts.map +1 -1
  42. package/dist/views/navReferrer.d.ts +5 -0
  43. package/dist/views/navReferrer.d.ts.map +1 -0
  44. package/package.json +8 -5
  45. package/src/FinancialContext.tsx +2 -0
  46. package/src/FinancialPage.tsx +32 -17
  47. package/src/components/PaymentModal.tsx +58 -11
  48. package/src/components/PersonStatementWidget.tsx +133 -0
  49. package/src/components/TransferModal.tsx +150 -0
  50. package/src/contract.test.ts +58 -0
  51. package/src/data/capability.test.ts +90 -0
  52. package/src/data/mock.ts +265 -52
  53. package/src/data/supabase.ts +359 -30
  54. package/src/data/types.ts +22 -3
  55. package/src/index.ts +28 -2
  56. package/src/locales/en.ts +76 -0
  57. package/src/locales/pt-BR.ts +76 -0
  58. package/src/migrations/006_extract_fee_amount.sql +20 -0
  59. package/src/migrations/006_rls_policies.sql +37 -0
  60. package/src/migrations/007_reconciliation.sql +32 -0
  61. package/src/migrations/008_split_payment_movements.sql +29 -0
  62. package/src/registries.ts +3 -1
  63. package/src/store.ts +27 -7
  64. package/src/types.ts +126 -1
  65. package/src/views/InvoiceDetailView.tsx +70 -51
  66. package/src/views/InvoiceFormView.tsx +55 -20
  67. package/src/views/PayablesView.tsx +13 -2
  68. package/src/views/ReceivablesView.tsx +13 -2
  69. package/src/views/ReconciliationView.tsx +211 -0
  70. package/src/views/StatementsView.tsx +241 -29
  71. 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={direction === 'debit' ? t('financial.nav.payables') : t('financial.nav.receivables')} />
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={direction === 'debit' ? t('financial.nav.payables') : t('financial.nav.receivables')} />
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={direction === 'debit' ? t('financial.nav.payables') : t('financial.nav.receivables')}
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
- // Resolve names for payment details
414
- const method = paymentMethods.find((m) => m.id === mov.paymentMethodId)
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="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-muted-foreground">
476
- {methodType && (
477
- <>
478
- <span className="font-medium text-foreground">{methodType.name}</span>
479
- {method && <span>({method.name})</span>}
480
- </>
481
- )}
482
- {mov.cardBrand && (
483
- <>
484
- {methodType && <span>&middot;</span>}
485
- <CreditCard className="h-3 w-3 shrink-0" />
486
- <span className="font-medium text-foreground">{mov.cardBrand}</span>
487
- {mov.cardInstallments && <span>{mov.cardInstallments}x</span>}
488
- </>
489
- )}
490
- {account && (
491
- <>
492
- {(methodType || mov.cardBrand) && <span>&middot;</span>}
493
- <Building2 className="h-3 w-3 shrink-0" />
494
- <span>{account.name}</span>
495
- </>
496
- )}
497
- {mov.paidAmount > 0 && (
498
- <>
499
- <span>&middot;</span>
500
- <span className="font-medium text-success">{formatCurrency(mov.paidAmount, currency)}</span>
501
- </>
502
- )}
503
- {mov.paymentDate && (
504
- <>
505
- <span>&middot;</span>
506
- <span>{mov.paymentDate}</span>
507
- </>
508
- )}
509
- {mov.notes && (
510
- <>
511
- <span>&middot;</span>
512
- <span className="italic truncate max-w-[200px]">{mov.notes}</span>
513
- </>
514
- )}
515
- {!methodType && !mov.cardBrand && !account && !mov.notes && (
516
- <span>{t('financial.invoice.noDetails')}</span>
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>&middot;</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>&middot;</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>&middot;</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>&middot;</span>
519
+ <span className="text-warning">{t('financial.statements.columnFee')} {formatCurrency(p.feeAmount ?? 0, currency)}</span>
520
+ </>
521
+ )}
522
+ {p.paymentDate && (
523
+ <>
524
+ <span>&middot;</span>
525
+ <span>{p.paymentDate}</span>
526
+ </>
527
+ )}
528
+ {p.notes && (
529
+ <>
530
+ <span>&middot;</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
- <div className="flex gap-1.5">
171
- {itemTypes.map((tp) => (
172
- <button
173
- key={tp.value}
174
- onClick={() => onUpdate({ itemKind: tp.value })}
175
- className={`rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors ${
176
- item.itemKind === tp.value
177
- ? 'bg-primary text-primary-foreground'
178
- : 'bg-muted text-muted-foreground hover:bg-muted/80'
179
- }`}
180
- >
181
- {tp.label}
182
- </button>
183
- ))}
184
- </div>
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={() => setAddingItem(true)}
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={() => setAddingItem(true)} className="text-xs text-primary hover:underline mt-1">
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
- onBack={() => onNavigate('payables-list')}
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
- onBack={() => onNavigate('receivables-list')}
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
+ }