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