@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,177 @@
|
|
|
1
|
+
import React, { useEffect, useState, useMemo } from 'react'
|
|
2
|
+
import { FileText, CircleDashed, CircleEllipsis, CircleCheckBig, CircleAlert, Ban } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import { ListView } from '@fayz-ai/ui'
|
|
5
|
+
import { useFinancialConfig, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
6
|
+
import { SubpageHeader } from '@fayz-ai/ui'
|
|
7
|
+
import { PersonLink } from '@fayz-ai/saas'
|
|
8
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
9
|
+
import type { TransactionDirection, InvoiceStatus, Invoice } from '../types'
|
|
10
|
+
|
|
11
|
+
const STATUS_OPTIONS: { value: InvoiceStatus; labelKey: string; color: string; icon: React.ElementType }[] = [
|
|
12
|
+
{ value: 'open', labelKey: 'financial.invoice.statusOpen', color: 'bg-info-soft text-info-soft-foreground', icon: CircleDashed },
|
|
13
|
+
{ value: 'partial', labelKey: 'financial.invoice.statusPartial', color: 'bg-warning-soft text-warning-soft-foreground', icon: CircleEllipsis },
|
|
14
|
+
{ value: 'paid', labelKey: 'financial.invoice.statusPaid', color: 'bg-success-soft text-success-soft-foreground', icon: CircleCheckBig },
|
|
15
|
+
{ value: 'overdue', labelKey: 'financial.invoice.statusOverdue', color: 'bg-destructive-soft text-destructive-soft-foreground', icon: CircleAlert },
|
|
16
|
+
{ value: 'cancelled', labelKey: 'financial.invoice.statusCancelled', color: 'bg-muted text-muted-foreground', icon: Ban },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
function StatusBadge({ status }: { status: string }) {
|
|
20
|
+
const t = useTranslation()
|
|
21
|
+
const opt = STATUS_OPTIONS.find((o) => o.value === status)
|
|
22
|
+
const Icon = opt?.icon ?? CircleDashed
|
|
23
|
+
return (
|
|
24
|
+
<span className={`inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[10px] font-medium ${opt?.color ?? 'bg-muted text-muted-foreground'}`}>
|
|
25
|
+
<Icon className="h-2.5 w-2.5" /> {opt ? t(opt.labelKey) : status}
|
|
26
|
+
</span>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function useInvoiceColumns(currency: { code: string; locale: string; symbol: string }): ColumnDef<Invoice, any>[] {
|
|
31
|
+
const t = useTranslation()
|
|
32
|
+
return useMemo(() => [
|
|
33
|
+
{
|
|
34
|
+
accessorKey: 'fiscalNumber', header: '#', size: 110,
|
|
35
|
+
cell: ({ getValue }) => {
|
|
36
|
+
const num = getValue() as string | undefined
|
|
37
|
+
return num ? <span className="text-xs font-mono text-muted-foreground">#{num}</span> : <span className="text-muted-foreground">—</span>
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
accessorKey: 'invoiceDate', header: t('financial.invoice.columnDate'),
|
|
42
|
+
cell: ({ getValue }) => <span className="text-muted-foreground text-xs">{getValue() as string}</span>,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'description', header: t('financial.invoice.columnDescription'),
|
|
46
|
+
cell: ({ row }) => (
|
|
47
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
48
|
+
{row.original.contactName ? (
|
|
49
|
+
<PersonLink personId={row.original.contactId} name={row.original.contactName} size="sm" className="text-sm font-medium" />
|
|
50
|
+
) : (
|
|
51
|
+
<p className="font-medium">—</p>
|
|
52
|
+
)}
|
|
53
|
+
{row.original.itemsSummary && <p className="text-[10px] text-muted-foreground truncate max-w-[250px]">{row.original.itemsSummary}</p>}
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
accessorKey: 'totalAmount', header: t('financial.invoice.columnAmount'), enableSorting: true,
|
|
59
|
+
cell: ({ row }) => (
|
|
60
|
+
<span className={`font-medium ${row.original.status === 'cancelled' ? 'line-through text-muted-foreground' : ''}`}>
|
|
61
|
+
{formatCurrency(row.original.totalAmount, currency)}
|
|
62
|
+
</span>
|
|
63
|
+
),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
accessorKey: 'paidAmount', header: t('financial.invoice.columnPaid'),
|
|
67
|
+
cell: ({ row }) => (
|
|
68
|
+
<span className={row.original.status === 'cancelled' ? 'line-through text-muted-foreground' : 'text-muted-foreground'}>
|
|
69
|
+
{formatCurrency(row.original.paidAmount, currency)}
|
|
70
|
+
</span>
|
|
71
|
+
),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
accessorKey: 'status', header: t('financial.invoice.columnStatus'),
|
|
75
|
+
cell: ({ getValue }) => <StatusBadge status={getValue() as string} />,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
accessorKey: 'totalInstallments', header: t('financial.invoice.columnInst'),
|
|
79
|
+
cell: ({ getValue }) => <span className="text-muted-foreground">{getValue() as number}</span>,
|
|
80
|
+
},
|
|
81
|
+
], [currency, t])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function InvoiceListView({ direction, onNew, onEdit }: {
|
|
85
|
+
direction: TransactionDirection
|
|
86
|
+
onNew?: () => void
|
|
87
|
+
onEdit?: (id: string) => void
|
|
88
|
+
}) {
|
|
89
|
+
const t = useTranslation()
|
|
90
|
+
const { currency } = useFinancialConfig()
|
|
91
|
+
const invoices = useFinancialStore((s) => s.invoices)
|
|
92
|
+
const invoicesTotal = useFinancialStore((s) => s.invoicesTotal)
|
|
93
|
+
const invoicesLoading = useFinancialStore((s) => s.invoicesLoading)
|
|
94
|
+
const fetchInvoices = useFinancialStore((s) => s.fetchInvoices)
|
|
95
|
+
|
|
96
|
+
const [search, setSearch] = useState('')
|
|
97
|
+
const [statusFilter, setStatusFilter] = useState<InvoiceStatus[]>([])
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
fetchInvoices({
|
|
101
|
+
direction,
|
|
102
|
+
status: statusFilter.length > 0 ? statusFilter : undefined,
|
|
103
|
+
search: search || undefined,
|
|
104
|
+
})
|
|
105
|
+
}, [direction, statusFilter, search])
|
|
106
|
+
|
|
107
|
+
const filtered = invoices.filter((inv) => inv.direction === direction)
|
|
108
|
+
const listTitle = direction === 'debit' ? t('financial.invoice.accountsPayable') : t('financial.invoice.accountsReceivable')
|
|
109
|
+
const columns = useInvoiceColumns(currency)
|
|
110
|
+
const tags = useMemo(
|
|
111
|
+
() => STATUS_OPTIONS.filter((o) => o.value !== 'cancelled').map((o) => ({ value: o.value, label: t(o.labelKey) })),
|
|
112
|
+
[t],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// Summary totals from currently visible data
|
|
116
|
+
const summary = useMemo(() => {
|
|
117
|
+
let totalAmount = 0
|
|
118
|
+
let paidAmount = 0
|
|
119
|
+
let openAmount = 0
|
|
120
|
+
for (const inv of filtered) {
|
|
121
|
+
if (inv.status === 'cancelled') continue
|
|
122
|
+
totalAmount += inv.totalAmount
|
|
123
|
+
paidAmount += inv.paidAmount
|
|
124
|
+
}
|
|
125
|
+
openAmount = totalAmount - paidAmount
|
|
126
|
+
return { totalAmount, paidAmount, openAmount }
|
|
127
|
+
}, [filtered])
|
|
128
|
+
|
|
129
|
+
const receivedLabel = direction === 'debit' ? t('financial.invoice.summaryPaid') : t('financial.invoice.summaryReceived')
|
|
130
|
+
const pendingLabel = direction === 'debit' ? t('financial.invoice.summaryPayable') : t('financial.invoice.summaryReceivable')
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="space-y-4">
|
|
134
|
+
<SubpageHeader
|
|
135
|
+
title={listTitle}
|
|
136
|
+
subtitle={t('financial.invoice.invoices', { count: filtered.length })}
|
|
137
|
+
/>
|
|
138
|
+
<ListView<Invoice>
|
|
139
|
+
columns={columns}
|
|
140
|
+
data={filtered}
|
|
141
|
+
loading={invoicesLoading}
|
|
142
|
+
searchPlaceholder={t('financial.invoice.searchPlaceholder')}
|
|
143
|
+
search={search}
|
|
144
|
+
onSearchChange={setSearch}
|
|
145
|
+
searchDebounce={0}
|
|
146
|
+
tags={tags}
|
|
147
|
+
activeTag={statusFilter.length === 1 ? statusFilter[0] : undefined}
|
|
148
|
+
onTagChange={(v) => setStatusFilter(v ? [v as InvoiceStatus] : [])}
|
|
149
|
+
newLabel={t('financial.invoice.new')}
|
|
150
|
+
onNew={onNew}
|
|
151
|
+
onRowClick={(row) => onEdit?.(row.id)}
|
|
152
|
+
emptyIcon={FileText}
|
|
153
|
+
emptyMessage={t('financial.invoice.noInvoices')}
|
|
154
|
+
emptyActionLabel={onNew ? t('financial.invoice.createFirst') : undefined}
|
|
155
|
+
onEmptyAction={onNew}
|
|
156
|
+
/>
|
|
157
|
+
{filtered.length > 0 && !invoicesLoading && (
|
|
158
|
+
<div className="flex items-center justify-end gap-6 rounded-lg border bg-muted/30 px-5 py-3 text-sm">
|
|
159
|
+
<div className="flex items-center gap-2">
|
|
160
|
+
<span className="text-muted-foreground">{t('financial.invoice.summaryTotal')}</span>
|
|
161
|
+
<span className="font-semibold">{formatCurrency(summary.totalAmount, currency)}</span>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="h-4 w-px bg-border" />
|
|
164
|
+
<div className="flex items-center gap-2">
|
|
165
|
+
<span className="text-muted-foreground">{receivedLabel}</span>
|
|
166
|
+
<span className="font-semibold text-success">{formatCurrency(summary.paidAmount, currency)}</span>
|
|
167
|
+
</div>
|
|
168
|
+
<div className="h-4 w-px bg-border" />
|
|
169
|
+
<div className="flex items-center gap-2">
|
|
170
|
+
<span className="text-muted-foreground">{pendingLabel}</span>
|
|
171
|
+
<span className="font-semibold text-warning">{formatCurrency(summary.openAmount, currency)}</span>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { InvoiceListView } from './InvoiceListView'
|
|
3
|
+
import { InvoiceFormView } from './InvoiceFormView'
|
|
4
|
+
import { InvoiceDetailView } from './InvoiceDetailView'
|
|
5
|
+
import type { ViewIntent } from '../FinancialPage'
|
|
6
|
+
|
|
7
|
+
export function PayablesView({ intent, onNavigate }: {
|
|
8
|
+
intent: ViewIntent
|
|
9
|
+
onNavigate: (view: string) => void
|
|
10
|
+
}) {
|
|
11
|
+
if (intent.mode === 'new') {
|
|
12
|
+
return <InvoiceFormView direction="debit" onSaved={(id) => onNavigate(id ? `payables-detail:${id}` : 'payables-list')} />
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (intent.mode === 'edit' && intent.editId) {
|
|
16
|
+
return <InvoiceFormView direction="debit" editId={intent.editId} onSaved={() => onNavigate(`payables-detail:${intent.editId}`)} />
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (intent.mode === 'detail' && intent.editId) {
|
|
20
|
+
return (
|
|
21
|
+
<InvoiceDetailView
|
|
22
|
+
invoiceId={intent.editId}
|
|
23
|
+
direction="debit"
|
|
24
|
+
onBack={() => onNavigate('payables-list')}
|
|
25
|
+
onEdit={() => onNavigate(`payables-edit:${intent.editId}`)}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<InvoiceListView
|
|
32
|
+
direction="debit"
|
|
33
|
+
onNew={() => onNavigate('payables-new')}
|
|
34
|
+
onEdit={(id) => onNavigate(`payables-detail:${id}`)}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { InvoiceListView } from './InvoiceListView'
|
|
3
|
+
import { InvoiceFormView } from './InvoiceFormView'
|
|
4
|
+
import { InvoiceDetailView } from './InvoiceDetailView'
|
|
5
|
+
import type { ViewIntent } from '../FinancialPage'
|
|
6
|
+
|
|
7
|
+
export function ReceivablesView({ intent, onNavigate }: {
|
|
8
|
+
intent: ViewIntent
|
|
9
|
+
onNavigate: (view: string) => void
|
|
10
|
+
}) {
|
|
11
|
+
if (intent.mode === 'new') {
|
|
12
|
+
return <InvoiceFormView direction="credit" onSaved={(id) => onNavigate(id ? `receivables-detail:${id}` : 'receivables-list')} />
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (intent.mode === 'edit' && intent.editId) {
|
|
16
|
+
return <InvoiceFormView direction="credit" editId={intent.editId} onSaved={() => onNavigate(`receivables-detail:${intent.editId}`)} />
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (intent.mode === 'detail' && intent.editId) {
|
|
20
|
+
return (
|
|
21
|
+
<InvoiceDetailView
|
|
22
|
+
invoiceId={intent.editId}
|
|
23
|
+
direction="credit"
|
|
24
|
+
onBack={() => onNavigate('receivables-list')}
|
|
25
|
+
onEdit={() => onNavigate(`receivables-edit:${intent.editId}`)}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<InvoiceListView
|
|
32
|
+
direction="credit"
|
|
33
|
+
onNew={() => onNavigate('receivables-new')}
|
|
34
|
+
onEdit={(id) => onNavigate(`receivables-detail:${id}`)}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React, { useEffect, useState, useMemo } from 'react'
|
|
2
|
+
import { Receipt } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import { DataTable } from '@fayz-ai/ui'
|
|
5
|
+
import { useFinancialConfig, useFinancialStore, formatCurrency } from '../FinancialContext'
|
|
6
|
+
import { SubpageHeader } from '@fayz-ai/ui'
|
|
7
|
+
import { DatePicker } from '@fayz-ai/ui'
|
|
8
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
9
|
+
import type { StatementEntry } from '../types'
|
|
10
|
+
|
|
11
|
+
export function StatementsView() {
|
|
12
|
+
const t = useTranslation()
|
|
13
|
+
const { currency } = useFinancialConfig()
|
|
14
|
+
const bankAccounts = useFinancialStore((s) => s.bankAccounts)
|
|
15
|
+
const statementEntries = useFinancialStore((s) => s.statementEntries)
|
|
16
|
+
const statementLoading = useFinancialStore((s) => s.statementLoading)
|
|
17
|
+
const fetchBankAccounts = useFinancialStore((s) => s.fetchBankAccounts)
|
|
18
|
+
const fetchStatement = useFinancialStore((s) => s.fetchStatement)
|
|
19
|
+
|
|
20
|
+
const [accountId, setAccountId] = useState('')
|
|
21
|
+
const [dateFrom, setDateFrom] = useState(() => {
|
|
22
|
+
const d = new Date(); d.setMonth(d.getMonth() - 1)
|
|
23
|
+
return d.toISOString().slice(0, 10)
|
|
24
|
+
})
|
|
25
|
+
const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10))
|
|
26
|
+
|
|
27
|
+
useEffect(() => { fetchBankAccounts() }, [])
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (accountId) {
|
|
31
|
+
fetchStatement({ bankAccountId: accountId, dateRange: { from: dateFrom, to: dateTo } })
|
|
32
|
+
}
|
|
33
|
+
}, [accountId, dateFrom, dateTo])
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!accountId && bankAccounts.length > 0) setAccountId(bankAccounts[0].id)
|
|
37
|
+
}, [bankAccounts])
|
|
38
|
+
|
|
39
|
+
const columns: ColumnDef<StatementEntry, any>[] = useMemo(() => [
|
|
40
|
+
{
|
|
41
|
+
id: 'date', header: t('financial.statements.columnDate'),
|
|
42
|
+
cell: ({ row }) => (
|
|
43
|
+
<span className="text-muted-foreground text-xs">{row.original.movement.paymentDate ?? row.original.movement.dueDate}</span>
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'description', header: t('financial.statements.columnDescription'),
|
|
48
|
+
cell: ({ row }) => (
|
|
49
|
+
<div>
|
|
50
|
+
<p className="text-xs font-medium">{row.original.invoice?.contactName || row.original.movement.notes || 'Transaction'}</p>
|
|
51
|
+
<p className="text-[10px] text-muted-foreground capitalize">{row.original.movement.movementKind}</p>
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'debit', header: t('financial.statements.columnDebit'),
|
|
57
|
+
cell: ({ row }) => (
|
|
58
|
+
<span className="text-right block text-destructive text-xs">
|
|
59
|
+
{row.original.movement.direction === 'debit' ? formatCurrency(row.original.movement.paidAmount, currency) : ''}
|
|
60
|
+
</span>
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'credit', header: t('financial.statements.columnCredit'),
|
|
65
|
+
cell: ({ row }) => (
|
|
66
|
+
<span className="text-right block text-success text-xs">
|
|
67
|
+
{row.original.movement.direction === 'credit' ? formatCurrency(row.original.movement.paidAmount, currency) : ''}
|
|
68
|
+
</span>
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'balance', header: t('financial.statements.columnBalance'),
|
|
73
|
+
cell: ({ row }) => (
|
|
74
|
+
<span className="text-right block font-medium text-xs">{formatCurrency(row.original.runningBalance, currency)}</span>
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
], [currency, t])
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-4">
|
|
81
|
+
<SubpageHeader title={t('financial.statements.title')} subtitle={t('financial.statements.subtitle')} />
|
|
82
|
+
|
|
83
|
+
{/* Filters */}
|
|
84
|
+
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
|
|
85
|
+
<div className="flex-1 max-w-xs">
|
|
86
|
+
<label className="text-[10px] font-medium text-muted-foreground uppercase">{t('financial.statements.account')}</label>
|
|
87
|
+
<select
|
|
88
|
+
value={accountId}
|
|
89
|
+
onChange={(e) => setAccountId(e.target.value)}
|
|
90
|
+
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-3 py-2 text-sm"
|
|
91
|
+
>
|
|
92
|
+
<option value="">{t('financial.statements.selectAccount')}</option>
|
|
93
|
+
{bankAccounts.map((a) => (
|
|
94
|
+
<option key={a.id} value={a.id}>{a.name}</option>
|
|
95
|
+
))}
|
|
96
|
+
</select>
|
|
97
|
+
</div>
|
|
98
|
+
<div>
|
|
99
|
+
<label className="text-[10px] font-medium text-muted-foreground uppercase">{t('financial.statements.from')}</label>
|
|
100
|
+
<DatePicker value={dateFrom} onChange={setDateFrom} className="mt-0.5" />
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<label className="text-[10px] font-medium text-muted-foreground uppercase">{t('financial.statements.to')}</label>
|
|
104
|
+
<DatePicker value={dateTo} onChange={setDateTo} className="mt-0.5" />
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Statement table */}
|
|
109
|
+
{!accountId ? (
|
|
110
|
+
<div className="flex flex-col items-center justify-center py-12 text-center rounded-lg border-2 border-dashed border-muted">
|
|
111
|
+
<Receipt className="h-8 w-8 text-muted-foreground mb-2" />
|
|
112
|
+
<p className="text-sm text-muted-foreground">{t('financial.statements.selectToView')}</p>
|
|
113
|
+
</div>
|
|
114
|
+
) : statementLoading ? (
|
|
115
|
+
<DataTable columns={columns} data={[]} loading skeletonRows={5} />
|
|
116
|
+
) : statementEntries.length === 0 ? (
|
|
117
|
+
<DataTable columns={columns} data={[]} emptyMessage={t('financial.statements.noTransactions')} />
|
|
118
|
+
) : (
|
|
119
|
+
<DataTable columns={columns} data={statementEntries} />
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { DashboardCanvas } from '@fayz-ai/ui'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Financial summary. KPIs, cash-flow chart, breakdown, overdue alerts and the
|
|
6
|
+
* recent-transactions table are registered dashboard widgets (see
|
|
7
|
+
* ./dashboardWidgets), rendered through the shared DashboardCanvas; the
|
|
8
|
+
* total-balance KPI also surfaces on the global app home.
|
|
9
|
+
*/
|
|
10
|
+
export function SummaryView() {
|
|
11
|
+
return <DashboardCanvas surface="plugin-home" domain="financial" showHeader={false} className="space-y-6" />
|
|
12
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
import { ArrowUpRight, ArrowDownRight, AlertTriangle } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import type { StoreApi } from 'zustand'
|
|
5
|
+
import type { DashboardWidgetDef } from '@fayz-ai/core'
|
|
6
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
7
|
+
import {
|
|
8
|
+
KpiCard, ChartWidget, TableWidget, Card, CardHeader, CardTitle, CardContent,
|
|
9
|
+
defineKpiWidget, defineChartWidget, defineCustomWidget, defineTableWidget,
|
|
10
|
+
} from '@fayz-ai/ui'
|
|
11
|
+
import { FinancialContextProvider, useFinancialConfig, useFinancialStore, formatCurrency, type ResolvedFinancialConfig } from '../FinancialContext'
|
|
12
|
+
import type { FinancialDataProvider } from '../data/types'
|
|
13
|
+
import type { FinancialUIState } from '../store'
|
|
14
|
+
import type { Invoice } from '../types'
|
|
15
|
+
|
|
16
|
+
function useEnsureSummary() {
|
|
17
|
+
const fetchSummary = useFinancialStore((s) => s.fetchSummary)
|
|
18
|
+
useEffect(() => { void fetchSummary() }, [])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// KPIs
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
function TotalBalanceKpi() {
|
|
26
|
+
const t = useTranslation()
|
|
27
|
+
const { currency } = useFinancialConfig()
|
|
28
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
29
|
+
const loading = useFinancialStore((s) => s.summaryLoading)
|
|
30
|
+
useEnsureSummary()
|
|
31
|
+
return <KpiCard label={t('financial.summary.totalBalance')} icon="Wallet" value={formatCurrency(summary?.totalBalance ?? 0, currency)} sub={loading ? undefined : t('financial.summary.allAccounts')} />
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ReceivableKpi() {
|
|
35
|
+
const t = useTranslation()
|
|
36
|
+
const { currency } = useFinancialConfig()
|
|
37
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
38
|
+
useEnsureSummary()
|
|
39
|
+
return <KpiCard label={t('financial.summary.receivable')} icon="TrendingUp" value={formatCurrency(summary?.totalReceivable ?? 0, currency)} sub={t('financial.summary.overdue', { count: String(summary?.overdueReceivableCount ?? 0) })} />
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function PayableKpi() {
|
|
43
|
+
const t = useTranslation()
|
|
44
|
+
const { currency } = useFinancialConfig()
|
|
45
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
46
|
+
useEnsureSummary()
|
|
47
|
+
return <KpiCard label={t('financial.summary.payable')} icon="TrendingDown" value={formatCurrency(summary?.totalPayable ?? 0, currency)} sub={t('financial.summary.overdue', { count: String(summary?.overduePayableCount ?? 0) })} />
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function MonthlyFlowKpi() {
|
|
51
|
+
const t = useTranslation()
|
|
52
|
+
const { currency } = useFinancialConfig()
|
|
53
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
54
|
+
useEnsureSummary()
|
|
55
|
+
const inflow = summary?.monthlyInflow ?? 0
|
|
56
|
+
const outflow = summary?.monthlyOutflow ?? 0
|
|
57
|
+
return <KpiCard label={t('financial.summary.monthlyFlow')} icon="BarChart3" value={formatCurrency(inflow - outflow, currency)} sub={inflow >= outflow ? t('financial.summary.positiveBalance') : t('financial.summary.negativeBalance')} />
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Cash flow (recharts) + breakdown + overdue alerts
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function CashFlowChart() {
|
|
65
|
+
const t = useTranslation()
|
|
66
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
67
|
+
useEnsureSummary()
|
|
68
|
+
const data = [{ name: t('financial.summary.cashFlow'), income: summary?.monthlyInflow ?? 0, expenses: summary?.monthlyOutflow ?? 0 }]
|
|
69
|
+
return (
|
|
70
|
+
<ChartWidget
|
|
71
|
+
type="bar" title={t('financial.summary.cashFlow')} icon="BarChart3" categoryKey="name" data={data}
|
|
72
|
+
series={[
|
|
73
|
+
{ dataKey: 'income', label: t('financial.summary.income'), color: 'hsl(var(--success))' },
|
|
74
|
+
{ dataKey: 'expenses', label: t('financial.summary.expenses'), color: 'hsl(var(--destructive))' },
|
|
75
|
+
]}
|
|
76
|
+
/>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function CashFlowBreakdown() {
|
|
81
|
+
const t = useTranslation()
|
|
82
|
+
const { currency } = useFinancialConfig()
|
|
83
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
84
|
+
useEnsureSummary()
|
|
85
|
+
const inflow = summary?.monthlyInflow ?? 0
|
|
86
|
+
const outflow = summary?.monthlyOutflow ?? 0
|
|
87
|
+
const total = Math.max(inflow + outflow, 1)
|
|
88
|
+
return (
|
|
89
|
+
<Card>
|
|
90
|
+
<CardHeader><CardTitle>{t('financial.summary.cashFlowBreakdown')}</CardTitle></CardHeader>
|
|
91
|
+
<CardContent className="space-y-4">
|
|
92
|
+
<div className="space-y-1.5">
|
|
93
|
+
<div className="flex items-center justify-between text-xs">
|
|
94
|
+
<span className="flex items-center gap-1.5 text-muted-foreground"><span className="h-2 w-2 rounded-full bg-success" />{t('financial.summary.received')}</span>
|
|
95
|
+
<span className="font-medium">{formatCurrency(inflow, currency)}</span>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="h-2 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-success" style={{ width: `${(inflow / total) * 100}%` }} /></div>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="space-y-1.5">
|
|
100
|
+
<div className="flex items-center justify-between text-xs">
|
|
101
|
+
<span className="flex items-center gap-1.5 text-muted-foreground"><span className="h-2 w-2 rounded-full bg-destructive" />{t('financial.summary.paid')}</span>
|
|
102
|
+
<span className="font-medium">{formatCurrency(outflow, currency)}</span>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="h-2 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-destructive" style={{ width: `${(outflow / total) * 100}%` }} /></div>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="border-t pt-2">
|
|
107
|
+
<div className="flex items-center justify-between text-xs">
|
|
108
|
+
<span className="text-muted-foreground">{t('financial.summary.net')}</span>
|
|
109
|
+
<span className={`font-semibold ${inflow - outflow >= 0 ? 'text-success' : 'text-destructive'}`}>{formatCurrency(inflow - outflow, currency)}</span>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</CardContent>
|
|
113
|
+
</Card>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function OverdueAlerts() {
|
|
118
|
+
const t = useTranslation()
|
|
119
|
+
const { currency } = useFinancialConfig()
|
|
120
|
+
const summary = useFinancialStore((s) => s.summary)
|
|
121
|
+
useEnsureSummary()
|
|
122
|
+
const items = [
|
|
123
|
+
{ label: t('financial.summary.overdueReceivable'), value: summary?.overdueReceivableAmount ?? 0, count: summary?.overdueReceivableCount ?? 0, Icon: ArrowUpRight, color: 'text-warning' },
|
|
124
|
+
{ label: t('financial.summary.overduePayable'), value: summary?.overduePayableAmount ?? 0, count: summary?.overduePayableCount ?? 0, Icon: ArrowDownRight, color: 'text-destructive' },
|
|
125
|
+
]
|
|
126
|
+
return (
|
|
127
|
+
<Card>
|
|
128
|
+
<CardHeader><CardTitle className="flex items-center gap-2"><AlertTriangle className="h-4 w-4 text-warning" />{t('financial.summary.overdueTitle')}</CardTitle></CardHeader>
|
|
129
|
+
<CardContent className="space-y-3">
|
|
130
|
+
{items.map((item) => (
|
|
131
|
+
<div key={item.label} className="flex items-center justify-between">
|
|
132
|
+
<div className="flex items-center gap-2"><item.Icon className={`h-3.5 w-3.5 ${item.color}`} /><span className="text-sm text-muted-foreground">{item.label}</span></div>
|
|
133
|
+
<span className="text-sm font-semibold">{formatCurrency(item.value, currency)}</span>
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
{items.every((i) => i.count === 0) ? <p className="border-t pt-3 text-xs text-muted-foreground">{t('financial.summary.noOverdue')}</p> : null}
|
|
137
|
+
</CardContent>
|
|
138
|
+
</Card>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function RecentTransactions() {
|
|
143
|
+
const t = useTranslation()
|
|
144
|
+
const { currency } = useFinancialConfig()
|
|
145
|
+
const invoices = useFinancialStore((s) => s.invoices)
|
|
146
|
+
const fetchInvoices = useFinancialStore((s) => s.fetchInvoices)
|
|
147
|
+
useEffect(() => { void fetchInvoices({ pageSize: 5 }) }, [])
|
|
148
|
+
const columns: ColumnDef<Invoice, unknown>[] = [
|
|
149
|
+
{ accessorKey: 'contactName', header: t('financial.summary.recentTransactions'), cell: ({ row }) => <span className="font-medium">{row.original.contactName || 'Unknown'}</span> },
|
|
150
|
+
{ id: 'date', header: '', cell: ({ row }) => <span className="text-xs capitalize text-muted-foreground">{row.original.direction} · {row.original.invoiceDate}</span> },
|
|
151
|
+
{ accessorKey: 'totalAmount', header: '', cell: ({ row }) => (
|
|
152
|
+
<span className={`font-semibold ${row.original.direction === 'credit' ? 'text-success' : 'text-destructive'}`}>
|
|
153
|
+
{row.original.direction === 'credit' ? '+' : '-'}{formatCurrency(row.original.totalAmount, currency)}
|
|
154
|
+
</span>
|
|
155
|
+
) },
|
|
156
|
+
]
|
|
157
|
+
return <TableWidget title={t('financial.summary.recentTransactions')} icon="CircleDollarSign" columns={columns} data={invoices.slice(0, 5)} emptyMessage={t('financial.summary.noTransactions')} />
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Registry builder
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export function createFinancialDashboardWidgets(ctx: {
|
|
165
|
+
config: ResolvedFinancialConfig
|
|
166
|
+
provider: FinancialDataProvider
|
|
167
|
+
store: StoreApi<FinancialUIState>
|
|
168
|
+
}): DashboardWidgetDef[] {
|
|
169
|
+
const withCtx = (Inner: React.ComponentType): React.ComponentType<unknown> => {
|
|
170
|
+
const Wrapped = () => (
|
|
171
|
+
<FinancialContextProvider config={ctx.config} provider={ctx.provider} store={ctx.store}>
|
|
172
|
+
<Inner />
|
|
173
|
+
</FinancialContextProvider>
|
|
174
|
+
)
|
|
175
|
+
Wrapped.displayName = `FinancialWidget(${Inner.displayName ?? Inner.name})`
|
|
176
|
+
return Wrapped
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return [
|
|
180
|
+
// Total balance is finance's headline KPI on the global home; the rest are
|
|
181
|
+
// home-hidden by default (shown on the financial plugin-home, addable via Customize).
|
|
182
|
+
defineKpiWidget({ id: 'financial.kpi.total-balance', title: 'financial.summary.totalBalance', domain: 'financial', defaultOrder: 0, component: withCtx(TotalBalanceKpi) }),
|
|
183
|
+
defineKpiWidget({ id: 'financial.kpi.receivable', title: 'financial.summary.receivable', domain: 'financial', defaultOrder: 1, defaultVisible: false, component: withCtx(ReceivableKpi) }),
|
|
184
|
+
defineKpiWidget({ id: 'financial.kpi.payable', title: 'financial.summary.payable', domain: 'financial', defaultOrder: 2, defaultVisible: false, component: withCtx(PayableKpi) }),
|
|
185
|
+
defineKpiWidget({ id: 'financial.kpi.monthly-flow', title: 'financial.summary.monthlyFlow', domain: 'financial', defaultOrder: 3, defaultVisible: false, component: withCtx(MonthlyFlowKpi) }),
|
|
186
|
+
defineChartWidget({ id: 'financial.chart.cash-flow', title: 'financial.summary.cashFlow', domain: 'financial', span: 2, defaultOrder: 10, surfaces: ['plugin-home'], component: withCtx(CashFlowChart) }),
|
|
187
|
+
defineCustomWidget({ id: 'financial.panel.breakdown', title: 'financial.summary.cashFlowBreakdown', domain: 'financial', span: 1, defaultOrder: 11, surfaces: ['plugin-home'], component: withCtx(CashFlowBreakdown) }),
|
|
188
|
+
defineCustomWidget({ id: 'financial.panel.overdue', title: 'financial.summary.overdueTitle', domain: 'financial', span: 1, defaultOrder: 12, surfaces: ['plugin-home'], component: withCtx(OverdueAlerts) }),
|
|
189
|
+
defineTableWidget({ id: 'financial.table.recent', title: 'financial.summary.recentTransactions', domain: 'financial', span: 4, defaultOrder: 20, surfaces: ['plugin-home'], component: withCtx(RecentTransactions) }),
|
|
190
|
+
]
|
|
191
|
+
}
|