@fayz-ai/plugin-financial 0.9.0 → 0.9.1

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 (91) hide show
  1. package/README.md +3 -3
  2. package/dist/components/PersonStatementWidget.d.ts +1 -1
  3. package/dist/components/PersonStatementWidget.d.ts.map +1 -1
  4. package/dist/{FinancialContext.d.ts → context.d.ts} +4 -2
  5. package/dist/context.d.ts.map +1 -0
  6. package/dist/data/registries.d.ts.map +1 -0
  7. package/dist/index.d.ts +10 -4
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1208 -1368
  10. package/dist/index.js.map +1 -1
  11. package/dist/lib/message-events.d.ts +12 -0
  12. package/dist/lib/message-events.d.ts.map +1 -0
  13. package/dist/{quick-add.d.ts → lib/quick-add.d.ts} +1 -1
  14. package/dist/lib/quick-add.d.ts.map +1 -0
  15. package/dist/locales/en.d.ts.map +1 -1
  16. package/dist/locales/pt-BR.d.ts.map +1 -1
  17. package/dist/{FinancialPage.d.ts → views/FinancialPage.d.ts} +3 -3
  18. package/dist/views/FinancialPage.d.ts.map +1 -0
  19. package/dist/views/InvoiceDetailView.d.ts.map +1 -1
  20. package/dist/views/InvoiceFormView.d.ts.map +1 -1
  21. package/dist/views/InvoiceListView.d.ts.map +1 -1
  22. package/dist/views/PayablesView.d.ts +1 -1
  23. package/dist/views/PayablesView.d.ts.map +1 -1
  24. package/dist/views/ReceivablesView.d.ts +1 -1
  25. package/dist/views/ReceivablesView.d.ts.map +1 -1
  26. package/dist/views/StatementsView.d.ts.map +1 -1
  27. package/dist/views/dashboardWidgets.d.ts +1 -1
  28. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  29. package/package.json +12 -14
  30. package/dist/FinancialContext.d.ts.map +0 -1
  31. package/dist/FinancialPage.d.ts.map +0 -1
  32. package/dist/contract.test.d.ts +0 -2
  33. package/dist/contract.test.d.ts.map +0 -1
  34. package/dist/quick-add.d.ts.map +0 -1
  35. package/dist/registries.d.ts.map +0 -1
  36. package/src/FinancialContext.tsx +0 -59
  37. package/src/FinancialPage.tsx +0 -413
  38. package/src/README.md +0 -155
  39. package/src/components/FinancialGeneralSettings.tsx +0 -39
  40. package/src/components/FinancialOnboarding.tsx +0 -87
  41. package/src/components/FinancialSettings.tsx +0 -31
  42. package/src/components/PaymentModal.tsx +0 -369
  43. package/src/components/PersonStatementWidget.tsx +0 -133
  44. package/src/components/TransferModal.tsx +0 -150
  45. package/src/contract.test.ts +0 -58
  46. package/src/data/capability.test.ts +0 -135
  47. package/src/data/index.ts +0 -3
  48. package/src/data/mock.ts +0 -842
  49. package/src/data/supabase.ts +0 -968
  50. package/src/data/tables.ts +0 -13
  51. package/src/data/types.ts +0 -86
  52. package/src/index.ts +0 -487
  53. package/src/lib/tenant.ts +0 -3
  54. package/src/locales/en.ts +0 -493
  55. package/src/locales/index.ts +0 -7
  56. package/src/locales/pt-BR.ts +0 -493
  57. package/src/migrations/000_plg_rename.sql +0 -30
  58. package/src/migrations/001_financial_base.sql +0 -109
  59. package/src/migrations/002_chart_of_accounts.sql +0 -28
  60. package/src/migrations/003_card_brands.sql +0 -12
  61. package/src/migrations/004_order_to_cash.sql +0 -178
  62. package/src/migrations/005_seed_defaults.sql +0 -38
  63. package/src/migrations/006_rls_policies.sql +0 -37
  64. package/src/migrations/006b_extract_fee_amount.sql +0 -20
  65. package/src/migrations/007_reconciliation.sql +0 -32
  66. package/src/migrations/007b_movement_payment_method_type.sql +0 -6
  67. package/src/migrations/008_split_payment_movements.sql +0 -29
  68. package/src/migrations/009_agent_rpcs.sql +0 -165
  69. package/src/migrations/010_invoice_internal.sql +0 -133
  70. package/src/migrations/011_nfe.sql +0 -58
  71. package/src/migrations/index.ts +0 -923
  72. package/src/quick-add.ts +0 -58
  73. package/src/registries.ts +0 -308
  74. package/src/store.ts +0 -329
  75. package/src/types.ts +0 -592
  76. package/src/views/CardsView.tsx +0 -213
  77. package/src/views/CashRegistersView.tsx +0 -597
  78. package/src/views/CommissionsView.tsx +0 -18
  79. package/src/views/InvoiceDetailView.tsx +0 -616
  80. package/src/views/InvoiceFormView.tsx +0 -945
  81. package/src/views/InvoiceListView.tsx +0 -323
  82. package/src/views/NotasFiscaisView.tsx +0 -209
  83. package/src/views/PayablesView.tsx +0 -48
  84. package/src/views/QuickTransactionForm.tsx +0 -548
  85. package/src/views/ReceivablesView.tsx +0 -48
  86. package/src/views/ReconciliationView.tsx +0 -211
  87. package/src/views/StatementsView.tsx +0 -335
  88. package/src/views/SummaryView.tsx +0 -203
  89. package/src/views/dashboardWidgets.tsx +0 -437
  90. package/src/views/navReferrer.ts +0 -24
  91. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
@@ -1,945 +0,0 @@
1
- import React, { useState, useRef, useEffect } from 'react'
2
- import { createPortal } from 'react-dom'
3
- import { Plus, Trash2, Search, LayoutList, X, ChevronDown, Percent, Tag, Building2, Check, MoreVertical, Ban } from 'lucide-react'
4
- import { useFinancialConfig, useFinancialStore, useFinancialProvider, formatCurrency } from '../FinancialContext'
5
- import { SearchSelect } from '@fayz-ai/ui'
6
- import { CurrencyInput } from '@fayz-ai/ui'
7
- import { DatePicker } from '@fayz-ai/ui'
8
- import { Button } from '@fayz-ai/ui'
9
- import type { EntityLookupMap } from '@fayz-ai/saas'
10
- import { useLimitGuard, invalidateLimit, usePermissionOptional } from '@fayz-ai/saas'
11
- import { SubpageHeader, useSaveBar, toast } from '@fayz-ai/ui'
12
- import { useTranslation } from '@fayz-ai/core'
13
- import type { TransactionDirection, CreateInvoiceItemInput, CreateMovementInput } from '../types'
14
-
15
- // ---------------------------------------------------------------------------
16
- // Location / unit selector
17
- // ---------------------------------------------------------------------------
18
-
19
- export interface LocationOption {
20
- id: string
21
- name: string
22
- isHQ?: boolean
23
- }
24
-
25
- function LocationSelector({ locations, value, onChange }: {
26
- locations: LocationOption[]
27
- value: string
28
- onChange: (id: string) => void
29
- }) {
30
- const t = useTranslation()
31
- const [open, setOpen] = useState(false)
32
- const [search, setSearch] = useState('')
33
- const ref = useRef<HTMLDivElement>(null)
34
-
35
- useEffect(() => {
36
- function handler(e: MouseEvent) {
37
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
38
- }
39
- document.addEventListener('mousedown', handler)
40
- return () => document.removeEventListener('mousedown', handler)
41
- }, [])
42
-
43
- const selected = locations.find((l) => l.id === value)
44
- const filtered = locations.filter((l) =>
45
- !search.trim() || l.name.toLowerCase().includes(search.toLowerCase())
46
- )
47
-
48
- return (
49
- <div ref={ref} className="relative">
50
- <label className="text-xs font-medium text-muted-foreground">{t('financial.invoiceForm.unit')}</label>
51
- <button
52
- type="button"
53
- onClick={() => setOpen(!open)}
54
- className="w-full mt-1 flex items-center gap-2 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm text-left hover:bg-muted/30 transition-colors"
55
- >
56
- <Building2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
57
- <span className="flex-1 truncate">{selected?.name ?? t('financial.invoiceForm.selectUnit')}</span>
58
- <ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
59
- </button>
60
-
61
- {open && (
62
- <div className="fayz-glass-surface absolute top-full left-0 right-0 z-50 mt-1 rounded-lg border bg-popover shadow-lg overflow-hidden min-w-[260px]">
63
- <div className="flex items-center gap-2 px-3 py-2 border-b">
64
- <Building2 className="h-4 w-4 text-muted-foreground" />
65
- <span className="text-sm font-semibold">{t('financial.invoiceForm.selectUnitTitle')}</span>
66
- <button onClick={() => setOpen(false)} className="ml-auto text-muted-foreground hover:text-foreground">
67
- <X className="h-3.5 w-3.5" />
68
- </button>
69
- </div>
70
- <div className="p-2 border-b">
71
- <div className="flex items-center gap-2 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2.5 py-1.5">
72
- <Search className="h-3.5 w-3.5 text-muted-foreground" />
73
- <input
74
- type="text"
75
- autoFocus
76
- value={search}
77
- onChange={(e) => setSearch(e.target.value)}
78
- placeholder={t('financial.invoiceForm.searchUnit')}
79
- className="flex-1 bg-transparent text-sm outline-none"
80
- />
81
- </div>
82
- </div>
83
- <div className="max-h-56 overflow-auto py-1">
84
- {filtered.map((loc) => (
85
- <button
86
- key={loc.id}
87
- onClick={() => { onChange(loc.id); setOpen(false); setSearch('') }}
88
- className={`w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/50 transition-colors ${
89
- loc.id === value ? 'bg-muted/30' : ''
90
- }`}
91
- >
92
- <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted shrink-0">
93
- <Building2 className="h-4 w-4 text-muted-foreground" />
94
- </div>
95
- <div className="min-w-0">
96
- <p className="text-sm font-medium truncate">{loc.name}</p>
97
- {loc.isHQ && <p className="text-[10px] text-muted-foreground">{t('financial.invoiceForm.headquarters')}</p>}
98
- </div>
99
- </button>
100
- ))}
101
- {filtered.length === 0 && (
102
- <p className="px-3 py-4 text-center text-xs text-muted-foreground">{t('financial.invoiceForm.noUnitsFound')}</p>
103
- )}
104
- </div>
105
- </div>
106
- )}
107
- </div>
108
- )
109
- }
110
-
111
- // ---------------------------------------------------------------------------
112
- // ID helper
113
- // ---------------------------------------------------------------------------
114
-
115
- let formId = 1
116
- function fid(): string { return `f${formId++}` }
117
-
118
- // ---------------------------------------------------------------------------
119
- // Item row — compact by default, expandable for details
120
- // ---------------------------------------------------------------------------
121
-
122
- interface FormItem extends CreateInvoiceItemInput {
123
- _id: string
124
- }
125
-
126
- function ItemRow({ item, index, itemTypes, currency, expanded, onToggle, onUpdate, onRemove, entityLookups, showTypeSelector }: {
127
- item: FormItem
128
- index: number
129
- itemTypes: Array<{ value: string; label: string; icon?: string }>
130
- currency: { code: string; locale: string; symbol: string }
131
- expanded: boolean
132
- onToggle: () => void
133
- onUpdate: (data: Partial<FormItem>) => void
134
- onRemove: () => void
135
- entityLookups: EntityLookupMap
136
- /** Whether to show the per-row Service/Product/Other type pills. Hidden when no item type is lookup-backed. */
137
- showTypeSelector: boolean
138
- }) {
139
- const t = useTranslation()
140
- const total = item.quantity * item.unitPrice - (item.discount ?? 0) + (item.surcharge ?? 0)
141
- const kindLabel = itemTypes.find((tp) => tp.value === item.itemKind)?.label ?? item.itemKind
142
- const hasAdjustments = (item.discount ?? 0) > 0 || (item.surcharge ?? 0) > 0
143
- const [showAdjustments, setShowAdjustments] = useState(hasAdjustments)
144
-
145
- // Enter in any of the row's edit fields commits the row (same as "Done").
146
- function handleFieldKeyDown(e: React.KeyboardEvent) {
147
- if (e.key === 'Enter' && !e.shiftKey) {
148
- e.preventDefault()
149
- onToggle()
150
- }
151
- }
152
-
153
- return (
154
- <div className="border-b last:border-0">
155
- {/* Compact row — always visible */}
156
- <div
157
- className="flex items-center gap-2 px-4 py-3 cursor-pointer hover:bg-muted/30 transition-colors group"
158
- onClick={onToggle}
159
- >
160
- <ChevronDown className={`h-3 w-3 text-muted-foreground shrink-0 transition-transform ${expanded ? 'rotate-0' : '-rotate-90'}`} />
161
- <span className="text-[10px] font-medium text-muted-foreground w-5 shrink-0">{index + 1}.</span>
162
- <span className="inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-medium bg-muted text-muted-foreground shrink-0">
163
- {kindLabel}
164
- </span>
165
- <span className="text-sm truncate flex-1 min-w-0">{item.description || <span className="text-muted-foreground italic">{t('financial.invoiceForm.noDescription')}</span>}</span>
166
- <span className="text-xs text-muted-foreground shrink-0">{item.quantity} x {formatCurrency(item.unitPrice, currency)}</span>
167
- {hasAdjustments && <Tag className="h-3 w-3 text-warning shrink-0" />}
168
- <span className="text-sm font-semibold shrink-0 min-w-[80px] text-right">{formatCurrency(total, currency)}</span>
169
- <button
170
- onClick={(e) => { e.stopPropagation(); onRemove() }}
171
- className="p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive transition-all shrink-0"
172
- >
173
- <Trash2 className="h-3 w-3" />
174
- </button>
175
- </div>
176
-
177
- {/* Expanded detail — edit fields */}
178
- {expanded && (
179
- <div className="px-4 pb-4 pt-1 ml-8 space-y-3 bg-muted/10">
180
- {/* Type selector — hidden when no item type is lookup-backed (manual-only entry) */}
181
- {showTypeSelector && (
182
- <div className="flex gap-1.5">
183
- {itemTypes.map((tp) => (
184
- <button
185
- key={tp.value}
186
- onClick={() => onUpdate({ itemKind: tp.value })}
187
- className={`rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors ${
188
- item.itemKind === tp.value
189
- ? 'bg-primary text-primary-foreground'
190
- : 'bg-muted text-muted-foreground hover:bg-muted/80'
191
- }`}
192
- >
193
- {tp.label}
194
- </button>
195
- ))}
196
- </div>
197
- )}
198
-
199
- {/* Main fields */}
200
- <div className="grid gap-3 grid-cols-12">
201
- <div className="col-span-6 sm:col-span-7">
202
- {entityLookups[item.itemKind] ? (
203
- <SearchSelect
204
- label={t('financial.invoiceForm.item')}
205
- value={item.referenceId ?? ''}
206
- displayValue={item.description}
207
- loadOnOpen
208
- onChange={(id, opt) => {
209
- const price = (opt?.data as any)?.price
210
- onUpdate({
211
- referenceId: id || undefined,
212
- description: opt?.label ?? '',
213
- ...(price != null ? { unitPrice: price } : {}),
214
- })
215
- }}
216
- onSearch={async (q) => {
217
- const results = await entityLookups[item.itemKind].search(q)
218
- return results.map((r) => ({
219
- id: r.id,
220
- label: r.label,
221
- subtitle: r.subtitle,
222
- group: r.group,
223
- data: r,
224
- }))
225
- }}
226
- placeholder={`Search ${itemTypes.find((tp) => tp.value === item.itemKind)?.label?.toLowerCase() ?? 'item'}...`}
227
- renderOption={(opt) => (
228
- <div className="min-w-0 flex-1">
229
- <p className="text-sm truncate">{opt.label}</p>
230
- {opt.subtitle && <p className="text-[10px] text-muted-foreground">{opt.subtitle}</p>}
231
- </div>
232
- )}
233
- />
234
- ) : (
235
- <>
236
- <label className="text-[10px] font-medium text-muted-foreground">{t('financial.invoiceForm.description')}</label>
237
- <input
238
- type="text"
239
- value={item.description}
240
- onChange={(e) => onUpdate({ description: e.target.value })}
241
- onKeyDown={handleFieldKeyDown}
242
- placeholder={t('financial.invoiceForm.itemDescription')}
243
- autoFocus
244
- 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"
245
- />
246
- </>
247
- )}
248
- </div>
249
- <div className="col-span-2 sm:col-span-1">
250
- <label className="text-[10px] font-medium text-muted-foreground">{t('financial.invoiceForm.qty')}</label>
251
- <input
252
- type="number"
253
- min={1}
254
- value={item.quantity}
255
- onChange={(e) => onUpdate({ quantity: Number(e.target.value) || 1 })}
256
- onKeyDown={handleFieldKeyDown}
257
- 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"
258
- />
259
- </div>
260
- <div className="col-span-4" onKeyDown={handleFieldKeyDown}>
261
- <CurrencyInput
262
- label={t('financial.invoiceForm.unitPrice')}
263
- value={item.unitPrice}
264
- onChange={(v) => onUpdate({ unitPrice: v })}
265
- symbol={currency.symbol}
266
- locale={currency.locale}
267
- currencyCode={currency.code}
268
- />
269
- </div>
270
- </div>
271
-
272
- {/* Optional adjustments toggle */}
273
- {!showAdjustments ? (
274
- <button
275
- onClick={() => setShowAdjustments(true)}
276
- className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
277
- >
278
- <Plus className="h-3 w-3" /> {t('financial.invoiceForm.discountSurcharge')}
279
- </button>
280
- ) : (
281
- <div className="flex items-center gap-3">
282
- <div className="flex-1">
283
- <label className="text-[10px] font-medium text-muted-foreground">{t('financial.invoiceForm.discount')}</label>
284
- <div className="flex items-center mt-0.5 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus-within:ring-2 focus-within:ring-primary/20">
285
- <span className="pl-2 text-xs text-muted-foreground">{currency.symbol}</span>
286
- <input
287
- type="number"
288
- min={0}
289
- step={0.01}
290
- value={item.discount ?? 0}
291
- onChange={(e) => onUpdate({ discount: Number(e.target.value) || 0 })}
292
- onKeyDown={handleFieldKeyDown}
293
- className="flex-1 bg-transparent px-2 py-1 text-xs outline-none"
294
- />
295
- </div>
296
- </div>
297
- <div className="flex-1">
298
- <label className="text-[10px] font-medium text-muted-foreground">{t('financial.invoiceForm.surcharge')}</label>
299
- <div className="flex items-center mt-0.5 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus-within:ring-2 focus-within:ring-primary/20">
300
- <span className="pl-2 text-xs text-muted-foreground">{currency.symbol}</span>
301
- <input
302
- type="number"
303
- min={0}
304
- step={0.01}
305
- value={item.surcharge ?? 0}
306
- onChange={(e) => onUpdate({ surcharge: Number(e.target.value) || 0 })}
307
- onKeyDown={handleFieldKeyDown}
308
- className="flex-1 bg-transparent px-2 py-1 text-xs outline-none"
309
- />
310
- </div>
311
- </div>
312
- <button
313
- onClick={() => { onUpdate({ discount: 0, surcharge: 0 }); setShowAdjustments(false) }}
314
- className="mt-3 p-1 text-muted-foreground hover:text-destructive transition-colors"
315
- title={t('financial.invoiceForm.removeAdjustments')}
316
- >
317
- <X className="h-3.5 w-3.5" />
318
- </button>
319
- </div>
320
- )}
321
-
322
- {/* Done — collapse row */}
323
- <div className="flex justify-end pt-1">
324
- <button
325
- onClick={onToggle}
326
- className="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
327
- >
328
- <Check className="h-3 w-3" /> {t('financial.invoiceForm.done')}
329
- </button>
330
- </div>
331
- </div>
332
- )}
333
- </div>
334
- )
335
- }
336
-
337
- // ---------------------------------------------------------------------------
338
- // Type selector step — shown when adding a new item
339
- // ---------------------------------------------------------------------------
340
-
341
- function AddItemStep({ itemTypes, onSelect, onCancel }: {
342
- itemTypes: Array<{ value: string; label: string; icon?: string }>
343
- onSelect: (kind: string) => void
344
- onCancel: () => void
345
- }) {
346
- const t = useTranslation()
347
- return (
348
- <div className="border-b px-4 py-3 bg-primary/5 animate-in fade-in-0 slide-in-from-top-1">
349
- <p className="text-xs font-medium text-muted-foreground mb-2">{t('financial.invoiceForm.selectItemType')}</p>
350
- <div className="flex gap-2 flex-wrap">
351
- {itemTypes.map((t) => (
352
- <button
353
- key={t.value}
354
- onClick={() => onSelect(t.value)}
355
- className="inline-flex items-center gap-1.5 rounded-lg border-2 border-transparent bg-card px-4 py-2 text-sm font-medium shadow-sm hover:border-primary hover:shadow-md transition-all"
356
- >
357
- {t.label}
358
- </button>
359
- ))}
360
- <button
361
- onClick={onCancel}
362
- className="inline-flex items-center rounded-lg px-3 py-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
363
- >
364
- {t('financial.invoiceForm.cancel')}
365
- </button>
366
- </div>
367
- </div>
368
- )
369
- }
370
-
371
- // ---------------------------------------------------------------------------
372
- // Installment form
373
- // ---------------------------------------------------------------------------
374
-
375
- interface FormInstallment {
376
- _id: string
377
- amount: number
378
- dueDate: string
379
- }
380
-
381
- // ---------------------------------------------------------------------------
382
- // Invoice form view
383
- // ---------------------------------------------------------------------------
384
-
385
- export function InvoiceFormView({ direction, editId, onSaved }: {
386
- direction: TransactionDirection
387
- editId?: string
388
- onSaved?: (id?: string) => void
389
- }) {
390
- const t = useTranslation()
391
- const config = useFinancialConfig()
392
- const { currency, itemTypes, locations } = config
393
- const provider = useFinancialProvider()
394
-
395
- // Whether any item type is backed by an entity lookup (product/service catalog).
396
- // When none are, item entry is manual-only: skip the type-select step and add
397
- // an "Other" line directly so the user can type the description right away.
398
- const hasItemLookups = itemTypes.some((tp) => config.entityLookups[tp.value])
399
- const defaultItemKind =
400
- itemTypes.find((tp) => tp.value === 'other')?.value ??
401
- itemTypes.find((tp) => !config.entityLookups[tp.value])?.value ??
402
- itemTypes[0]?.value ??
403
- 'other'
404
- const createInvoice = useFinancialStore((s) => s.createInvoice)
405
- const guardMovements = useLimitGuard('movements_month')
406
- const can = usePermissionOptional()
407
- const hasLocations = locations.length > 0
408
- const isEdit = !!editId
409
-
410
- const [loaded, setLoaded] = useState(!editId)
411
- const [loading, setLoading] = useState(!!editId)
412
- const [contactName, setContactName] = useState('')
413
- const [contactId, setContactId] = useState<string | undefined>()
414
- const [unitId, setUnitId] = useState(locations[0]?.id ?? '')
415
- const [invoiceDate, setInvoiceDate] = useState(new Date().toISOString().slice(0, 10))
416
- const [fiscalNumber, setFiscalNumber] = useState('')
417
- const [observations, setObservations] = useState('')
418
- const [items, setItems] = useState<FormItem[]>([])
419
- const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
420
- const [addingItem, setAddingItem] = useState(false)
421
- const [installments, setInstallments] = useState<FormInstallment[]>([])
422
- const [saving, setSaving] = useState(false)
423
- const [menuOpen, setMenuOpen] = useState(false)
424
- const [confirmCancel, setConfirmCancel] = useState(false)
425
- const [cancelling, setCancelling] = useState(false)
426
- const menuRef = useRef<HTMLDivElement>(null)
427
- const cancelInvoice = useFinancialStore((s) => s.cancelInvoice)
428
-
429
- // Close menu on click outside
430
- useEffect(() => {
431
- if (!menuOpen) return
432
- function handleClick(e: MouseEvent) {
433
- if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
434
- }
435
- document.addEventListener('mousedown', handleClick)
436
- return () => document.removeEventListener('mousedown', handleClick)
437
- }, [menuOpen])
438
-
439
- async function handleCancelInvoice() {
440
- if (!editId) return
441
- setCancelling(true)
442
- try {
443
- await cancelInvoice(editId)
444
- onSaved?.()
445
- } catch {
446
- // toast handled by store
447
- } finally {
448
- setCancelling(false)
449
- }
450
- }
451
-
452
- // Load existing invoice for edit mode
453
- useEffect(() => {
454
- if (!editId || loaded) return
455
- setLoading(true)
456
- ;(async () => {
457
- const invoice = await provider.getInvoiceById(editId)
458
- if (!invoice) return
459
- setContactName(invoice.contactName ?? '')
460
- setContactId(invoice.contactId)
461
- setUnitId(invoice.unitId ?? locations[0]?.id ?? '')
462
- setInvoiceDate(invoice.invoiceDate)
463
- setFiscalNumber(invoice.fiscalNumber ?? '')
464
- setObservations(invoice.observations ?? '')
465
-
466
- const invoiceItems = await provider.getInvoiceItems(editId)
467
- setItems(invoiceItems.map((it) => ({
468
- _id: fid(),
469
- itemKind: it.itemKind,
470
- referenceId: it.referenceId,
471
- description: it.description,
472
- quantity: it.quantity,
473
- unitPrice: it.unitPrice,
474
- discount: it.discount,
475
- surcharge: it.surcharge,
476
- })))
477
-
478
- const movements = await provider.getMovements({ direction: invoice.direction })
479
- const invoiceMovements = movements.data
480
- .filter((m) => m.invoiceId === editId && m.movementKind === 'bill')
481
- .sort((a, b) => (a.installmentNumber ?? 0) - (b.installmentNumber ?? 0))
482
- setInstallments(invoiceMovements.map((m) => ({
483
- _id: fid(),
484
- amount: m.amount,
485
- dueDate: m.dueDate,
486
- })))
487
-
488
- setLoaded(true)
489
- setLoading(false)
490
- })()
491
- }, [editId])
492
-
493
- const totalAmount = items.reduce((sum, it) => {
494
- return sum + it.quantity * it.unitPrice - (it.discount ?? 0) + (it.surcharge ?? 0)
495
- }, 0)
496
-
497
- // Auto-generate 1x installment when first item is added
498
- const prevItemCount = useRef(items.length)
499
- useEffect(() => {
500
- if (items.length > 0 && prevItemCount.current === 0 && installments.length === 0 && !isEdit) {
501
- // First item added — generate 1x installment
502
- const total = items.reduce((sum, it) => sum + it.quantity * it.unitPrice - (it.discount ?? 0) + (it.surcharge ?? 0), 0)
503
- if (total > 0) {
504
- setInstallments([{ _id: fid(), amount: total, dueDate: invoiceDate }])
505
- }
506
- }
507
- prevItemCount.current = items.length
508
- }, [items.length])
509
-
510
- // Recalculate installment amounts when total changes
511
- useEffect(() => {
512
- if (installments.length > 0 && totalAmount > 0 && !isEdit) {
513
- const count = installments.length
514
- const perInstallment = Math.floor(totalAmount * 100 / count) / 100
515
- const remainder = Math.round((totalAmount - perInstallment * count) * 100) / 100
516
- setInstallments((prev) => prev.map((inst, i) => ({
517
- ...inst,
518
- amount: i === 0 ? perInstallment + remainder : perInstallment,
519
- })))
520
- }
521
- }, [totalAmount])
522
-
523
- function addItemOfKind(kind: string) {
524
- const newItem: FormItem = {
525
- _id: fid(),
526
- itemKind: kind,
527
- description: '',
528
- quantity: 1,
529
- unitPrice: 0,
530
- }
531
- setItems([...items, newItem])
532
- setExpandedItemId(newItem._id)
533
- setAddingItem(false)
534
- }
535
-
536
- // Entry point for the "Add Item" / "Add your first item" buttons. With
537
- // lookup-backed types we ask which type first; otherwise we add an "Other"
538
- // line straight away so the user types directly.
539
- function startAddItem() {
540
- if (hasItemLookups) setAddingItem(true)
541
- else addItemOfKind(defaultItemKind)
542
- }
543
-
544
- function updateItem(id: string, data: Partial<FormItem>) {
545
- setItems(items.map((it) => it._id === id ? { ...it, ...data } : it))
546
- }
547
-
548
- function removeItem(id: string) {
549
- setItems(items.filter((it) => it._id !== id))
550
- }
551
-
552
- function generateInstallments(count: number) {
553
- if (count < 1 || totalAmount <= 0) return
554
- const perInstallment = Math.floor(totalAmount * 100 / count) / 100
555
- const remainder = Math.round((totalAmount - perInstallment * count) * 100) / 100
556
- const base = new Date(invoiceDate)
557
- const inst: FormInstallment[] = []
558
- for (let i = 0; i < count; i++) {
559
- const due = new Date(base)
560
- due.setMonth(due.getMonth() + i + 1)
561
- inst.push({
562
- _id: fid(),
563
- amount: i === 0 ? perInstallment + remainder : perInstallment,
564
- dueDate: due.toISOString().slice(0, 10),
565
- })
566
- }
567
- setInstallments(inst)
568
- }
569
-
570
- async function handleSave() {
571
- // Defense in depth: block creating a new invoice without the `create` action.
572
- if (!isEdit && !can('financial', 'create')) return
573
- if (items.length === 0) { toast.error(t('common.formIncomplete')); return }
574
- // Plan quantity guard (client-side): an invoice writes one financial movement
575
- // per installment (min 1). Guard that many against the monthly cap before the
576
- // store call — the guard opens the global UpgradeModal when it would exceed.
577
- if (!isEdit && (await guardMovements(Math.max(1, installments.length))) === 'blocked') return
578
- setSaving(true)
579
- try {
580
- const movInputs: CreateMovementInput[] = installments.map((inst, i) => ({
581
- direction,
582
- movementKind: 'bill' as const,
583
- amount: inst.amount,
584
- dueDate: inst.dueDate,
585
- installmentNumber: i + 1,
586
- }))
587
-
588
- if (movInputs.length === 0) {
589
- movInputs.push({
590
- direction,
591
- movementKind: 'bill',
592
- amount: totalAmount,
593
- dueDate: invoiceDate,
594
- installmentNumber: 1,
595
- })
596
- }
597
-
598
- if (isEdit && editId) {
599
- await provider.updateInvoice(editId, {
600
- invoiceDate,
601
- contactId,
602
- contactName: contactName || undefined,
603
- unitId: unitId || undefined,
604
- fiscalNumber: fiscalNumber || undefined,
605
- observations: observations || undefined,
606
- totalAmount,
607
- })
608
- onSaved?.(editId)
609
- } else {
610
- const invoice = await createInvoice({
611
- direction,
612
- invoiceDate,
613
- contactId,
614
- contactName: contactName || undefined,
615
- unitId: unitId || undefined,
616
- fiscalNumber: fiscalNumber || undefined,
617
- observations: observations || undefined,
618
- items: items.map(({ _id, ...rest }) => rest),
619
- installments: movInputs,
620
- })
621
- invalidateLimit('movements_month')
622
- onSaved?.(invoice.id)
623
- }
624
- } finally {
625
- setSaving(false)
626
- }
627
- }
628
-
629
- const fields = {
630
- contactName,
631
- contactId,
632
- unitId,
633
- invoiceDate,
634
- fiscalNumber,
635
- observations,
636
- items: items.map(({ _id, ...rest }) => rest),
637
- installments: installments.map(({ _id, ...rest }) => rest),
638
- }
639
- const snapshot = useRef<string | null>(null)
640
- useEffect(() => {
641
- if (loaded && snapshot.current === null) snapshot.current = JSON.stringify(fields)
642
- }, [loaded])
643
- const dirty = snapshot.current !== null && JSON.stringify(fields) !== snapshot.current
644
- useSaveBar({
645
- dirty,
646
- saving,
647
- onSave: () => { void handleSave() },
648
- onDiscard: () => onSaved?.(),
649
- saveLabel: t('financial.invoiceForm.save'),
650
- })
651
-
652
- const title = direction === 'debit' ? t('financial.invoice.accountsPayable') : t('financial.invoice.accountsReceivable')
653
- const subtitle = isEdit ? t('financial.invoiceForm.editing') : t('financial.invoiceForm.creating')
654
-
655
- return (
656
- <div className="space-y-6">
657
- <SubpageHeader
658
- title={title}
659
- subtitle={subtitle}
660
- onBack={onSaved}
661
- parentLabel={direction === 'debit' ? t('financial.nav.payables') : t('financial.nav.receivables')}
662
- actions={
663
- isEdit ? (
664
- <div className="flex items-center gap-2">
665
- <div className="relative" ref={menuRef}>
666
- <Button
667
- variant="outline"
668
- size="icon"
669
- onClick={() => setMenuOpen(!menuOpen)}
670
- aria-label="More actions"
671
- >
672
- <MoreVertical className="h-4 w-4" />
673
- </Button>
674
- {menuOpen && (
675
- <div className="fayz-glass-surface absolute right-0 top-full mt-1 w-44 rounded-lg border bg-popover shadow-md z-20 py-1" style={{ animation: 'field-slide-in 150ms ease-out' }}>
676
- <button
677
- onClick={() => { setConfirmCancel(true); setMenuOpen(false) }}
678
- className="flex w-full items-center gap-2 px-3 py-2 text-xs text-destructive hover:bg-destructive/10 transition-colors"
679
- >
680
- <Ban className="h-3.5 w-3.5" /> {t('financial.invoiceForm.cancelInvoice')}
681
- </button>
682
- </div>
683
- )}
684
- </div>
685
- </div>
686
- ) : null
687
- }
688
- />
689
-
690
- {/* Top fields: Pay to + Unit + Date + Fiscal Number */}
691
- <div className="rounded-lg border bg-card shadow-sm p-5">
692
- <div className={`grid gap-4 ${hasLocations ? 'sm:grid-cols-12' : 'sm:grid-cols-10'}`}>
693
- <div className={hasLocations ? 'sm:col-span-4' : 'sm:col-span-4'}>
694
- <SearchSelect
695
- label={direction === 'debit' ? t('financial.invoiceForm.payTo') : t('financial.invoiceForm.receiveFrom')}
696
- required
697
- value={contactId ?? ''}
698
- displayValue={contactName}
699
- onChange={(id, opt) => { setContactId(id || undefined); setContactName(opt?.label ?? '') }}
700
- onSearch={async (q) => {
701
- if (!config.contactLookup) return []
702
- const results = await config.contactLookup.search(q)
703
- return results.map((r) => ({ id: r.id, label: r.label, subtitle: r.subtitle, group: r.group, data: r }))
704
- }}
705
- placeholder={t('financial.invoiceForm.searchType')}
706
- />
707
- </div>
708
- {hasLocations && (
709
- <div className="sm:col-span-3">
710
- <LocationSelector
711
- locations={locations}
712
- value={unitId}
713
- onChange={setUnitId}
714
- />
715
- </div>
716
- )}
717
- <div className={hasLocations ? 'sm:col-span-2' : 'sm:col-span-3'}>
718
- <label className="text-xs font-medium text-muted-foreground">{t('financial.invoiceForm.date')}</label>
719
- <DatePicker value={invoiceDate} onChange={setInvoiceDate} className="mt-1" />
720
- </div>
721
- <div className={hasLocations ? 'sm:col-span-3' : 'sm:col-span-3'}>
722
- <label className="text-xs font-medium text-muted-foreground">{t('financial.invoiceForm.fiscalNumber')}</label>
723
- <input
724
- type="text"
725
- value={fiscalNumber}
726
- onChange={(e) => setFiscalNumber(e.target.value)}
727
- placeholder={t('financial.invoiceForm.fiscalNumber')}
728
- className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
729
- />
730
- </div>
731
- </div>
732
- </div>
733
-
734
- {/* Items + Installments side by side */}
735
- {loading ? (
736
- <div className="grid gap-4 lg:grid-cols-3">
737
- <div className="lg:col-span-2 rounded-lg border bg-card shadow-sm overflow-hidden">
738
- <div className="flex items-center justify-between px-4 py-3 border-b">
739
- <div className="flex items-center gap-2">
740
- <LayoutList className="h-4 w-4 text-muted-foreground" />
741
- <h3 className="text-sm font-semibold">{t('financial.invoiceForm.items')}</h3>
742
- </div>
743
- </div>
744
- <div className="p-4 space-y-3">
745
- {[1, 2].map((i) => (
746
- <div key={i} className="flex items-center gap-3 px-2 py-3">
747
- <div className="h-4 w-4 rounded bg-muted animate-pulse" />
748
- <div className="h-4 w-8 rounded bg-muted animate-pulse" />
749
- <div className="h-4 w-16 rounded bg-muted animate-pulse" />
750
- <div className="h-4 flex-1 rounded bg-muted animate-pulse" />
751
- <div className="h-4 w-20 rounded bg-muted animate-pulse" />
752
- <div className="h-4 w-16 rounded bg-muted animate-pulse" />
753
- </div>
754
- ))}
755
- </div>
756
- </div>
757
- <div className="rounded-lg border bg-card shadow-sm">
758
- <div className="px-5 py-3 border-b">
759
- <h3 className="text-sm font-semibold">{t('financial.invoiceForm.installments')}</h3>
760
- </div>
761
- <div className="p-4 space-y-3">
762
- {[1, 2].map((i) => (
763
- <div key={i} className="rounded border p-2.5 space-y-2">
764
- <div className="flex justify-between">
765
- <div className="h-3 w-6 rounded bg-muted animate-pulse" />
766
- <div className="h-4 w-16 rounded bg-muted animate-pulse" />
767
- </div>
768
- <div className="h-7 rounded bg-muted animate-pulse" />
769
- </div>
770
- ))}
771
- </div>
772
- </div>
773
- </div>
774
- ) : (
775
- <div className="grid gap-4 lg:grid-cols-3">
776
- {/* Items (left 2/3) */}
777
- <div className="lg:col-span-2 rounded-lg border bg-card shadow-sm overflow-hidden flex flex-col">
778
- <div className="flex items-center justify-between px-4 py-3 border-b">
779
- <div className="flex items-center gap-2">
780
- <LayoutList className="h-4 w-4 text-muted-foreground" />
781
- <h3 className="text-sm font-semibold">{t('financial.invoiceForm.items')}</h3>
782
- </div>
783
- <button
784
- onClick={startAddItem}
785
- 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"
786
- >
787
- <Plus className="h-3 w-3" /> {t('financial.invoiceForm.addItem')}
788
- </button>
789
- </div>
790
-
791
- {/* Content area — grows to push footer down */}
792
- <div className="flex-1">
793
- {/* Type selector step */}
794
- {addingItem && (
795
- <AddItemStep
796
- itemTypes={itemTypes}
797
- onSelect={addItemOfKind}
798
- onCancel={() => setAddingItem(false)}
799
- />
800
- )}
801
-
802
- {/* Item rows */}
803
- {items.length === 0 && !addingItem ? (
804
- <div className="py-10 text-center">
805
- <LayoutList className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
806
- <p className="text-sm text-muted-foreground">{t('financial.invoiceForm.noItems')}</p>
807
- <button onClick={startAddItem} className="text-xs text-primary hover:underline mt-1">
808
- {t('financial.invoiceForm.addFirstItem')}
809
- </button>
810
- </div>
811
- ) : (
812
- items.map((item, i) => (
813
- <ItemRow
814
- key={item._id}
815
- item={item}
816
- index={i}
817
- itemTypes={itemTypes}
818
- currency={currency}
819
- expanded={expandedItemId === item._id}
820
- onToggle={() => setExpandedItemId(expandedItemId === item._id ? null : item._id)}
821
- onUpdate={(data) => updateItem(item._id, data)}
822
- onRemove={() => { removeItem(item._id); if (expandedItemId === item._id) setExpandedItemId(null) }}
823
- entityLookups={config.entityLookups}
824
- showTypeSelector={hasItemLookups}
825
- />
826
- ))
827
- )}
828
- </div>
829
-
830
- {/* Footer total — sticks to bottom */}
831
- {items.length > 0 && (
832
- <div className="px-4 py-3 border-t bg-muted/20 flex justify-between items-center mt-auto">
833
- <span className="text-xs text-muted-foreground">{t('financial.invoiceForm.itemCount', { count: items.length, plural: items.length !== 1 ? 's' : '' })}</span>
834
- <div className="text-right">
835
- <span className="text-xs text-muted-foreground">{t('financial.invoiceForm.total')} </span>
836
- <span className="text-lg font-bold">{formatCurrency(totalAmount, currency)}</span>
837
- </div>
838
- </div>
839
- )}
840
- </div>
841
-
842
- {/* Installments (right 1/3) */}
843
- <div className="rounded-lg border bg-card shadow-sm">
844
- <div className="flex items-center justify-between px-5 py-3 border-b">
845
- <h3 className="text-sm font-semibold">Installments</h3>
846
- </div>
847
- <div className="p-4 space-y-3">
848
- {/* Quick split buttons */}
849
- <div className="flex gap-1.5 flex-wrap">
850
- {[1, 2, 3, 6, 12].map((n) => (
851
- <button
852
- key={n}
853
- onClick={() => generateInstallments(n)}
854
- disabled={totalAmount <= 0}
855
- className={`rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors disabled:opacity-30 ${
856
- installments.length === n
857
- ? 'bg-primary text-primary-foreground'
858
- : 'bg-muted text-muted-foreground hover:bg-muted/80'
859
- }`}
860
- >
861
- {n}x
862
- </button>
863
- ))}
864
- </div>
865
-
866
- {installments.length === 0 ? (
867
- <p className="text-xs text-muted-foreground py-4 text-center">
868
- {totalAmount <= 0 ? t('financial.invoiceForm.addItemsFirst') : t('financial.invoiceForm.selectInstallments')}
869
- </p>
870
- ) : (
871
- <div className="space-y-2">
872
- {installments.map((inst, i) => (
873
- <div key={inst._id} className="rounded border p-2.5 space-y-1.5">
874
- <div className="flex items-center justify-between">
875
- <span className="text-[10px] font-medium text-muted-foreground">#{i + 1}</span>
876
- <span className="text-sm font-semibold">{formatCurrency(inst.amount, currency)}</span>
877
- </div>
878
- <DatePicker
879
- value={inst.dueDate}
880
- onChange={(v) => {
881
- const updated = [...installments]
882
- updated[i] = { ...inst, dueDate: v }
883
- setInstallments(updated)
884
- }}
885
- />
886
- </div>
887
- ))}
888
- </div>
889
- )}
890
- </div>
891
- </div>
892
- </div>
893
- )}
894
-
895
- {/* Observations */}
896
- <div>
897
- <label className="text-xs font-medium text-muted-foreground">{t('financial.invoiceForm.observations')}</label>
898
- <textarea
899
- value={observations}
900
- onChange={(e) => setObservations(e.target.value)}
901
- placeholder={t('financial.invoiceForm.additionalNotes')}
902
- rows={2}
903
- className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
904
- />
905
- </div>
906
-
907
- {/* Cancel confirmation */}
908
- {confirmCancel && createPortal(
909
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setConfirmCancel(false)}>
910
- <div className="w-full max-w-sm rounded-modal border bg-card shadow-lg mx-4 p-5" onClick={(e) => e.stopPropagation()} style={{ animation: 'field-slide-in 200ms ease-out' }}>
911
- <div className="flex items-center gap-3 mb-3">
912
- <div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 shrink-0">
913
- <Ban className="h-5 w-5 text-destructive" />
914
- </div>
915
- <div>
916
- <h3 className="text-sm font-semibold">{t('financial.invoiceForm.cancelInvoice')}</h3>
917
- <p className="text-xs text-muted-foreground">{t('financial.invoiceForm.cancelConfirm')}</p>
918
- </div>
919
- </div>
920
- <p className="text-xs text-muted-foreground mb-4">
921
- {t('financial.invoiceForm.cancelDesc')}
922
- </p>
923
- <div className="flex justify-end gap-2">
924
- <Button variant="outline" size="sm" onClick={() => setConfirmCancel(false)}>
925
- {t('financial.invoiceForm.keepInvoice')}
926
- </Button>
927
- <Button variant="destructive" size="sm" onClick={handleCancelInvoice} disabled={cancelling}>
928
- <Ban className="h-3.5 w-3.5" />
929
- {cancelling ? t('financial.invoiceForm.cancelling') : t('financial.invoiceForm.yesCancel')}
930
- </Button>
931
- </div>
932
- </div>
933
- </div>,
934
- document.body
935
- )}
936
-
937
- <style>{`
938
- @keyframes field-slide-in {
939
- from { opacity: 0; transform: translateY(8px); }
940
- to { opacity: 1; transform: translateY(0); }
941
- }
942
- `}</style>
943
- </div>
944
- )
945
- }