@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.
- package/dist/FinancialContext.d.ts +44 -0
- package/dist/FinancialContext.d.ts.map +1 -0
- package/dist/FinancialPage.d.ts +18 -0
- package/dist/FinancialPage.d.ts.map +1 -0
- package/dist/components/FinancialGeneralSettings.d.ts +3 -0
- package/dist/components/FinancialGeneralSettings.d.ts.map +1 -0
- package/dist/components/FinancialOnboarding.d.ts +5 -0
- package/dist/components/FinancialOnboarding.d.ts.map +1 -0
- package/dist/components/FinancialSettings.d.ts +8 -0
- package/dist/components/FinancialSettings.d.ts.map +1 -0
- package/dist/components/PaymentModal.d.ts +8 -0
- package/dist/components/PaymentModal.d.ts.map +1 -0
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +9 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +27 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/index.cjs +4937 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4930 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/tenant.d.ts +3 -0
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/registries.d.ts +3 -0
- package/dist/registries.d.ts.map +1 -0
- package/dist/store.d.ts +38 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +302 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/CardsView.d.ts +3 -0
- package/dist/views/CardsView.d.ts.map +1 -0
- package/dist/views/CashRegistersView.d.ts +3 -0
- package/dist/views/CashRegistersView.d.ts.map +1 -0
- package/dist/views/CommissionsView.d.ts +3 -0
- package/dist/views/CommissionsView.d.ts.map +1 -0
- package/dist/views/InvoiceDetailView.d.ts +9 -0
- package/dist/views/InvoiceDetailView.d.ts.map +1 -0
- package/dist/views/InvoiceFormView.d.ts +13 -0
- package/dist/views/InvoiceFormView.d.ts.map +1 -0
- package/dist/views/InvoiceListView.d.ts +8 -0
- package/dist/views/InvoiceListView.d.ts.map +1 -0
- package/dist/views/PayablesView.d.ts +7 -0
- package/dist/views/PayablesView.d.ts.map +1 -0
- package/dist/views/ReceivablesView.d.ts +7 -0
- package/dist/views/ReceivablesView.d.ts.map +1 -0
- package/dist/views/StatementsView.d.ts +3 -0
- package/dist/views/StatementsView.d.ts.map +1 -0
- package/dist/views/SummaryView.d.ts +9 -0
- package/dist/views/SummaryView.d.ts.map +1 -0
- package/dist/views/dashboardWidgets.d.ts +11 -0
- package/dist/views/dashboardWidgets.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/FinancialContext.tsx +47 -0
- package/src/FinancialPage.tsx +261 -0
- package/src/README.md +155 -0
- package/src/components/FinancialGeneralSettings.tsx +26 -0
- package/src/components/FinancialOnboarding.tsx +87 -0
- package/src/components/FinancialSettings.tsx +31 -0
- package/src/components/PaymentModal.tsx +322 -0
- package/src/data/index.ts +2 -0
- package/src/data/mock.ts +520 -0
- package/src/data/supabase.ts +509 -0
- package/src/data/types.ts +53 -0
- package/src/index.ts +307 -0
- package/src/lib/tenant.ts +3 -0
- package/src/locales/en.ts +331 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +331 -0
- package/src/migrations/001_financial_base.sql +109 -0
- package/src/migrations/002_chart_of_accounts.sql +28 -0
- package/src/migrations/003_card_brands.sql +12 -0
- package/src/migrations/004_order_to_cash.sql +164 -0
- package/src/migrations/005_seed_defaults.sql +38 -0
- package/src/registries.ts +305 -0
- package/src/store.ts +221 -0
- package/src/types.ts +359 -0
- package/src/views/CardsView.tsx +74 -0
- package/src/views/CashRegistersView.tsx +597 -0
- package/src/views/CommissionsView.tsx +18 -0
- package/src/views/InvoiceDetailView.tsx +595 -0
- package/src/views/InvoiceFormView.tsx +900 -0
- package/src/views/InvoiceListView.tsx +177 -0
- package/src/views/PayablesView.tsx +37 -0
- package/src/views/ReceivablesView.tsx +37 -0
- package/src/views/StatementsView.tsx +123 -0
- package/src/views/SummaryView.tsx +12 -0
- 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
|
+
}
|