Expand sales, treasury, and parties modules with Jalali calendar and FunZone integrations
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
49
package-lock.json
generated
49
package-lock.json
generated
@@ -9,11 +9,14 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"jalaali-js": "^2.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jalaali-js": "^1.2.0",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -1217,6 +1220,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jalaali-js": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jalaali-js/-/jalaali-js-1.2.0.tgz",
|
||||
"integrity": "sha512-DRQKjbfFe0/v3siCou9eFzYjDxJItkJpxbU+/CSptvoRqq2/VmTA6y8QvqK/VELKW6xX3qb174nLZlrFV9OT3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
||||
@@ -1807,6 +1817,15 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jalaali-js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-2.0.0.tgz",
|
||||
"integrity": "sha512-HkWlwO3KxuYwERP1jsn+5M+QA+EKpIJ+zGesLee5VJNn2d2Melue0uQIvd9C/0QYFR7XVWvfF/uiNEz9Jbr9Hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -2236,6 +2255,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -2249,6 +2274,30 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-element-popper": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz",
|
||||
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-multi-date-picker": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
|
||||
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-date-object": "^2.1.8",
|
||||
"react-element-popper": "^2.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -8,15 +8,19 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test:integration": "npx tsx src/pages/sales/salesUtils.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"jalaali-js": "^2.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jalaali-js": "^1.2.0",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
||||
@@ -11,8 +11,6 @@ import { Purchases } from './pages/Purchases'
|
||||
import { Inventory } from './pages/Inventory'
|
||||
import { Treasury } from './pages/Treasury'
|
||||
import { Payroll } from './pages/Payroll'
|
||||
import { Owners } from './pages/Owners'
|
||||
import { Customers } from './pages/Customers'
|
||||
import { Reports } from './pages/Reports'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
@@ -30,13 +28,11 @@ function AppRoutes() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/accounts" element={<Accounts />} />
|
||||
<Route path="/vouchers" element={<Vouchers />} />
|
||||
<Route path="/sales" element={<Sales />} />
|
||||
<Route path="/purchases" element={<Purchases />} />
|
||||
<Route path="/sales/*" element={<Sales />} />
|
||||
<Route path="/purchases/*" element={<Purchases />} />
|
||||
<Route path="/inventory" element={<Inventory />} />
|
||||
<Route path="/treasury" element={<Treasury />} />
|
||||
<Route path="/payroll" element={<Payroll />} />
|
||||
<Route path="/owners" element={<Owners />} />
|
||||
<Route path="/customers" element={<Customers />} />
|
||||
<Route path="/reports" element={<Reports />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { Voucher, VoucherAccountMap, VoucherLine } from '../types'
|
||||
import type { Invoice, Party, Voucher, VoucherAccountMap, VoucherLine } from '../types'
|
||||
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
|
||||
import { useStore } from '../store/AppStore'
|
||||
import { createId, nextNumber } from '../utils/id'
|
||||
import { invoiceTotals } from '../utils/accounting'
|
||||
import { isWithdrawalReturn } from '../pages/sales/salesUtils'
|
||||
|
||||
export interface SyncResult {
|
||||
created: number
|
||||
@@ -61,6 +63,7 @@ function buildOwnerVouchers(
|
||||
|
||||
for (const txn of records) {
|
||||
if (txn.status !== 'completed') continue
|
||||
if (txn.type === 'deposit' || txn.type === 'booking_payment') continue
|
||||
const amount = Math.abs(txn.amount)
|
||||
if (amount <= 0) continue
|
||||
|
||||
@@ -73,13 +76,6 @@ function buildOwnerVouchers(
|
||||
let lines: VoucherLine[]
|
||||
let description: string
|
||||
switch (txn.type) {
|
||||
case 'deposit':
|
||||
lines = [
|
||||
makeLine(map.bankAccountId, amount, 0, 'واریز کیف پول مالک'),
|
||||
makeLine(map.ownerPayableAccountId, 0, amount, 'بدهی به مالک'),
|
||||
]
|
||||
description = 'واریز به کیف پول مالک'
|
||||
break
|
||||
case 'withdraw':
|
||||
lines = [
|
||||
makeLine(map.ownerPayableAccountId, amount, 0, 'تسویه با مالک'),
|
||||
@@ -87,13 +83,6 @@ function buildOwnerVouchers(
|
||||
]
|
||||
description = 'برداشت مالک از کیف پول'
|
||||
break
|
||||
case 'booking_payment':
|
||||
lines = [
|
||||
makeLine(map.ownerShareAccountId, amount, 0, 'سهم مالک از رزرو'),
|
||||
makeLine(map.ownerPayableAccountId, 0, amount, 'بدهی به مالک'),
|
||||
]
|
||||
description = 'سهم مالک از فروش رزرو'
|
||||
break
|
||||
case 'refund':
|
||||
lines = [
|
||||
makeLine(map.ownerPayableAccountId, amount, 0, 'بازپرداخت به مالک'),
|
||||
@@ -112,11 +101,29 @@ function buildOwnerVouchers(
|
||||
return { vouchers, skipped }
|
||||
}
|
||||
|
||||
function walletWithdrawAlreadyBooked(
|
||||
invoices: Invoice[],
|
||||
parties: Party[],
|
||||
customerName: string,
|
||||
amount: number,
|
||||
): boolean {
|
||||
for (const inv of invoices) {
|
||||
if (!isWithdrawalReturn(inv) || inv.status === 'draft') continue
|
||||
const party = parties.find((p) => p.id === inv.partyId)
|
||||
const net = invoiceTotals(inv).net
|
||||
if (Math.abs(net - amount) > 0.01) continue
|
||||
if (inv.note.includes(customerName) || party?.name === customerName) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function buildCustomerVouchers(
|
||||
records: ApiPaymentOrRefund[],
|
||||
map: VoucherAccountMap,
|
||||
existingSources: Set<string>,
|
||||
startNumber: number,
|
||||
invoices: Invoice[],
|
||||
parties: Party[],
|
||||
): { vouchers: Voucher[]; skipped: number } {
|
||||
const vouchers: Voucher[] = []
|
||||
let skipped = 0
|
||||
@@ -152,6 +159,10 @@ function buildCustomerVouchers(
|
||||
description = `فروش/رزرو - ${label}`
|
||||
}
|
||||
} else if (isWallet) {
|
||||
if (walletWithdrawAlreadyBooked(invoices, parties, label, amount)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
lines = [
|
||||
makeLine(map.customerPayableAccountId, amount, 0, 'برداشت کیف پول'),
|
||||
makeLine(map.bankAccountId, 0, amount, 'پرداخت از بانک'),
|
||||
@@ -210,8 +221,10 @@ export function useVoucherSync() {
|
||||
|
||||
const syncCustomerActivity = useCallback(
|
||||
(records: ApiPaymentOrRefund[]): Promise<SyncResult> =>
|
||||
run((map, sources, start) => buildCustomerVouchers(records, map, sources, start)),
|
||||
[run],
|
||||
run((map, sources, start) =>
|
||||
buildCustomerVouchers(records, map, sources, start, data.invoices, data.parties),
|
||||
),
|
||||
[run, data.invoices, data.parties],
|
||||
)
|
||||
|
||||
return { syncOwnerTransactions, syncCustomerActivity }
|
||||
|
||||
@@ -50,6 +50,8 @@ export const ACCOUNTING_ENDPOINTS = {
|
||||
PRODUCTS: '/accounting/products/',
|
||||
VOUCHERS: '/accounting/vouchers/',
|
||||
INVOICES: '/accounting/invoices/',
|
||||
SALES_TYPES: '/accounting/sales-types/',
|
||||
PARTY_ADJUSTMENTS: '/accounting/party-adjustments/',
|
||||
TREASURY: '/accounting/treasury/',
|
||||
EMPLOYEES: '/accounting/employees/',
|
||||
PAYSLIPS: '/accounting/payslips/',
|
||||
|
||||
@@ -6,10 +6,14 @@ export interface ApiOwner {
|
||||
mobile_number: string | null
|
||||
email: string | null
|
||||
address: string | null
|
||||
national_code: string | null
|
||||
balance: number
|
||||
credit_number: string | null
|
||||
is_active: boolean
|
||||
is_verified?: boolean
|
||||
created_at: string
|
||||
birthday?: string | null
|
||||
avatar?: string | null
|
||||
social_hubs?: string[]
|
||||
}
|
||||
|
||||
@@ -21,10 +25,17 @@ export interface ApiCustomer {
|
||||
mobile_number: string | null
|
||||
email: string | null
|
||||
address: string | null
|
||||
national_code: number | string | null
|
||||
balance: number
|
||||
iban: string | null
|
||||
is_active: boolean
|
||||
is_verified?: boolean
|
||||
created_at: string
|
||||
birthday?: string | null
|
||||
avatar?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
favorites?: string[]
|
||||
}
|
||||
|
||||
export type WalletTxnType = 'deposit' | 'withdraw' | 'booking_payment' | 'refund'
|
||||
@@ -69,3 +80,9 @@ export interface ApiWithdrawal {
|
||||
iban: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const ownerFullName = (owner: ApiOwner): string =>
|
||||
`${owner.f_name ?? ''} ${owner.l_name ?? ''}`.trim() || owner.username || 'بدون نام'
|
||||
|
||||
export const customerFullName = (customer: ApiCustomer): string =>
|
||||
`${customer.f_name ?? ''} ${customer.l_name ?? ''}`.trim() || customer.username || 'بدون نام'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DataTable,
|
||||
Field,
|
||||
Input,
|
||||
JalaliDateInput,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Select,
|
||||
@@ -16,8 +17,10 @@ import {
|
||||
} from '../ui'
|
||||
import type { Invoice, InvoiceKind, InvoiceLine, InvoiceStatus, Party } from '../../types'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
import { formatDate, formatMoney, toFa } from '../../utils/format'
|
||||
import { formatDate, formatMoney, todayISO, toFa } from '../../utils/format'
|
||||
import { invoiceTotals, lineTotals } from '../../utils/accounting'
|
||||
import { customers, reconcileInvoicePaymentStatus, suppliers } from '../../pages/sales/salesUtils'
|
||||
import { invoiceSideEffects, invoiceDeleteEffects } from '../../pages/sales/invoiceEffects'
|
||||
|
||||
interface TradeConfig {
|
||||
kind: InvoiceKind
|
||||
@@ -25,6 +28,7 @@ interface TradeConfig {
|
||||
subtitle: string
|
||||
partyLabel: string
|
||||
partyTabLabel: string
|
||||
hidePartyTab?: boolean
|
||||
}
|
||||
|
||||
const statusLabels: Record<InvoiceStatus, string> = {
|
||||
@@ -40,22 +44,28 @@ const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
|
||||
}
|
||||
|
||||
export function TradeModule({ config }: { config: TradeConfig }) {
|
||||
const [tab, setTab] = useState<'invoices' | 'parties'>('invoices')
|
||||
const [tab, setTab] = useState<'invoices' | 'parties'>(config.hidePartyTab ? 'invoices' : 'invoices')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={config.title} subtitle={config.subtitle} />
|
||||
|
||||
<div className="inline-flex rounded-xl bg-slate-200/60 p-1">
|
||||
<TabButton active={tab === 'invoices'} onClick={() => setTab('invoices')}>
|
||||
فاکتورها
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'parties'} onClick={() => setTab('parties')}>
|
||||
{config.partyTabLabel}
|
||||
</TabButton>
|
||||
</div>
|
||||
{!config.hidePartyTab && (
|
||||
<div className="inline-flex rounded-xl bg-slate-200/60 p-1">
|
||||
<TabButton active={tab === 'invoices'} onClick={() => setTab('invoices')}>
|
||||
فاکتورها
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'parties'} onClick={() => setTab('parties')}>
|
||||
{config.partyTabLabel}
|
||||
</TabButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'invoices' ? <InvoiceSection config={config} /> : <PartySection config={config} />}
|
||||
{tab === 'invoices' || config.hidePartyTab ? (
|
||||
<InvoiceSection config={config} />
|
||||
) : (
|
||||
<PartySection config={config} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -93,12 +103,14 @@ const newInvoiceLine = (): InvoiceLine => ({
|
||||
})
|
||||
|
||||
function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
const { data, upsertInvoice, removeInvoice } = useStore()
|
||||
const { data, upsertInvoice, removeInvoice, upsertProduct, upsertVoucher, removeVoucher } = useStore()
|
||||
const [draft, setDraft] = useState<Invoice | null>(null)
|
||||
const [toDelete, setToDelete] = useState<Invoice | null>(null)
|
||||
const [previousDraft, setPreviousDraft] = useState<Invoice | null>(null)
|
||||
|
||||
const parties = useMemo(
|
||||
() => data.parties.filter((party) => party.kind === (config.kind === 'sale' ? 'customer' : 'supplier')),
|
||||
() =>
|
||||
(config.kind === 'sale' ? customers(data.parties) : suppliers(data.parties)).filter((p) => p.id),
|
||||
[data.parties, config.kind],
|
||||
)
|
||||
const invoices = useMemo(
|
||||
@@ -114,9 +126,10 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
const createDraft = (): Invoice => ({
|
||||
id: '',
|
||||
kind: config.kind,
|
||||
documentType: 'invoice',
|
||||
number: nextNumber(data.invoices.filter((i) => i.kind === config.kind)),
|
||||
partyId: parties[0]?.id ?? '',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
date: todayISO(),
|
||||
status: 'draft',
|
||||
note: '',
|
||||
lines: [newInvoiceLine()],
|
||||
@@ -143,8 +156,37 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
if (!draft || !draft.partyId) return
|
||||
const cleanLines = draft.lines.filter((line) => line.productId && line.quantity > 0)
|
||||
if (cleanLines.length === 0) return
|
||||
upsertInvoice({ ...draft, lines: cleanLines, id: draft.id || createId('inv-') })
|
||||
|
||||
const saved: Invoice = {
|
||||
...draft,
|
||||
kind: config.kind,
|
||||
documentType: draft.documentType ?? 'invoice',
|
||||
lines: cleanLines,
|
||||
id: draft.id || createId('inv-'),
|
||||
}
|
||||
|
||||
const effects = invoiceSideEffects(saved, previousDraft, data)
|
||||
for (const product of effects.products) {
|
||||
const original = data.products.find((p) => p.id === product.id)
|
||||
if (original && original.stock !== product.stock) upsertProduct(product)
|
||||
}
|
||||
|
||||
upsertInvoice(saved)
|
||||
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||
if (effects.voucher) upsertVoucher(effects.voucher)
|
||||
|
||||
if (saved.partyId && saved.status !== 'draft') {
|
||||
const dataAfterSave = {
|
||||
...data,
|
||||
invoices: [...data.invoices.filter((i) => i.id !== saved.id), saved],
|
||||
}
|
||||
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
||||
upsertInvoice(inv)
|
||||
}
|
||||
}
|
||||
|
||||
setDraft(null)
|
||||
setPreviousDraft(null)
|
||||
}
|
||||
|
||||
const totals = draft ? invoiceTotals(draft) : null
|
||||
@@ -172,7 +214,10 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
align: 'end',
|
||||
render: (i) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...i, lines: i.lines.map((l) => ({ ...l })) })}>
|
||||
<Button size="sm" variant="ghost" onClick={() => {
|
||||
setPreviousDraft({ ...i, lines: i.lines.map((l) => ({ ...l })) })
|
||||
setDraft({ ...i, lines: i.lines.map((l) => ({ ...l })) })
|
||||
}}>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(i)}>
|
||||
@@ -187,13 +232,15 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
<>
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button icon="+" onClick={() => setDraft(createDraft())} disabled={parties.length === 0}>
|
||||
<Button icon="+" onClick={() => { setPreviousDraft(null); setDraft(createDraft()) }} disabled={parties.length === 0}>
|
||||
فاکتور جدید
|
||||
</Button>
|
||||
</div>
|
||||
{parties.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">
|
||||
ابتدا حداقل یک {config.partyLabel} تعریف کنید.
|
||||
{config.kind === 'sale'
|
||||
? 'مشتریای از فانزون همگام نشده است. به بخش مشتریان بروید و «بروزرسانی» را بزنید.'
|
||||
: 'تأمینکنندهای از فانزون همگام نشده است. به بخش تأمینکنندگان بروید و «بروزرسانی» را بزنید.'}
|
||||
</p>
|
||||
) : (
|
||||
<DataTable
|
||||
@@ -208,7 +255,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={draft?.id ? `ویرایش فاکتور #${toFa(draft.number)}` : 'فاکتور جدید'}
|
||||
onClose={() => setDraft(null)}
|
||||
onClose={() => { setDraft(null); setPreviousDraft(null) }}
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
@@ -221,7 +268,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||
<Button variant="secondary" onClick={() => { setDraft(null); setPreviousDraft(null) }}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>ذخیره فاکتور</Button>
|
||||
@@ -239,7 +286,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="تاریخ">
|
||||
<Input type="date" value={draft.date} onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
|
||||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||
</Field>
|
||||
<Field label={config.partyLabel}>
|
||||
<Select value={draft.partyId} onChange={(e) => setDraft({ ...draft, partyId: e.target.value })}>
|
||||
@@ -360,6 +407,12 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
placeholder="توضیحات فاکتور…"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{draft.status !== 'draft' && (
|
||||
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -370,7 +423,15 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
||||
message={`فاکتور شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
|
||||
onCancel={() => setToDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (toDelete) removeInvoice(toDelete.id)
|
||||
if (toDelete) {
|
||||
const effects = invoiceDeleteEffects(toDelete, data)
|
||||
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||
for (const product of effects.products) {
|
||||
const original = data.products.find((p) => p.id === product.id)
|
||||
if (original && original.stock !== product.stock) upsertProduct(product)
|
||||
}
|
||||
removeInvoice(toDelete.id)
|
||||
}
|
||||
setToDelete(null)
|
||||
}}
|
||||
/>
|
||||
@@ -387,8 +448,8 @@ function PartySection({ config }: { config: TradeConfig }) {
|
||||
const partyKind = config.kind === 'sale' ? 'customer' : 'supplier'
|
||||
|
||||
const parties = useMemo(
|
||||
() => data.parties.filter((party) => party.kind === partyKind),
|
||||
[data.parties, partyKind],
|
||||
() => (config.kind === 'sale' ? customers(data.parties) : suppliers(data.parties)),
|
||||
[data.parties, config.kind],
|
||||
)
|
||||
|
||||
const createDraft = (): Party => ({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { navItems } from './navigation'
|
||||
import { useConnection } from '../../connection/ConnectionContext'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
import { ConnectionRequired } from '../business/ConnectionRequired'
|
||||
import { GlobalPartySync } from '../../parties/GlobalPartySync'
|
||||
import { Button, Card } from '../ui'
|
||||
|
||||
function DataGate({ children }: { children: React.ReactNode }) {
|
||||
@@ -51,6 +52,15 @@ function DataGate({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function DataGateInner({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<GlobalPartySync />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
@@ -96,10 +106,12 @@ export function Layout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 lg:px-8">
|
||||
<main className="mx-auto w-full max-w-screen-2xl flex-1 px-4 py-6 lg:px-8">
|
||||
<div className="animate-fade-in">
|
||||
<DataGate>
|
||||
<Outlet />
|
||||
<DataGateInner>
|
||||
<Outlet />
|
||||
</DataGateInner>
|
||||
</DataGate>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { navItems, type NavItem } from './navigation'
|
||||
import { salesNavItems } from '../../pages/sales/salesNavigation'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
|
||||
const purchaseNavItems = [
|
||||
{ to: '/purchases/suppliers', label: 'تأمینکنندگان', icon: '🏢' },
|
||||
{ to: '/purchases/invoices', label: 'فاکتورهای خرید', icon: '🛒' },
|
||||
]
|
||||
|
||||
function groupItems(items: NavItem[]): Array<[string, NavItem[]]> {
|
||||
const map = new Map<string, NavItem[]>()
|
||||
for (const item of items) {
|
||||
@@ -14,7 +20,20 @@ function groupItems(items: NavItem[]): Array<[string, NavItem[]]> {
|
||||
|
||||
export function Sidebar({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
|
||||
const { data } = useStore()
|
||||
const location = useLocation()
|
||||
const groups = groupItems(navItems)
|
||||
const salesExpanded = location.pathname.startsWith('/sales')
|
||||
const purchasesExpanded = location.pathname.startsWith('/purchases')
|
||||
|
||||
const linkClass = (isActive: boolean) =>
|
||||
`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`
|
||||
|
||||
const subLinkClass = (isActive: boolean) =>
|
||||
`flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium transition-colors ${
|
||||
isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-800'
|
||||
}`
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -38,22 +57,50 @@ export function Sidebar({ open, onNavigate }: { open: boolean; onNavigate: () =>
|
||||
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">{group}</p>
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-50 text-brand-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
<div key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) => linkClass(isActive)}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
|
||||
{item.to === '/sales' && salesExpanded && (
|
||||
<div className="me-2 mt-1 space-y-0.5 border-r-2 border-brand-100 pr-2">
|
||||
{salesNavItems.map((sub) => (
|
||||
<NavLink
|
||||
key={sub.to}
|
||||
to={sub.to}
|
||||
end={sub.end}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) => subLinkClass(isActive)}
|
||||
>
|
||||
<span>{sub.icon}</span>
|
||||
<span>{sub.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.to === '/purchases' && purchasesExpanded && (
|
||||
<div className="me-2 mt-1 space-y-0.5 border-r-2 border-brand-100 pr-2">
|
||||
{purchaseNavItems.map((sub) => (
|
||||
<NavLink
|
||||
key={sub.to}
|
||||
to={sub.to}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) => subLinkClass(isActive)}
|
||||
>
|
||||
<span>{sub.icon}</span>
|
||||
<span>{sub.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,9 +18,6 @@ export const navItems: NavItem[] = [
|
||||
{ to: '/treasury', label: 'دریافت و پرداخت', icon: '💰', group: 'عملیات' },
|
||||
{ to: '/payroll', label: 'حقوق و دستمزد', icon: '👥', group: 'عملیات' },
|
||||
|
||||
{ to: '/owners', label: 'مالکان مجموعهها', icon: '🏢', group: 'مدیریت فانزون' },
|
||||
{ to: '/customers', label: 'کاربران', icon: '🧑🤝🧑', group: 'مدیریت فانزون' },
|
||||
|
||||
{ to: '/reports', label: 'گزارشها', icon: '📊', group: 'تحلیل' },
|
||||
{ to: '/settings', label: 'تنظیمات', icon: '⚙️', group: 'سیستم' },
|
||||
]
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { EmptyState } from './primitives'
|
||||
|
||||
export type ColumnSticky = boolean | { left?: string }
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: ReactNode
|
||||
render: (row: T) => ReactNode
|
||||
align?: 'start' | 'center' | 'end'
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
/** Pin column while scrolling horizontally (left edge in RTL layout). */
|
||||
sticky?: ColumnSticky
|
||||
/** Truncate overflowing cell text with ellipsis. */
|
||||
truncate?: boolean
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
@@ -17,6 +24,11 @@ interface DataTableProps<T> {
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
footer?: ReactNode
|
||||
/** Minimum table width before horizontal scroll (default 720px). */
|
||||
minWidth?: string
|
||||
/** Row density — comfortable uses larger text and padding. */
|
||||
size?: 'default' | 'comfortable'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const alignClass = {
|
||||
@@ -25,6 +37,38 @@ const alignClass = {
|
||||
end: 'text-end',
|
||||
} as const
|
||||
|
||||
const sizeStyles = {
|
||||
default: {
|
||||
table: 'text-sm',
|
||||
cell: 'px-3 py-3 sm:px-4',
|
||||
head: 'text-slate-500',
|
||||
},
|
||||
comfortable: {
|
||||
table: 'text-base',
|
||||
cell: 'px-4 py-4 sm:px-5',
|
||||
head: 'text-slate-600',
|
||||
},
|
||||
} as const
|
||||
|
||||
function stickyLeft(column: { sticky?: ColumnSticky }): string {
|
||||
if (!column.sticky) return '0'
|
||||
if (typeof column.sticky === 'object') return column.sticky.left ?? '0'
|
||||
return '0'
|
||||
}
|
||||
|
||||
function stickyCellClass(isHeader: boolean): string {
|
||||
return [
|
||||
'sticky',
|
||||
isHeader ? 'bg-slate-50' : 'bg-white group-hover:bg-slate-50',
|
||||
'shadow-[2px_0_8px_-2px_rgba(15,23,42,0.12)]',
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function stickyStyle(column: { sticky?: ColumnSticky }, zIndex?: number): CSSProperties | undefined {
|
||||
if (!column.sticky) return undefined
|
||||
return { left: stickyLeft(column), zIndex: zIndex ?? 10 }
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
@@ -33,20 +77,31 @@ export function DataTable<T>({
|
||||
emptyTitle = 'موردی یافت نشد',
|
||||
emptyDescription,
|
||||
footer,
|
||||
minWidth = '720px',
|
||||
size = 'default',
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} />
|
||||
}
|
||||
|
||||
const density = sizeStyles[size]
|
||||
const stickyColumns = columns.filter((c) => c.sticky)
|
||||
const stickyZ = (column: Column<T>) => {
|
||||
const idx = stickyColumns.indexOf(column)
|
||||
return idx >= 0 ? (stickyColumns.length - idx) * 10 + 10 : 0
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<div className={`overflow-x-auto ${className ?? ''}`}>
|
||||
<table className={`w-full table-fixed border-collapse ${density.table}`} style={{ minWidth }}>
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50/70">
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`whitespace-nowrap px-4 py-3 font-semibold text-slate-500 ${alignClass[column.align ?? 'start']}`}
|
||||
style={stickyStyle(column, column.sticky ? stickyZ(column) + 10 : undefined)}
|
||||
className={`whitespace-nowrap ${density.cell} font-semibold ${density.head} ${alignClass[column.align ?? 'start']} ${column.headerClassName ?? ''} ${column.sticky ? stickyCellClass(true) : ''}`}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
@@ -58,12 +113,13 @@ export function DataTable<T>({
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={`transition-colors ${onRowClick ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-slate-50'}`}
|
||||
className={`group transition-colors ${onRowClick ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-slate-50'}`}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`px-4 py-3 text-slate-700 ${alignClass[column.align ?? 'start']} ${column.className ?? ''}`}
|
||||
style={stickyStyle(column, column.sticky ? stickyZ(column) : undefined)}
|
||||
className={`${density.cell} text-slate-700 ${alignClass[column.align ?? 'start']} ${column.className ?? ''} ${column.truncate ? 'max-w-0 overflow-hidden' : ''} ${column.sticky ? stickyCellClass(false) : ''}`}
|
||||
>
|
||||
{column.render(row)}
|
||||
</td>
|
||||
|
||||
61
src/components/ui/JalaliDateInput.tsx
Normal file
61
src/components/ui/JalaliDateInput.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react'
|
||||
import DatePicker from 'react-multi-date-picker'
|
||||
import type { DateObject } from 'react-multi-date-picker'
|
||||
import persian from 'react-date-object/calendars/persian'
|
||||
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||
import { dateToISO, parseISODateLocal } from '../../utils/solarHijriCalendar'
|
||||
|
||||
const inputClassName =
|
||||
'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100'
|
||||
|
||||
interface JalaliDateInputProps {
|
||||
value: string
|
||||
onChange: (iso: string) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
min?: string
|
||||
max?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
/** Persian (Solar Hijri) date picker — same UX as FunZone SolarHijriDatePicker. */
|
||||
export function JalaliDateInput({
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
disabled = false,
|
||||
min,
|
||||
max,
|
||||
placeholder = 'انتخاب تاریخ',
|
||||
}: JalaliDateInputProps) {
|
||||
const pickerValue = useMemo(() => {
|
||||
if (!value) return undefined
|
||||
const date = parseISODateLocal(value)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date
|
||||
}, [value])
|
||||
|
||||
const handleChange = (date: DateObject | DateObject[] | null) => {
|
||||
if (!date || Array.isArray(date)) {
|
||||
onChange('')
|
||||
return
|
||||
}
|
||||
onChange(dateToISO(date.toDate()))
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
value={pickerValue}
|
||||
onChange={handleChange}
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
format="YYYY/MM/DD"
|
||||
calendarPosition="bottom-right"
|
||||
disabled={disabled}
|
||||
minDate={min ? parseISODateLocal(min) : undefined}
|
||||
maxDate={max ? parseISODateLocal(max) : undefined}
|
||||
containerClassName="w-full"
|
||||
inputClass={`${inputClassName} ${className}`.trim()}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -13,3 +13,4 @@ export {
|
||||
export { Modal } from './Modal'
|
||||
export { DataTable, type Column } from './DataTable'
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
export { JalaliDateInput } from './JalaliDateInput'
|
||||
|
||||
@@ -60,3 +60,18 @@ body {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Persian (Jalali) date picker — RTL alignment like FunZone */
|
||||
.rmdp-wrapper {
|
||||
direction: rtl;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.rmdp-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rmdp-input {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import 'react-multi-date-picker/styles/colors/teal.css'
|
||||
import 'react-multi-date-picker/styles/layouts/prime.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useConnection } from '../connection/ConnectionContext'
|
||||
import { useApiResource } from '../hooks/useApiResource'
|
||||
import { apiGet } from '../api/client'
|
||||
import { ENDPOINTS } from '../api/config'
|
||||
import type { ApiCustomer, ApiPaymentOrRefund, ApiWithdrawal } from '../api/types'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Input,
|
||||
PageHeader,
|
||||
StatCard,
|
||||
type Column,
|
||||
} from '../components/ui'
|
||||
import { ConnectionRequired } from '../components/business/ConnectionRequired'
|
||||
import { useVoucherSync } from '../accounting/voucherSync'
|
||||
import { formatDateTime, formatMoney, toFa } from '../utils/format'
|
||||
|
||||
const customerFullName = (customer: ApiCustomer): string =>
|
||||
`${customer.f_name ?? ''} ${customer.l_name ?? ''}`.trim() || customer.username || 'بدون نام'
|
||||
|
||||
export function Customers() {
|
||||
const { connected } = useConnection()
|
||||
const { syncCustomerActivity } = useVoucherSync()
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<ApiCustomer | null>(null)
|
||||
const [syncBusy, setSyncBusy] = useState(false)
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||
|
||||
const customers = useApiResource(
|
||||
() =>
|
||||
apiGet<unknown>(ENDPOINTS.CUSTOMERS).then((data) => {
|
||||
if (Array.isArray(data)) return data as ApiCustomer[]
|
||||
const record = data as { results?: ApiCustomer[] }
|
||||
return record.results ?? []
|
||||
}),
|
||||
[connected],
|
||||
connected,
|
||||
)
|
||||
const withdrawals = useApiResource(
|
||||
() =>
|
||||
apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS).then((res) => res.withdrawals ?? []),
|
||||
[connected],
|
||||
connected,
|
||||
)
|
||||
|
||||
const list = customers.data ?? []
|
||||
const customerWithdrawals = useMemo(
|
||||
() => (withdrawals.data ?? []).filter((w) => w.user_type === 'customer'),
|
||||
[withdrawals.data],
|
||||
)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalBalance = list.reduce((sum, c) => sum + (c.balance || 0), 0)
|
||||
const pending = customerWithdrawals.filter((w) => w.status === 'pending').length
|
||||
return { count: list.length, totalBalance, pending }
|
||||
}, [list, customerWithdrawals])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (!query) return list
|
||||
return list.filter((c) => {
|
||||
const name = customerFullName(c).toLowerCase()
|
||||
return (
|
||||
name.includes(query) ||
|
||||
(c.username ?? '').toLowerCase().includes(query) ||
|
||||
(c.mobile_number ?? '').includes(query)
|
||||
)
|
||||
})
|
||||
}, [list, search])
|
||||
|
||||
const handleBulkSync = async () => {
|
||||
setSyncBusy(true)
|
||||
setSyncMsg(null)
|
||||
try {
|
||||
const all = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
||||
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
|
||||
).then((res) => res.payments_and_refunds ?? [])
|
||||
const res = await syncCustomerActivity(all)
|
||||
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
|
||||
} catch (err) {
|
||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
||||
} finally {
|
||||
setSyncBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="کاربران (مشتریان)" subtitle="اطلاعات مالی کاربران از اپلیکیشن مشتریان" />
|
||||
<ConnectionRequired />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return <CustomerDetail customer={selected} withdrawals={customerWithdrawals} onBack={() => setSelected(null)} />
|
||||
}
|
||||
|
||||
const columns: Array<Column<ApiCustomer>> = [
|
||||
{ key: 'name', header: 'نام کاربر', render: (c) => <span className="font-medium text-slate-800">{customerFullName(c)}</span> },
|
||||
{ key: 'username', header: 'نام کاربری', render: (c) => c.username || '—' },
|
||||
{ key: 'mobile', header: 'موبایل', render: (c) => <span className="tabular-nums">{toFa(c.mobile_number ?? '') || '—'}</span> },
|
||||
{ key: 'iban', header: 'شبا', render: (c) => <span className="font-mono text-xs text-slate-500">{c.iban || '—'}</span> },
|
||||
{
|
||||
key: 'balance',
|
||||
header: 'موجودی کیف پول',
|
||||
align: 'end',
|
||||
render: (c) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(c.balance || 0)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
render: (c) => (
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelected(c)}>
|
||||
مشاهده پرداختها
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="کاربران (مشتریان)"
|
||||
subtitle="اطلاعات مالی کاربران از اپلیکیشن مشتریان"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
|
||||
{syncBusy ? 'در حال صدور…' : 'صدور سند همه پرداختها'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="↻"
|
||||
onClick={() => {
|
||||
customers.reload()
|
||||
withdrawals.reload()
|
||||
}}
|
||||
>
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{syncMsg && (
|
||||
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard label="تعداد کاربران" value={toFa(summary.count)} icon="👥" tone="brand" />
|
||||
<StatCard label="مجموع موجودی کیف پولها" value={formatMoney(summary.totalBalance)} icon="💳" tone="green" />
|
||||
<StatCard label="برداشتهای در انتظار" value={toFa(summary.pending)} icon="⏳" tone={summary.pending > 0 ? 'amber' : 'green'} />
|
||||
</div>
|
||||
|
||||
{customers.error && (
|
||||
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{customers.error}</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<Input
|
||||
placeholder="جستجوی کاربر بر اساس نام، نام کاربری یا موبایل…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="mb-4 max-w-sm"
|
||||
/>
|
||||
{customers.loading ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
||||
) : (
|
||||
<DataTable columns={columns} rows={filtered} rowKey={(c) => c.id} onRowClick={(c) => setSelected(c)} emptyTitle="کاربری یافت نشد" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerDetail({
|
||||
customer,
|
||||
withdrawals,
|
||||
onBack,
|
||||
}: {
|
||||
customer: ApiCustomer
|
||||
withdrawals: ApiWithdrawal[]
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { syncCustomerActivity } = useVoucherSync()
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||
const [syncBusy, setSyncBusy] = useState(false)
|
||||
|
||||
const activity = useApiResource(
|
||||
() =>
|
||||
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
||||
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(customer.id),
|
||||
).then((res) => res.payments_and_refunds ?? []),
|
||||
[customer.id],
|
||||
)
|
||||
|
||||
const rows = activity.data ?? []
|
||||
const customerWithdrawals = withdrawals.filter((w) => w.user_id === customer.id)
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncBusy(true)
|
||||
try {
|
||||
const res = await syncCustomerActivity(rows)
|
||||
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
|
||||
} catch (err) {
|
||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
||||
} finally {
|
||||
setSyncBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const payments = rows.filter((r) => r.type === 'payment').reduce((s, r) => s + r.amount, 0)
|
||||
const refunds = rows.filter((r) => r.type === 'cancellation').reduce((s, r) => s + r.amount, 0)
|
||||
return { payments, refunds }
|
||||
}, [rows])
|
||||
|
||||
const columns: Array<Column<ApiPaymentOrRefund>> = [
|
||||
{ key: 'date', header: 'تاریخ و زمان', render: (r) => formatDateTime(r.created_at) },
|
||||
{
|
||||
key: 'type',
|
||||
header: 'نوع',
|
||||
render: (r) => (
|
||||
<Badge tone={r.type === 'payment' ? 'blue' : 'amber'}>
|
||||
{r.type === 'payment' ? 'پرداخت' : 'لغو/بازپرداخت'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'event', header: 'رویداد/کیف پول', render: (r) => (r.event_name === 'wallet' ? 'کیف پول' : r.event_name) },
|
||||
{ key: 'trace', header: 'شماره پیگیری', render: (r) => <span className="font-mono text-xs text-slate-500">{toFa(r.trace_no) || '—'}</span> },
|
||||
{ key: 'ref', header: 'شماره مرجع', render: (r) => <span className="font-mono text-xs text-slate-500">{toFa(r.ref_num) || '—'}</span> },
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (r) => (
|
||||
<span className={`tabular-nums font-semibold ${r.type === 'payment' ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||
{formatMoney(r.amount)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" icon="→" onClick={onBack}>
|
||||
بازگشت
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">{customerFullName(customer)}</h1>
|
||||
<p className="text-sm text-slate-500">{toFa(customer.mobile_number ?? '')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" icon="📑" disabled={syncBusy || activity.loading || rows.length === 0} onClick={handleSync}>
|
||||
{syncBusy ? 'در حال صدور…' : 'صدور سند پرداختها'}
|
||||
</Button>
|
||||
<Button variant="secondary" icon="↻" onClick={activity.reload}>
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncMsg && (
|
||||
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<StatCard label="موجودی کیف پول" value={formatMoney(customer.balance || 0)} icon="💳" tone="brand" />
|
||||
<StatCard label="جمع پرداختها" value={formatMoney(totals.payments)} icon="↓" tone="green" />
|
||||
<StatCard label="جمع بازپرداختها" value={formatMoney(totals.refunds)} icon="↑" tone="amber" />
|
||||
<StatCard label="برداشتها" value={toFa(customerWithdrawals.length)} icon="🏧" tone="rose" />
|
||||
</div>
|
||||
|
||||
{activity.error && (
|
||||
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{activity.error}</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="mb-3 text-lg font-bold text-slate-800">پرداختها و بازپرداختها</h2>
|
||||
{activity.loading ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
||||
) : (
|
||||
<DataTable columns={columns} rows={rows} rowKey={(r) => r.id} emptyTitle="پرداختی ثبت نشده است" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,10 @@ interface MonthBucket {
|
||||
purchases: number
|
||||
}
|
||||
|
||||
function isSaleInvoice(invoice: ReturnType<typeof useStore>['data']['invoices'][number]): boolean {
|
||||
return invoice.kind === 'sale' && (invoice.documentType ?? 'invoice') === 'invoice' && invoice.status !== 'draft'
|
||||
}
|
||||
|
||||
function monthlyBuckets(
|
||||
invoices: ReturnType<typeof useStore>['data']['invoices'],
|
||||
): MonthBucket[] {
|
||||
@@ -33,10 +37,10 @@ function monthlyBuckets(
|
||||
buckets.push({
|
||||
label: formatter.format(date),
|
||||
sales: inMonth
|
||||
.filter((i) => i.kind === 'sale')
|
||||
.filter(isSaleInvoice)
|
||||
.reduce((sum, i) => sum + invoiceTotals(i).net, 0),
|
||||
purchases: inMonth
|
||||
.filter((i) => i.kind === 'purchase')
|
||||
.filter((i) => i.kind === 'purchase' && i.status !== 'draft')
|
||||
.reduce((sum, i) => sum + invoiceTotals(i).net, 0),
|
||||
})
|
||||
}
|
||||
@@ -53,16 +57,18 @@ export function Dashboard() {
|
||||
.reduce((sum, b) => sum + b.balance, 0)
|
||||
|
||||
const sales = data.invoices
|
||||
.filter((i) => i.kind === 'sale')
|
||||
.filter(isSaleInvoice)
|
||||
.reduce((sum, i) => sum + invoiceTotals(i).net, 0)
|
||||
const purchases = data.invoices
|
||||
.filter((i) => i.kind === 'purchase')
|
||||
.filter((i) => i.kind === 'purchase' && i.status !== 'draft')
|
||||
.reduce((sum, i) => sum + invoiceTotals(i).net, 0)
|
||||
|
||||
const income = incomeStatement(balances)
|
||||
const sheet = balanceSheet(balances)
|
||||
const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel)
|
||||
const unpaidSales = data.invoices.filter((i) => i.kind === 'sale' && i.status !== 'paid')
|
||||
const unpaidSales = data.invoices.filter(
|
||||
(i) => i.kind === 'sale' && (i.documentType ?? 'invoice') === 'invoice' && i.status === 'confirmed',
|
||||
)
|
||||
|
||||
return { liquidity, sales, purchases, income, sheet, lowStock, unpaidSales }
|
||||
}, [data])
|
||||
@@ -88,7 +94,7 @@ export function Dashboard() {
|
||||
<StatCard
|
||||
label="فروش کل"
|
||||
value={formatMoney(metrics.sales)}
|
||||
hint={`${toFa(data.invoices.filter((i) => i.kind === 'sale').length)} فاکتور فروش`}
|
||||
hint={`${toFa(data.invoices.filter(isSaleInvoice).length)} فاکتور فروش`}
|
||||
icon="🧾"
|
||||
tone="green"
|
||||
/>
|
||||
@@ -190,8 +196,8 @@ export function Dashboard() {
|
||||
<Card className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-slate-800">فاکتورهای فروش تسویهنشده</h2>
|
||||
<Link to="/sales" className="text-sm font-medium text-brand-600 hover:underline">
|
||||
مشاهده فروش
|
||||
<Link to="/sales/review" className="text-sm font-medium text-brand-600 hover:underline">
|
||||
مرور فروش
|
||||
</Link>
|
||||
</div>
|
||||
{metrics.unpaidSales.length === 0 ? (
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '../components/ui'
|
||||
import type { Product } from '../types'
|
||||
import { createId } from '../utils/id'
|
||||
import { formatMoney, toFa } from '../utils/format'
|
||||
import { formatMoney, sumAmounts, toAmount, toFa } from '../utils/format'
|
||||
|
||||
const emptyDraft = (): Product => ({
|
||||
id: '',
|
||||
@@ -36,7 +36,7 @@ export function Inventory() {
|
||||
const [toDelete, setToDelete] = useState<Product | null>(null)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const value = data.products.reduce((sum, p) => sum + p.stock * p.purchasePrice, 0)
|
||||
const value = sumAmounts(data.products.map((p) => toAmount(p.stock) * toAmount(p.purchasePrice)))
|
||||
const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel).length
|
||||
return { value, lowStock, count: data.products.length }
|
||||
}, [data.products])
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useConnection } from '../connection/ConnectionContext'
|
||||
import { useApiResource } from '../hooks/useApiResource'
|
||||
import { apiGet, asList } from '../api/client'
|
||||
import { ENDPOINTS } from '../api/config'
|
||||
import type { ApiOwner, ApiOwnerTransaction, ApiWithdrawal, WalletTxnType } from '../api/types'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Input,
|
||||
PageHeader,
|
||||
StatCard,
|
||||
type Column,
|
||||
} from '../components/ui'
|
||||
import { ConnectionRequired } from '../components/business/ConnectionRequired'
|
||||
import { useVoucherSync } from '../accounting/voucherSync'
|
||||
import { formatDateTime, formatMoney, toFa } from '../utils/format'
|
||||
|
||||
const txnTypeLabels: Record<WalletTxnType, string> = {
|
||||
deposit: 'واریز',
|
||||
withdraw: 'برداشت',
|
||||
booking_payment: 'پرداخت رزرو',
|
||||
refund: 'بازپرداخت',
|
||||
}
|
||||
|
||||
const txnTypeTones: Record<WalletTxnType, 'green' | 'rose' | 'blue' | 'amber'> = {
|
||||
deposit: 'green',
|
||||
withdraw: 'rose',
|
||||
booking_payment: 'blue',
|
||||
refund: 'amber',
|
||||
}
|
||||
|
||||
const ownerFullName = (owner: ApiOwner): string =>
|
||||
`${owner.f_name ?? ''} ${owner.l_name ?? ''}`.trim() || owner.username || 'بدون نام'
|
||||
|
||||
export function Owners() {
|
||||
const { connected } = useConnection()
|
||||
const { syncOwnerTransactions } = useVoucherSync()
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<ApiOwner | null>(null)
|
||||
const [syncBusy, setSyncBusy] = useState(false)
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||
|
||||
const owners = useApiResource(
|
||||
() => apiGet<unknown>(ENDPOINTS.OWNERS).then(asList<ApiOwner>),
|
||||
[connected],
|
||||
connected,
|
||||
)
|
||||
const withdrawals = useApiResource(
|
||||
() =>
|
||||
apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS).then(
|
||||
(res) => res.withdrawals ?? [],
|
||||
),
|
||||
[connected],
|
||||
connected,
|
||||
)
|
||||
|
||||
const list = owners.data ?? []
|
||||
const ownerWithdrawals = useMemo(
|
||||
() => (withdrawals.data ?? []).filter((w) => w.user_type === 'owner'),
|
||||
[withdrawals.data],
|
||||
)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalBalance = list.reduce((sum, o) => sum + (o.balance || 0), 0)
|
||||
const pending = ownerWithdrawals.filter((w) => w.status === 'pending').length
|
||||
return { count: list.length, totalBalance, pending }
|
||||
}, [list, ownerWithdrawals])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (!query) return list
|
||||
return list.filter((o) => {
|
||||
const name = ownerFullName(o).toLowerCase()
|
||||
return (
|
||||
name.includes(query) ||
|
||||
(o.username ?? '').toLowerCase().includes(query) ||
|
||||
(o.mobile_number ?? '').includes(query)
|
||||
)
|
||||
})
|
||||
}, [list, search])
|
||||
|
||||
const handleBulkSync = async () => {
|
||||
setSyncBusy(true)
|
||||
setSyncMsg(null)
|
||||
try {
|
||||
const all = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>)
|
||||
const res = await syncOwnerTransactions(all)
|
||||
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
|
||||
} catch (err) {
|
||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
||||
} finally {
|
||||
setSyncBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="مالکان مجموعهها" subtitle="اطلاعات مالی صاحبان مجموعهها از اپلیکیشن مالکان" />
|
||||
<ConnectionRequired />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return <OwnerDetail owner={selected} withdrawals={ownerWithdrawals} onBack={() => setSelected(null)} />
|
||||
}
|
||||
|
||||
const columns: Array<Column<ApiOwner>> = [
|
||||
{ key: 'name', header: 'نام مالک', render: (o) => <span className="font-medium text-slate-800">{ownerFullName(o)}</span> },
|
||||
{ key: 'username', header: 'نام کاربری', render: (o) => o.username || '—' },
|
||||
{ key: 'mobile', header: 'موبایل', render: (o) => <span className="tabular-nums">{toFa(o.mobile_number ?? '') || '—'}</span> },
|
||||
{ key: 'venues', header: 'مجموعهها', render: (o) => toFa(o.social_hubs?.length ?? 0) },
|
||||
{ key: 'iban', header: 'شبا', render: (o) => <span className="font-mono text-xs text-slate-500">{o.credit_number || '—'}</span> },
|
||||
{
|
||||
key: 'balance',
|
||||
header: 'موجودی کیف پول',
|
||||
align: 'end',
|
||||
render: (o) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(o.balance || 0)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
render: (o) => (
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelected(o)}>
|
||||
مشاهده تراکنشها
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="مالکان مجموعهها"
|
||||
subtitle="اطلاعات مالی صاحبان مجموعهها از اپلیکیشن مالکان"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
|
||||
{syncBusy ? 'در حال صدور…' : 'صدور سند همه تراکنشها'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="↻"
|
||||
onClick={() => {
|
||||
owners.reload()
|
||||
withdrawals.reload()
|
||||
}}
|
||||
>
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{syncMsg && (
|
||||
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard label="تعداد مالکان" value={toFa(summary.count)} icon="👤" tone="brand" />
|
||||
<StatCard label="مجموع موجودی کیف پولها" value={formatMoney(summary.totalBalance)} icon="💼" tone="green" />
|
||||
<StatCard label="برداشتهای در انتظار" value={toFa(summary.pending)} icon="⏳" tone={summary.pending > 0 ? 'amber' : 'green'} />
|
||||
</div>
|
||||
|
||||
{owners.error && (
|
||||
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{owners.error}</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<Input
|
||||
placeholder="جستجوی مالک بر اساس نام، نام کاربری یا موبایل…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="mb-4 max-w-sm"
|
||||
/>
|
||||
{owners.loading ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
||||
) : (
|
||||
<DataTable columns={columns} rows={filtered} rowKey={(o) => o.id} onRowClick={(o) => setSelected(o)} emptyTitle="مالکی یافت نشد" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerDetail({
|
||||
owner,
|
||||
withdrawals,
|
||||
onBack,
|
||||
}: {
|
||||
owner: ApiOwner
|
||||
withdrawals: ApiWithdrawal[]
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { syncOwnerTransactions } = useVoucherSync()
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||
const [syncBusy, setSyncBusy] = useState(false)
|
||||
|
||||
const transactions = useApiResource(
|
||||
() => apiGet<unknown>(ENDPOINTS.OWNER_TRANSACTIONS(owner.id)).then(asList<ApiOwnerTransaction>),
|
||||
[owner.id],
|
||||
)
|
||||
|
||||
const rows = transactions.data ?? []
|
||||
const ownerWithdrawals = withdrawals.filter((w) => w.user_id === owner.id)
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncBusy(true)
|
||||
try {
|
||||
const res = await syncOwnerTransactions(rows)
|
||||
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
|
||||
} catch (err) {
|
||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
||||
} finally {
|
||||
setSyncBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<Column<ApiOwnerTransaction>> = [
|
||||
{ key: 'date', header: 'تاریخ و زمان', render: (t) => formatDateTime(t.created_at) },
|
||||
{ key: 'type', header: 'نوع', render: (t) => <Badge tone={txnTypeTones[t.type]}>{txnTypeLabels[t.type]}</Badge> },
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (t) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(t.amount)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (t) => (
|
||||
<Badge tone={t.status === 'completed' ? 'green' : t.status === 'pending' ? 'amber' : 'rose'}>
|
||||
{t.status === 'completed' ? 'انجامشده' : t.status === 'pending' ? 'در انتظار' : 'ناموفق'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'desc', header: 'توضیحات', render: (t) => <span className="text-slate-600">{t.description || '—'}</span> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" icon="→" onClick={onBack}>
|
||||
بازگشت
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">{ownerFullName(owner)}</h1>
|
||||
<p className="text-sm text-slate-500">{toFa(owner.mobile_number ?? '')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" icon="📑" disabled={syncBusy || transactions.loading || rows.length === 0} onClick={handleSync}>
|
||||
{syncBusy ? 'در حال صدور…' : 'صدور سند تراکنشها'}
|
||||
</Button>
|
||||
<Button variant="secondary" icon="↻" onClick={transactions.reload}>
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncMsg && (
|
||||
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard label="موجودی کیف پول" value={formatMoney(owner.balance || 0)} icon="💼" tone="brand" />
|
||||
<StatCard label="تعداد تراکنشها" value={toFa(rows.length)} icon="🔁" tone="green" />
|
||||
<StatCard label="برداشتها" value={toFa(ownerWithdrawals.length)} icon="🏧" tone="amber" />
|
||||
</div>
|
||||
|
||||
{transactions.error && (
|
||||
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{transactions.error}</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="mb-3 text-lg font-bold text-slate-800">گردش حساب کیف پول</h2>
|
||||
{transactions.loading ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
||||
) : (
|
||||
<DataTable columns={columns} rows={rows} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DataTable,
|
||||
Field,
|
||||
Input,
|
||||
JalaliDateInput,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Select,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
} from '../components/ui'
|
||||
import type { Employee, Payslip } from '../types'
|
||||
import { createId } from '../utils/id'
|
||||
import { formatMoney, toFa } from '../utils/format'
|
||||
import { formatMoney, todayISO, toFa } from '../utils/format'
|
||||
import { payslipNet } from '../utils/accounting'
|
||||
|
||||
export function Payroll() {
|
||||
@@ -57,7 +58,7 @@ const emptyEmployee = (): Employee => ({
|
||||
position: '',
|
||||
baseSalary: 0,
|
||||
insuranceNo: '',
|
||||
hireDate: new Date().toISOString().slice(0, 10),
|
||||
hireDate: todayISO(),
|
||||
active: true,
|
||||
})
|
||||
|
||||
@@ -152,7 +153,7 @@ function EmployeeSection() {
|
||||
<Input value={draft.insuranceNo} onChange={(e) => setDraft({ ...draft, insuranceNo: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="تاریخ استخدام">
|
||||
<Input type="date" value={draft.hireDate} onChange={(e) => setDraft({ ...draft, hireDate: e.target.value })} />
|
||||
<JalaliDateInput value={draft.hireDate} onChange={(hireDate) => setDraft({ ...draft, hireDate })} />
|
||||
</Field>
|
||||
<Field label="وضعیت">
|
||||
<Select value={draft.active ? 'active' : 'inactive'} onChange={(e) => setDraft({ ...draft, active: e.target.value === 'active' })}>
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { PurchasesLayout } from './purchases/PurchasesLayout'
|
||||
import { PurchaseSuppliers } from './purchases/PurchaseSuppliers'
|
||||
import { TradeModule } from '../components/business/TradeModule'
|
||||
|
||||
export function Purchases() {
|
||||
return (
|
||||
<TradeModule
|
||||
config={{
|
||||
kind: 'purchase',
|
||||
title: 'تأمینکنندگان و خرید',
|
||||
subtitle: 'مدیریت تأمینکنندگان و ثبت فاکتورهای خرید',
|
||||
partyLabel: 'تأمینکننده',
|
||||
partyTabLabel: 'تأمینکنندگان',
|
||||
}}
|
||||
/>
|
||||
<Routes>
|
||||
<Route element={<PurchasesLayout />}>
|
||||
<Route index element={<Navigate to="/purchases/suppliers" replace />} />
|
||||
<Route path="suppliers" element={<PurchaseSuppliers />} />
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
<TradeModule
|
||||
config={{
|
||||
kind: 'purchase',
|
||||
title: 'فاکتورهای خرید',
|
||||
subtitle: 'ثبت فاکتور خرید از تأمینکنندگان (مالکان)',
|
||||
partyLabel: 'تأمینکننده',
|
||||
partyTabLabel: 'تأمینکنندگان',
|
||||
hidePartyTab: true,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/purchases/suppliers" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { TradeModule } from '../components/business/TradeModule'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { SalesLayout } from './sales/SalesLayout'
|
||||
import { SalesProcess } from './sales/SalesProcess'
|
||||
import { SalesProforma, SalesInvoice, SalesReturn } from './sales/SalesDocuments'
|
||||
import { PartyAdjustmentsPage } from './sales/PartyAdjustmentsPage'
|
||||
import { SalesTypesPage } from './sales/SalesTypesPage'
|
||||
import { SalesReview } from './sales/SalesReview'
|
||||
import { SalesCustomers } from './sales/SalesCustomers'
|
||||
|
||||
export function Sales() {
|
||||
return (
|
||||
<TradeModule
|
||||
config={{
|
||||
kind: 'sale',
|
||||
title: 'مشتریان و فروش',
|
||||
subtitle: 'مدیریت مشتریان و صدور فاکتورهای فروش',
|
||||
partyLabel: 'مشتری',
|
||||
partyTabLabel: 'مشتریان',
|
||||
}}
|
||||
/>
|
||||
<Routes>
|
||||
<Route element={<SalesLayout />}>
|
||||
<Route index element={<SalesProcess />} />
|
||||
<Route path="proforma" element={<SalesProforma />} />
|
||||
<Route path="invoice" element={<SalesInvoice />} />
|
||||
<Route path="return" element={<SalesReturn />} />
|
||||
<Route path="adjustments" element={<PartyAdjustmentsPage />} />
|
||||
<Route path="sales-types" element={<SalesTypesPage />} />
|
||||
<Route path="customers" element={<SalesCustomers />} />
|
||||
<Route path="review" element={<SalesReview />} />
|
||||
<Route path="*" element={<Navigate to="/sales" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,198 +1,701 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useStore } from '../store/AppStore'
|
||||
|
||||
import {
|
||||
|
||||
Badge,
|
||||
|
||||
Button,
|
||||
|
||||
Card,
|
||||
|
||||
ConfirmDialog,
|
||||
|
||||
DataTable,
|
||||
|
||||
Field,
|
||||
|
||||
Input,
|
||||
|
||||
JalaliDateInput,
|
||||
|
||||
Modal,
|
||||
|
||||
PageHeader,
|
||||
|
||||
Select,
|
||||
|
||||
StatCard,
|
||||
|
||||
Textarea,
|
||||
|
||||
type Column,
|
||||
|
||||
} from '../components/ui'
|
||||
|
||||
import type { TreasuryKind, TreasuryMethod, TreasuryTxn } from '../types'
|
||||
|
||||
import { createId, nextNumber } from '../utils/id'
|
||||
import { formatDate, formatMoney } from '../utils/format'
|
||||
|
||||
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format'
|
||||
|
||||
import { buildTreasuryVoucher, customers, reconcileInvoicePaymentStatus, suppliers, voucherSourceForTreasury } from './sales/salesUtils'
|
||||
|
||||
import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync'
|
||||
|
||||
import { useFunZoneTreasurySync } from './treasury/useFunZoneTreasurySync'
|
||||
|
||||
|
||||
|
||||
const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
|
||||
|
||||
const methodLabels: Record<TreasuryMethod, string> = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' }
|
||||
|
||||
|
||||
|
||||
export function Treasury() {
|
||||
const { data, upsertTreasury, removeTreasury } = useStore()
|
||||
|
||||
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||
|
||||
const { sync, loading: syncLoading, error: syncError, stats: syncStats } = useFunZoneTreasurySync()
|
||||
|
||||
const [draft, setDraft] = useState<TreasuryTxn | null>(null)
|
||||
|
||||
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
|
||||
|
||||
|
||||
|
||||
const partyName = (id: string | null) =>
|
||||
|
||||
id ? data.parties.find((p) => p.id === id)?.name ?? '—' : '—'
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const receipts = data.treasury.filter((t) => t.kind === 'receipt').reduce((s, t) => s + t.amount, 0)
|
||||
const payments = data.treasury.filter((t) => t.kind === 'payment').reduce((s, t) => s + t.amount, 0)
|
||||
return { receipts, payments, net: receipts - payments }
|
||||
|
||||
|
||||
const funZoneCounts = useMemo(() => {
|
||||
|
||||
const fz = data.treasury.filter(isFunZoneTreasuryTxn)
|
||||
|
||||
return {
|
||||
|
||||
receipts: fz.filter((t) => t.kind === 'receipt').length,
|
||||
|
||||
payments: fz.filter((t) => t.kind === 'payment').length,
|
||||
|
||||
}
|
||||
|
||||
}, [data.treasury])
|
||||
|
||||
|
||||
|
||||
const summary = useMemo(() => {
|
||||
|
||||
const receipts = data.treasury.filter((t) => t.kind === 'receipt').reduce((s, t) => s + t.amount, 0)
|
||||
|
||||
const payments = data.treasury.filter((t) => t.kind === 'payment').reduce((s, t) => s + t.amount, 0)
|
||||
|
||||
const platformProfit = data.treasury.reduce((s, t) => s + (t.platformProfitAmount ?? 0), 0)
|
||||
|
||||
return { receipts, payments, net: receipts - payments, platformProfit }
|
||||
|
||||
}, [data.treasury])
|
||||
|
||||
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...data.treasury].sort((a, b) => b.date.localeCompare(a.date)),
|
||||
|
||||
() => [...data.treasury].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
|
||||
|
||||
[data.treasury],
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
const treasuryParties = useMemo(() => {
|
||||
|
||||
if (!draft) return []
|
||||
|
||||
const list = draft.kind === 'receipt' ? customers(data.parties) : suppliers(data.parties)
|
||||
|
||||
return list.filter((p) => p.id)
|
||||
|
||||
}, [data.parties, draft])
|
||||
|
||||
|
||||
|
||||
const createDraft = (kind: TreasuryKind): TreasuryTxn => ({
|
||||
|
||||
id: '',
|
||||
|
||||
number: nextNumber(data.treasury.filter((t) => t.kind === kind)),
|
||||
|
||||
kind,
|
||||
|
||||
method: 'bank',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
|
||||
date: todayISO(),
|
||||
|
||||
partyId: null,
|
||||
|
||||
amount: 0,
|
||||
|
||||
reference: '',
|
||||
|
||||
description: '',
|
||||
|
||||
source: '',
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
const handleSave = () => {
|
||||
|
||||
if (!draft || draft.amount <= 0) return
|
||||
upsertTreasury({ ...draft, id: draft.id || createId('tr-') })
|
||||
|
||||
const saved: TreasuryTxn = {
|
||||
|
||||
...draft,
|
||||
|
||||
id: draft.id || createId('tr-'),
|
||||
|
||||
source: draft.source ?? '',
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
upsertTreasury(saved)
|
||||
|
||||
|
||||
|
||||
const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(saved.id))
|
||||
|
||||
const newVoucher = buildTreasuryVoucher(saved, data)
|
||||
|
||||
if (existingVoucher) removeVoucher(existingVoucher.id)
|
||||
|
||||
if (newVoucher) upsertVoucher(newVoucher)
|
||||
|
||||
|
||||
|
||||
if (saved.partyId) {
|
||||
|
||||
const dataAfterSave = {
|
||||
|
||||
...data,
|
||||
|
||||
treasury: [...data.treasury.filter((t) => t.id !== saved.id), saved],
|
||||
|
||||
}
|
||||
|
||||
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
||||
|
||||
upsertInvoice(inv)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
setDraft(null)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const columns: Array<Column<TreasuryTxn>> = [
|
||||
|
||||
{ key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) },
|
||||
|
||||
{
|
||||
|
||||
key: 'kind',
|
||||
|
||||
header: 'نوع',
|
||||
|
||||
render: (t) => <Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
key: 'source',
|
||||
|
||||
header: 'منبع',
|
||||
|
||||
render: (t) =>
|
||||
|
||||
isFunZoneTreasuryTxn(t) ? (
|
||||
|
||||
<Badge tone="indigo">فانزون</Badge>
|
||||
|
||||
) : (
|
||||
|
||||
<Badge tone="slate">سپیدار</Badge>
|
||||
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
{ key: 'method', header: 'روش', render: (t) => <Badge tone="slate">{methodLabels[t.method]}</Badge> },
|
||||
|
||||
{ key: 'party', header: 'طرف حساب', render: (t) => partyName(t.partyId) },
|
||||
{ key: 'ref', header: 'مرجع', render: (t) => <span className="font-mono text-slate-500">{t.reference || '—'}</span> },
|
||||
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
|
||||
key: 'details',
|
||||
|
||||
header: 'شرح',
|
||||
|
||||
render: (t) => (
|
||||
<span className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{t.kind === 'receipt' ? '+' : '−'} {formatMoney(t.amount)}
|
||||
|
||||
<span className="line-clamp-2 max-w-xs text-xs text-slate-500" title={t.description}>
|
||||
|
||||
{t.description.split('\n')[0] || '—'}
|
||||
|
||||
</span>
|
||||
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
{ key: 'ref', header: 'مرجع', render: (t) => <span className="font-mono text-slate-500">{t.reference || '—'}</span> },
|
||||
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
|
||||
key: 'amount',
|
||||
|
||||
header: 'مبلغ',
|
||||
|
||||
align: 'end',
|
||||
|
||||
render: (t) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...t })}>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
|
||||
{t.kind === 'receipt' ? '+' : '−'} {formatMoney(t.amount)}
|
||||
|
||||
</span>
|
||||
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
key: 'actions',
|
||||
|
||||
header: '',
|
||||
|
||||
align: 'end',
|
||||
|
||||
render: (t) => (
|
||||
|
||||
<div className="flex justify-end gap-1">
|
||||
|
||||
<Button
|
||||
|
||||
size="sm"
|
||||
|
||||
variant="ghost"
|
||||
|
||||
onClick={() => setDraft({ ...t })}
|
||||
|
||||
>
|
||||
|
||||
{isFunZoneTreasuryTxn(t) ? 'جزئیات' : 'ویرایش'}
|
||||
|
||||
</Button>
|
||||
|
||||
{!isFunZoneTreasuryTxn(t) && (
|
||||
|
||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
|
||||
|
||||
حذف
|
||||
|
||||
</Button>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
<PageHeader
|
||||
|
||||
title="دریافت و پرداخت (خزانهداری)"
|
||||
subtitle="ثبت دریافتها و پرداختهای نقدی، بانکی و چک"
|
||||
|
||||
subtitle="دریافت از مشتریان (فانزون) و پرداخت به مالکان — همگام با سپیدار (سند + تسویه فاکتور)"
|
||||
|
||||
actions={
|
||||
|
||||
<>
|
||||
|
||||
<Button variant="secondary" icon="↻" onClick={() => void sync()} disabled={syncLoading}>
|
||||
|
||||
{syncLoading ? 'همگامسازی…' : 'همگامسازی فانزون'}
|
||||
|
||||
</Button>
|
||||
|
||||
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
|
||||
دریافت جدید
|
||||
|
||||
دریافت دستی
|
||||
|
||||
</Button>
|
||||
|
||||
<Button variant="danger" icon="↑" onClick={() => setDraft(createDraft('payment'))}>
|
||||
پرداخت جدید
|
||||
|
||||
پرداخت دستی
|
||||
|
||||
</Button>
|
||||
|
||||
</>
|
||||
|
||||
}
|
||||
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard label="جمع دریافتها" value={formatMoney(summary.receipts)} icon="↓" tone="green" />
|
||||
<StatCard label="جمع پرداختها" value={formatMoney(summary.payments)} icon="↑" tone="rose" />
|
||||
<StatCard
|
||||
label="مانده خالص"
|
||||
value={formatMoney(summary.net)}
|
||||
icon="💰"
|
||||
tone={summary.net >= 0 ? 'brand' : 'rose'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است" />
|
||||
|
||||
<Card className="border-brand-200 bg-brand-50/40 p-4">
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
|
||||
<div>
|
||||
|
||||
<h3 className="font-bold text-slate-800">اتصال فانزون → خزانه</h3>
|
||||
|
||||
<ul className="mt-2 space-y-1 text-sm text-slate-600">
|
||||
<li>↓ <strong>دریافت:</strong> پرداخت مشتری — مبلغ کل رویداد (رزرو/بلیت)</li>
|
||||
<li>↳ تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ — سود در حساب درآمد (۴۰۰۱)</li>
|
||||
<li>↑ <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)</li>
|
||||
<li>↳ واریز کیف پول مالک و سهم رزرو در خزانه نیست — یکبار هنگام خرید بلیت در «دریافت» لحاظ شده</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
{syncStats && (
|
||||
|
||||
<div className="text-sm text-slate-600">
|
||||
|
||||
<p>
|
||||
|
||||
آخرین همگامسازی: {toFa(syncStats.created)} جدید، {toFa(syncStats.updated)} بهروز،{' '}
|
||||
{toFa(syncStats.removed)} حذف تکراری، {toFa(syncStats.skipped)} بدون تغییر
|
||||
{syncStats.excludedOwnerTxns > 0 &&
|
||||
` — ${toFa(syncStats.excludedOwnerTxns)} تراکنش داخلی مالک نادیده گرفته شد`}
|
||||
|
||||
</p>
|
||||
|
||||
{(syncStats.unmatchedCustomers > 0 || syncStats.unmatchedOwners > 0) && (
|
||||
|
||||
<p className="mt-1 text-amber-700">
|
||||
|
||||
{syncStats.unmatchedCustomers > 0 &&
|
||||
|
||||
`${toFa(syncStats.unmatchedCustomers)} پرداخت مشتری بدون طرف حساب — ابتدا مشتریان را همگام کنید. `}
|
||||
|
||||
{syncStats.unmatchedOwners > 0 &&
|
||||
|
||||
`${toFa(syncStats.unmatchedOwners)} تراکنش مالک بدون طرف حساب — ابتدا تأمینکنندگان را همگام کنید.`}
|
||||
|
||||
</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{syncError && <p className="mt-3 text-sm text-rose-600">{syncError}</p>}
|
||||
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
|
||||
<StatCard label="جمع دریافتها" value={formatMoney(summary.receipts)} icon="↓" tone="green" />
|
||||
|
||||
<StatCard label="جمع پرداختها" value={formatMoney(summary.payments)} icon="↑" tone="rose" />
|
||||
|
||||
<StatCard
|
||||
|
||||
label="مانده خالص"
|
||||
|
||||
value={formatMoney(summary.net)}
|
||||
|
||||
icon="💰"
|
||||
|
||||
tone={summary.net >= 0 ? 'brand' : 'rose'}
|
||||
|
||||
/>
|
||||
|
||||
<StatCard label="دریافت فانزون" value={toFa(funZoneCounts.receipts)} icon="👤" tone="brand" />
|
||||
|
||||
<StatCard label="سود پلتفرم (۱۴٪)" value={formatMoney(summary.platformProfit)} icon="📈" tone="green" />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<Card className="p-4">
|
||||
|
||||
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است — همگامسازی فانزون را بزنید" />
|
||||
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
<Modal
|
||||
|
||||
open={draft !== null}
|
||||
title={draft ? `${kindLabels[draft.kind]} جدید` : ''}
|
||||
onClose={() => setDraft(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>ذخیره</Button>
|
||||
</>
|
||||
|
||||
title={
|
||||
|
||||
draft
|
||||
|
||||
? isFunZoneTreasuryTxn(draft)
|
||||
|
||||
? `${kindLabels[draft.kind]} #${toFa(draft.number)} (فانزون)`
|
||||
|
||||
: draft.id
|
||||
|
||||
? `${kindLabels[draft.kind]} #${toFa(draft.number)}`
|
||||
|
||||
: `${kindLabels[draft.kind]} جدید`
|
||||
|
||||
: ''
|
||||
|
||||
}
|
||||
|
||||
onClose={() => setDraft(null)}
|
||||
|
||||
footer={
|
||||
|
||||
<>
|
||||
|
||||
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||
|
||||
{draft && isFunZoneTreasuryTxn(draft) ? 'بستن' : 'انصراف'}
|
||||
|
||||
</Button>
|
||||
|
||||
{draft && !isFunZoneTreasuryTxn(draft) && <Button onClick={handleSave}>ذخیره</Button>}
|
||||
|
||||
</>
|
||||
|
||||
}
|
||||
|
||||
>
|
||||
|
||||
{draft && (
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="تاریخ">
|
||||
<Input type="date" value={draft.date} onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="روش">
|
||||
<Select value={draft.method} onChange={(e) => setDraft({ ...draft, method: e.target.value as TreasuryMethod })}>
|
||||
{Object.entries(methodLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="طرف حساب">
|
||||
<Select
|
||||
value={draft.partyId ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
|
||||
>
|
||||
<option value="">— بدون طرف حساب —</option>
|
||||
{data.parties.map((party) => (
|
||||
<option key={party.id} value={party.id}>
|
||||
{party.name} ({party.kind === 'customer' ? 'مشتری' : 'تأمینکننده'})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="مبلغ (تومان)">
|
||||
<Input type="number" value={draft.amount} onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })} />
|
||||
</Field>
|
||||
<Field label="شماره مرجع / چک">
|
||||
<Input value={draft.reference} onChange={(e) => setDraft({ ...draft, reference: e.target.value })} />
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="توضیحات">
|
||||
<Textarea rows={2} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{isFunZoneTreasuryTxn(draft) ? (
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
|
||||
<pre className="whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm text-slate-700">
|
||||
|
||||
{draft.description}
|
||||
|
||||
</pre>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
|
||||
<div>
|
||||
|
||||
<dt className="text-slate-400">طرف حساب</dt>
|
||||
|
||||
<dd className="font-medium">{partyName(draft.partyId)}</dd>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<dt className="text-slate-400">مبلغ</dt>
|
||||
|
||||
<dd className="font-semibold tabular-nums">{formatMoney(draft.amount)}</dd>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<dt className="text-slate-400">تاریخ</dt>
|
||||
|
||||
<dd>{formatDate(draft.date)}</dd>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<dt className="text-slate-400">مرجع</dt>
|
||||
|
||||
<dd className="font-mono">{draft.reference || '—'}</dd>
|
||||
|
||||
</div>
|
||||
|
||||
</dl>
|
||||
|
||||
</div>
|
||||
|
||||
) : (
|
||||
|
||||
<>
|
||||
|
||||
<Field label="تاریخ">
|
||||
|
||||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||
|
||||
</Field>
|
||||
|
||||
<Field label="روش">
|
||||
|
||||
<Select value={draft.method} onChange={(e) => setDraft({ ...draft, method: e.target.value as TreasuryMethod })}>
|
||||
|
||||
{Object.entries(methodLabels).map(([value, label]) => (
|
||||
|
||||
<option key={value} value={value}>
|
||||
|
||||
{label}
|
||||
|
||||
</option>
|
||||
|
||||
))}
|
||||
|
||||
</Select>
|
||||
|
||||
</Field>
|
||||
|
||||
<Field label="طرف حساب">
|
||||
|
||||
<Select
|
||||
|
||||
value={draft.partyId ?? ''}
|
||||
|
||||
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
|
||||
|
||||
>
|
||||
|
||||
<option value="">— بدون طرف حساب —</option>
|
||||
|
||||
{treasuryParties.map((party) => (
|
||||
|
||||
<option key={party.id} value={party.id}>
|
||||
|
||||
{party.name}
|
||||
|
||||
</option>
|
||||
|
||||
))}
|
||||
|
||||
</Select>
|
||||
|
||||
</Field>
|
||||
|
||||
<Field label="مبلغ (تومان)">
|
||||
|
||||
<Input type="number" value={draft.amount} onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })} />
|
||||
|
||||
</Field>
|
||||
|
||||
<Field label="شماره مرجع / چک">
|
||||
|
||||
<Input value={draft.reference} onChange={(e) => setDraft({ ...draft, reference: e.target.value })} />
|
||||
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
|
||||
<Field label="توضیحات">
|
||||
|
||||
<Textarea rows={2} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
|
||||
|
||||
</Field>
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</Modal>
|
||||
|
||||
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
open={toDelete !== null}
|
||||
|
||||
title="حذف تراکنش"
|
||||
|
||||
message="این تراکنش خزانه حذف شود؟"
|
||||
|
||||
onCancel={() => setToDelete(null)}
|
||||
|
||||
onConfirm={() => {
|
||||
if (toDelete) removeTreasury(toDelete.id)
|
||||
|
||||
if (toDelete) {
|
||||
|
||||
const voucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(toDelete.id))
|
||||
|
||||
if (voucher) removeVoucher(voucher.id)
|
||||
|
||||
removeTreasury(toDelete.id)
|
||||
|
||||
|
||||
|
||||
if (toDelete.partyId) {
|
||||
|
||||
const dataAfterDelete = {
|
||||
|
||||
...data,
|
||||
|
||||
treasury: data.treasury.filter((t) => t.id !== toDelete.id),
|
||||
|
||||
}
|
||||
|
||||
for (const inv of reconcileInvoicePaymentStatus(toDelete.partyId, dataAfterDelete)) {
|
||||
|
||||
upsertInvoice(inv)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
setToDelete(null)
|
||||
|
||||
}}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DataTable,
|
||||
Field,
|
||||
Input,
|
||||
JalaliDateInput,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Select,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
} from '../components/ui'
|
||||
import type { Voucher, VoucherLine } from '../types'
|
||||
import { createId, nextNumber } from '../utils/id'
|
||||
import { formatDate, formatMoney, toFa } from '../utils/format'
|
||||
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format'
|
||||
import { voucherBalance } from '../utils/accounting'
|
||||
|
||||
const newLine = (): VoucherLine => ({
|
||||
@@ -30,7 +31,7 @@ function createDraft(items: Voucher[]): Voucher {
|
||||
return {
|
||||
id: '',
|
||||
number: nextNumber(items),
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
date: todayISO(),
|
||||
description: '',
|
||||
status: 'draft',
|
||||
lines: [newLine(), newLine()],
|
||||
@@ -74,18 +75,45 @@ export function Vouchers() {
|
||||
}
|
||||
|
||||
const columns: Array<Column<Voucher>> = [
|
||||
{ key: 'number', header: 'شماره', render: (v) => <span className="font-mono">{toFa(v.number)}</span> },
|
||||
{ key: 'date', header: 'تاریخ', render: (v) => formatDate(v.date) },
|
||||
{ key: 'desc', header: 'شرح', render: (v) => <span className="text-slate-700">{v.description || '—'}</span> },
|
||||
{
|
||||
key: 'number',
|
||||
header: 'شماره',
|
||||
headerClassName: 'w-[4.5rem]',
|
||||
className: 'w-[4.5rem] whitespace-nowrap',
|
||||
render: (v) => <span className="font-mono">{toFa(v.number)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: 'تاریخ',
|
||||
headerClassName: 'w-[6.5rem]',
|
||||
className: 'w-[6.5rem] whitespace-nowrap tabular-nums',
|
||||
render: (v) => formatDate(v.date),
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
header: 'شرح',
|
||||
truncate: true,
|
||||
className: 'min-w-0',
|
||||
render: (v) => (
|
||||
<span className="block truncate text-slate-700" title={v.description || undefined}>
|
||||
{v.description || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (v) => <span className="tabular-nums">{formatMoney(voucherBalance(v).debit)}</span>,
|
||||
headerClassName: 'w-[7.5rem]',
|
||||
className: 'w-[7.5rem] whitespace-nowrap tabular-nums',
|
||||
render: (v) => formatMoney(voucherBalance(v).debit),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
sticky: { left: '9rem' },
|
||||
headerClassName: 'w-[6.5rem]',
|
||||
className: 'w-[6.5rem] whitespace-nowrap',
|
||||
render: (v) =>
|
||||
v.status === 'posted' ? <Badge tone="green">ثبت قطعی</Badge> : <Badge tone="amber">پیشنویس</Badge>,
|
||||
},
|
||||
@@ -93,6 +121,9 @@ export function Vouchers() {
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
sticky: { left: '0' },
|
||||
headerClassName: 'w-[9rem]',
|
||||
className: 'w-[9rem] whitespace-nowrap',
|
||||
render: (v) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...v, lines: v.lines.map((l) => ({ ...l })) })}>
|
||||
@@ -171,7 +202,7 @@ export function Vouchers() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="تاریخ">
|
||||
<Input type="date" value={draft.date} onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
|
||||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||
</Field>
|
||||
<Field label="شرح سند">
|
||||
<Input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
|
||||
|
||||
14
src/pages/purchases/PurchaseSuppliers.tsx
Normal file
14
src/pages/purchases/PurchaseSuppliers.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FunZonePartySection } from '../../parties/FunZonePartySection'
|
||||
|
||||
export function PurchaseSuppliers() {
|
||||
return (
|
||||
<FunZonePartySection
|
||||
config={{
|
||||
kind: 'supplier',
|
||||
title: 'تأمینکنندگان',
|
||||
subtitle: 'مالکان مجموعهها از اپلیکیشن مالکان — منبع داده فانزون، مشابه «تأمینکنندگان» در سپیدار',
|
||||
externalSource: 'funzone_owner',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
50
src/pages/purchases/PurchasesLayout.tsx
Normal file
50
src/pages/purchases/PurchasesLayout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { PageHeader } from '../../components/ui'
|
||||
|
||||
const purchaseNavItems = [
|
||||
{ to: '/purchases/suppliers', label: 'تأمینکنندگان', icon: '🏢', end: false },
|
||||
{ to: '/purchases/invoices', label: 'فاکتورهای خرید', icon: '🛒', end: false },
|
||||
{ to: '/treasury', label: 'پرداخت به تأمینکنندگان', icon: '💰', end: false },
|
||||
]
|
||||
|
||||
export function PurchasesLayout() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="تأمینکنندگان و خرید"
|
||||
subtitle="مدیریت تأمینکنندگان (مالکان) و ثبت فاکتورهای خرید"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<nav className="lg:w-56 shrink-0">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-2 shadow-sm">
|
||||
<p className="px-3 py-2 text-xs font-semibold text-slate-400">زیرمنو خرید</p>
|
||||
<div className="space-y-0.5">
|
||||
{purchaseNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-50 text-brand-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
238
src/pages/sales/PartyAdjustmentsPage.tsx
Normal file
238
src/pages/sales/PartyAdjustmentsPage.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
Input,
|
||||
JalaliDateInput,
|
||||
Modal,
|
||||
Select,
|
||||
Textarea,
|
||||
type Column,
|
||||
} from '../../components/ui'
|
||||
import type { AdjustmentKind, PartyAdjustment } from '../../types'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
import { formatDate, formatMoney, todayISO, toFa } from '../../utils/format'
|
||||
import { customers, partyReceivable, buildAdjustmentVoucher, reconcileInvoicePaymentStatus, voucherSourceForAdjustment } from './salesUtils'
|
||||
|
||||
const kindLabels: Record<AdjustmentKind, string> = {
|
||||
debit: 'بدهکار',
|
||||
credit: 'بستانکار',
|
||||
}
|
||||
|
||||
const kindTones: Record<AdjustmentKind, 'rose' | 'green'> = {
|
||||
debit: 'rose',
|
||||
credit: 'green',
|
||||
}
|
||||
|
||||
export function PartyAdjustmentsPage() {
|
||||
const { data, upsertPartyAdjustment, removePartyAdjustment, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||
const [draft, setDraft] = useState<PartyAdjustment | null>(null)
|
||||
const [toDelete, setToDelete] = useState<PartyAdjustment | null>(null)
|
||||
|
||||
const customerList = useMemo(
|
||||
() => customers(data.parties).filter((p) => p.id),
|
||||
[data.parties],
|
||||
)
|
||||
const sorted = useMemo(
|
||||
() => [...data.partyAdjustments].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
|
||||
[data.partyAdjustments],
|
||||
)
|
||||
|
||||
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||
|
||||
const createDraft = (): PartyAdjustment => ({
|
||||
id: '',
|
||||
number: nextNumber(data.partyAdjustments),
|
||||
partyId: customerList[0]?.id ?? '',
|
||||
kind: 'debit',
|
||||
date: todayISO(),
|
||||
amount: 0,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (!draft || !draft.partyId || draft.amount <= 0) return
|
||||
const saved: PartyAdjustment = { ...draft, id: draft.id || createId('adj-') }
|
||||
|
||||
upsertPartyAdjustment(saved)
|
||||
|
||||
const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForAdjustment(saved.id))
|
||||
const newVoucher = buildAdjustmentVoucher(saved, data)
|
||||
if (existingVoucher) removeVoucher(existingVoucher.id)
|
||||
if (newVoucher) upsertVoucher(newVoucher)
|
||||
|
||||
const dataAfterSave = {
|
||||
...data,
|
||||
partyAdjustments: [...data.partyAdjustments.filter((a) => a.id !== saved.id), saved],
|
||||
}
|
||||
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
||||
upsertInvoice(inv)
|
||||
}
|
||||
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const columns: Array<Column<PartyAdjustment>> = [
|
||||
{ key: 'number', header: 'شماره', render: (a) => <span className="font-mono">{toFa(a.number)}</span> },
|
||||
{ key: 'date', header: 'تاریخ', render: (a) => formatDate(a.date) },
|
||||
{ key: 'party', header: 'مشتری', render: (a) => partyName(a.partyId) },
|
||||
{
|
||||
key: 'kind',
|
||||
header: 'نوع',
|
||||
render: (a) => <Badge tone={kindTones[a.kind]}>{kindLabels[a.kind]}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (a) => (
|
||||
<span className={`tabular-nums font-semibold ${a.kind === 'debit' ? 'text-rose-600' : 'text-emerald-600'}`}>
|
||||
{a.kind === 'debit' ? '+' : '−'} {formatMoney(a.amount)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'desc', header: 'شرح', render: (a) => <span className="text-slate-500">{a.description || '—'}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
render: (a) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...a })}>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(a)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">اعلامیه بدهکار/بستانکار</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
اصلاح مانده حساب مشتری بدون صدور فاکتور — بدهکار افزایش مطالبات، بستانکار کاهش مطالبات
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="+" onClick={() => setDraft(createDraft())} disabled={customerList.length === 0}>
|
||||
اعلامیه جدید
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={sorted}
|
||||
rowKey={(a) => a.id}
|
||||
emptyTitle="اعلامیهای ثبت نشده است"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={draft?.id ? `ویرایش اعلامیه #${toFa(draft.number)}` : 'اعلامیه جدید'}
|
||||
onClose={() => setDraft(null)}
|
||||
footer={
|
||||
<>
|
||||
{draft && (
|
||||
<p className="me-auto text-sm text-slate-500">
|
||||
مانده فعلی مشتری:{' '}
|
||||
<span className="tabular-nums font-semibold text-slate-700">
|
||||
{formatMoney(partyReceivable(draft.partyId, data))}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>ذخیره</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{draft && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="شماره">
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.number}
|
||||
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="تاریخ">
|
||||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||
</Field>
|
||||
<Field label="مشتری">
|
||||
<Select value={draft.partyId} onChange={(e) => setDraft({ ...draft, partyId: e.target.value })}>
|
||||
{customerList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="نوع اعلامیه">
|
||||
<Select
|
||||
value={draft.kind}
|
||||
onChange={(e) => setDraft({ ...draft, kind: e.target.value as AdjustmentKind })}
|
||||
>
|
||||
{Object.entries(kindLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="مبلغ (تومان)">
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.amount}
|
||||
onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="شرح">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={toDelete !== null}
|
||||
title="حذف اعلامیه"
|
||||
message={`اعلامیه شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
|
||||
onCancel={() => setToDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (toDelete) {
|
||||
const voucher = data.vouchers.find((v) => v.source === voucherSourceForAdjustment(toDelete.id))
|
||||
if (voucher) removeVoucher(voucher.id)
|
||||
removePartyAdjustment(toDelete.id)
|
||||
|
||||
const dataAfterDelete = {
|
||||
...data,
|
||||
partyAdjustments: data.partyAdjustments.filter((a) => a.id !== toDelete.id),
|
||||
}
|
||||
for (const inv of reconcileInvoicePaymentStatus(toDelete.partyId, dataAfterDelete)) {
|
||||
upsertInvoice(inv)
|
||||
}
|
||||
}
|
||||
setToDelete(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
116
src/pages/sales/PendingWithdrawalsPanel.tsx
Normal file
116
src/pages/sales/PendingWithdrawalsPanel.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
import type { Invoice, Party } from '../../types'
|
||||
import { Badge, Button, Card, DataTable, StatCard, type Column } from '../../components/ui'
|
||||
import { formatDateTime, formatMoney, toFa } from '../../utils/format'
|
||||
import { invoiceForWithdrawal } from './usePendingCustomerWithdrawals'
|
||||
|
||||
interface PendingWithdrawalsPanelProps {
|
||||
withdrawals: ApiWithdrawal[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
returnInvoices: Invoice[]
|
||||
customerList: Party[]
|
||||
onReload: () => void
|
||||
onCreateReturn: (withdrawal: ApiWithdrawal) => void
|
||||
}
|
||||
|
||||
export function PendingWithdrawalsPanel({
|
||||
withdrawals,
|
||||
loading,
|
||||
error,
|
||||
returnInvoices,
|
||||
customerList,
|
||||
onReload,
|
||||
onCreateReturn,
|
||||
}: PendingWithdrawalsPanelProps) {
|
||||
const openWithdrawals = useMemo(
|
||||
() => withdrawals.filter((w) => !invoiceForWithdrawal(returnInvoices, w.id)),
|
||||
[withdrawals, returnInvoices],
|
||||
)
|
||||
|
||||
const columns: Array<Column<ApiWithdrawal>> = [
|
||||
{ key: 'customer', header: 'مشتری', render: (w) => <span className="font-medium">{w.user_name}</span> },
|
||||
{ key: 'username', header: 'نام کاربری', render: (w) => w.username || '—' },
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (w) => <span className="tabular-nums font-semibold">{formatMoney(w.amount)}</span>,
|
||||
},
|
||||
{ key: 'iban', header: 'شبا', render: (w) => <span className="font-mono text-xs">{w.iban || '—'}</span> },
|
||||
{
|
||||
key: 'date',
|
||||
header: 'تاریخ درخواست',
|
||||
render: (w) => formatDateTime(w.created_at),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: () => <Badge tone="amber">در انتظار</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
render: (w) => {
|
||||
const party = customerList.find((p) => p.externalId === w.user_id)
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!party}
|
||||
onClick={() => onCreateReturn(w)}
|
||||
title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
|
||||
>
|
||||
ثبت فاکتور برگشتی
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card className="border-amber-200 bg-amber-50/30 p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
درخواستهای برداشت کیف پول مشتریان از بخش حسابداری ادمین — برای هر مورد فاکتور برگشتی صادر کنید
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard
|
||||
label="برداشت در انتظار"
|
||||
value={toFa(openWithdrawals.length)}
|
||||
icon="⏳"
|
||||
tone={openWithdrawals.length > 0 ? 'amber' : 'green'}
|
||||
/>
|
||||
<StatCard
|
||||
label="جمع مبالغ"
|
||||
value={formatMoney(openWithdrawals.reduce((s, w) => s + w.amount, 0))}
|
||||
icon="💳"
|
||||
tone="brand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">در حال بارگذاری برداشتها…</p>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={openWithdrawals}
|
||||
rowKey={(w) => w.id}
|
||||
emptyTitle="برداشت در انتظاری ثبت نشده است"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
14
src/pages/sales/SalesCustomers.tsx
Normal file
14
src/pages/sales/SalesCustomers.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FunZonePartySection } from '../../parties/FunZonePartySection'
|
||||
|
||||
export function SalesCustomers() {
|
||||
return (
|
||||
<FunZonePartySection
|
||||
config={{
|
||||
kind: 'customer',
|
||||
title: 'مشتریان',
|
||||
subtitle: 'لیست کاربران اپلیکیشن مشتریان — منبع داده فانزون، مشابه «مشتریان» در سپیدار',
|
||||
externalSource: 'funzone_customer',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
595
src/pages/sales/SalesDocumentSection.tsx
Normal file
595
src/pages/sales/SalesDocumentSection.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
Input,
|
||||
JalaliDateInput,
|
||||
Modal,
|
||||
Select,
|
||||
Textarea,
|
||||
type Column,
|
||||
} from '../../components/ui'
|
||||
import type { Invoice, InvoiceLine, InvoiceStatus, SalesDocumentType } from '../../types'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
import { formatDate, formatMoney, todayISO, toFa } from '../../utils/format'
|
||||
import { invoiceTotals, lineTotals } from '../../utils/accounting'
|
||||
import {
|
||||
byDocumentType,
|
||||
customers,
|
||||
documentTypeLabels,
|
||||
isWithdrawalReturn,
|
||||
reconcileInvoicePaymentStatus,
|
||||
statusLabels,
|
||||
} from './salesUtils'
|
||||
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
|
||||
import {
|
||||
usePendingCustomerWithdrawals,
|
||||
withdrawalNoteTag,
|
||||
} from './usePendingCustomerWithdrawals'
|
||||
import { PendingWithdrawalsPanel } from './PendingWithdrawalsPanel'
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
|
||||
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
|
||||
draft: 'amber',
|
||||
confirmed: 'blue',
|
||||
paid: 'green',
|
||||
}
|
||||
|
||||
const newLine = (): InvoiceLine => ({
|
||||
id: createId('il-'),
|
||||
productId: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
discount: 0,
|
||||
taxRate: 0,
|
||||
})
|
||||
|
||||
interface SalesDocumentSectionProps {
|
||||
documentType: SalesDocumentType
|
||||
title: string
|
||||
subtitle: string
|
||||
/** Allow converting from proforma when creating sales invoice */
|
||||
allowProformaConversion?: boolean
|
||||
/** Allow linking to original invoice for returns */
|
||||
allowRelatedInvoice?: boolean
|
||||
}
|
||||
|
||||
export function SalesDocumentSection({
|
||||
documentType,
|
||||
title,
|
||||
subtitle,
|
||||
allowProformaConversion = false,
|
||||
allowRelatedInvoice = false,
|
||||
}: SalesDocumentSectionProps) {
|
||||
const { data, upsertInvoice, removeInvoice, upsertProduct, upsertVoucher, removeVoucher } = useStore()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [draft, setDraft] = useState<Invoice | null>(null)
|
||||
const [toDelete, setToDelete] = useState<Invoice | null>(null)
|
||||
const [previousDraft, setPreviousDraft] = useState<Invoice | null>(null)
|
||||
|
||||
const customerList = useMemo(
|
||||
() => customers(data.parties).filter((p) => p.id),
|
||||
[data.parties],
|
||||
)
|
||||
const documents = useMemo(() => byDocumentType(data.invoices, documentType), [data.invoices, documentType])
|
||||
const proformas = useMemo(() => byDocumentType(data.invoices, 'proforma'), [data.invoices])
|
||||
const saleInvoices = useMemo(() => byDocumentType(data.invoices, 'invoice'), [data.invoices])
|
||||
const activeSalesTypes = useMemo(() => data.salesTypes.filter((t) => t.active), [data.salesTypes])
|
||||
const isReturnSection = documentType === 'return'
|
||||
const {
|
||||
withdrawals: pendingWithdrawals,
|
||||
loading: pendingLoading,
|
||||
error: pendingError,
|
||||
reload: reloadPending,
|
||||
} = usePendingCustomerWithdrawals(isReturnSection)
|
||||
|
||||
const returnDocuments = useMemo(
|
||||
() => (isReturnSection ? documents : []),
|
||||
[documents, isReturnSection],
|
||||
)
|
||||
|
||||
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||
const salesTypeName = (id?: string) =>
|
||||
id ? data.salesTypes.find((t) => t.id === id)?.name ?? '—' : '—'
|
||||
|
||||
const createDraft = (fromProforma?: Invoice): Invoice => ({
|
||||
id: '',
|
||||
kind: 'sale',
|
||||
documentType,
|
||||
number: nextNumber(documents),
|
||||
partyId: fromProforma?.partyId ?? customerList[0]?.id ?? '',
|
||||
salesTypeId: fromProforma?.salesTypeId,
|
||||
relatedInvoiceId: fromProforma?.id,
|
||||
date: todayISO(),
|
||||
status: 'draft',
|
||||
note: fromProforma ? `تبدیل از پیشفاکتور #${fromProforma.number}` : '',
|
||||
lines: fromProforma
|
||||
? fromProforma.lines.map((l) => ({ ...l, id: createId('il-') }))
|
||||
: [newLine()],
|
||||
})
|
||||
|
||||
const openDraft = (invoice?: Invoice) => {
|
||||
if (invoice) {
|
||||
setPreviousDraft({ ...invoice, lines: invoice.lines.map((l) => ({ ...l })) })
|
||||
setDraft({ ...invoice, lines: invoice.lines.map((l) => ({ ...l })) })
|
||||
} else {
|
||||
setPreviousDraft(null)
|
||||
setDraft(createDraft())
|
||||
}
|
||||
}
|
||||
|
||||
const updateLine = (lineId: string, patch: Partial<InvoiceLine>) => {
|
||||
if (!draft) return
|
||||
setDraft({
|
||||
...draft,
|
||||
lines: draft.lines.map((line) => (line.id === lineId ? { ...line, ...patch } : line)),
|
||||
})
|
||||
}
|
||||
|
||||
const onSelectProduct = (lineId: string, productId: string) => {
|
||||
const product = data.products.find((p) => p.id === productId)
|
||||
updateLine(lineId, {
|
||||
productId,
|
||||
unitPrice: product?.salePrice ?? 0,
|
||||
taxRate: data.settings.taxRate,
|
||||
})
|
||||
}
|
||||
|
||||
const loadFromProforma = (proformaId: string) => {
|
||||
const proforma = proformas.find((p) => p.id === proformaId)
|
||||
if (proforma) {
|
||||
setPreviousDraft(null)
|
||||
setDraft(createDraft(proforma))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fromProformaId = (location.state as { fromProformaId?: string } | null)?.fromProformaId
|
||||
if (fromProformaId && documentType === 'invoice') {
|
||||
loadFromProforma(fromProformaId)
|
||||
navigate(location.pathname, { replace: true, state: null })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const loadFromInvoice = (invoiceId: string) => {
|
||||
const source = saleInvoices.find((i) => i.id === invoiceId)
|
||||
if (!source) return
|
||||
setPreviousDraft(null)
|
||||
setDraft({
|
||||
...createDraft(),
|
||||
partyId: source.partyId,
|
||||
salesTypeId: source.salesTypeId,
|
||||
relatedInvoiceId: source.id,
|
||||
note: `برگشت از فاکتور #${source.number}`,
|
||||
lines: source.lines.map((l) => ({ ...l, id: createId('il-') })),
|
||||
})
|
||||
}
|
||||
|
||||
const loadFromWithdrawal = (withdrawal: ApiWithdrawal) => {
|
||||
const party = customerList.find((p) => p.externalId === withdrawal.user_id)
|
||||
if (!party) return
|
||||
setPreviousDraft(null)
|
||||
setDraft({
|
||||
...createDraft(),
|
||||
partyId: party.id,
|
||||
date: withdrawal.created_at.slice(0, 10),
|
||||
status: 'draft',
|
||||
note: `${withdrawalNoteTag(withdrawal.id)}\nبرداشت کیف پول مشتری — ${withdrawal.user_name}${withdrawal.iban ? `\nشبا: ${withdrawal.iban}` : ''}`,
|
||||
lines: [
|
||||
{
|
||||
id: createId('il-'),
|
||||
productId: '',
|
||||
quantity: 1,
|
||||
unitPrice: withdrawal.amount,
|
||||
discount: 0,
|
||||
taxRate: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!draft || !draft.partyId) return
|
||||
const fromWithdrawal = isWithdrawalReturn(draft)
|
||||
const cleanLines = draft.lines.filter((line) => {
|
||||
if (fromWithdrawal && line.unitPrice > 0) return true
|
||||
return Boolean(line.productId) && line.quantity > 0
|
||||
})
|
||||
if (cleanLines.length === 0) return
|
||||
|
||||
const saved: Invoice = {
|
||||
...draft,
|
||||
documentType,
|
||||
kind: 'sale',
|
||||
lines: cleanLines,
|
||||
id: draft.id || createId('inv-'),
|
||||
}
|
||||
|
||||
const effects = invoiceSideEffects(saved, previousDraft, data)
|
||||
for (const product of effects.products) {
|
||||
const original = data.products.find((p) => p.id === product.id)
|
||||
if (original && original.stock !== product.stock) upsertProduct(product)
|
||||
}
|
||||
|
||||
upsertInvoice(saved)
|
||||
|
||||
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||
if (effects.voucher) upsertVoucher(effects.voucher)
|
||||
|
||||
if (saved.partyId && saved.status !== 'draft') {
|
||||
const dataAfterSave = {
|
||||
...data,
|
||||
invoices: [...data.invoices.filter((i) => i.id !== saved.id), saved],
|
||||
}
|
||||
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
||||
upsertInvoice(inv)
|
||||
}
|
||||
}
|
||||
|
||||
if (allowProformaConversion && saved.relatedInvoiceId && documentType === 'invoice') {
|
||||
const proforma = proformas.find((p) => p.id === saved.relatedInvoiceId)
|
||||
if (proforma && proforma.status === 'draft') {
|
||||
upsertInvoice({ ...proforma, status: 'confirmed', note: `${proforma.note}\nتبدیل به فاکتور #${saved.number}` })
|
||||
}
|
||||
}
|
||||
|
||||
setDraft(null)
|
||||
setPreviousDraft(null)
|
||||
}
|
||||
|
||||
const totals = draft ? invoiceTotals(draft) : null
|
||||
|
||||
const columns: Array<Column<Invoice>> = [
|
||||
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
||||
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
||||
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
||||
{
|
||||
key: 'type',
|
||||
header: 'نوع فروش',
|
||||
render: (i) => <span className="text-slate-500">{salesTypeName(i.salesTypeId)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'net',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (i) => <span className="tabular-nums">{formatMoney(invoiceTotals(i).net)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (i) => <Badge tone={statusTones[i.status]}>{statusLabels[i.status]}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
render: (i) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
{documentType === 'proforma' && i.status === 'draft' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/sales/invoice', { state: { fromProformaId: i.id } })}
|
||||
>
|
||||
تبدیل
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => openDraft(i)}>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(i)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{isReturnSection && (
|
||||
<PendingWithdrawalsPanel
|
||||
withdrawals={pendingWithdrawals}
|
||||
loading={pendingLoading}
|
||||
error={pendingError}
|
||||
returnInvoices={returnDocuments}
|
||||
customerList={customerList}
|
||||
onReload={() => void reloadPending()}
|
||||
onCreateReturn={loadFromWithdrawal}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">{title}</h2>
|
||||
<p className="text-sm text-slate-500">{subtitle}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customerList.length === 0 && (
|
||||
<Link to="/sales/customers" className="text-sm text-brand-600 hover:underline">
|
||||
ابتدا مشتری تعریف کنید
|
||||
</Link>
|
||||
)}
|
||||
<Button icon="+" onClick={() => openDraft()} disabled={customerList.length === 0}>
|
||||
{documentTypeLabels[documentType]} جدید
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customerList.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">
|
||||
ابتدا در بخش{' '}
|
||||
<Link to="/sales/customers" className="text-brand-600 hover:underline">
|
||||
مشتریان
|
||||
</Link>{' '}
|
||||
حداقل یک مشتری تعریف کنید.
|
||||
</p>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={documents}
|
||||
rowKey={(i) => i.id}
|
||||
emptyTitle={`${documentTypeLabels[documentType]}ی ثبت نشده است`}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={
|
||||
draft?.id
|
||||
? `ویرایش ${documentTypeLabels[documentType]} #${toFa(draft.number)}`
|
||||
: `${documentTypeLabels[documentType]} جدید`
|
||||
}
|
||||
onClose={() => {
|
||||
setDraft(null)
|
||||
setPreviousDraft(null)
|
||||
}}
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
{totals && (
|
||||
<div className="me-auto flex flex-wrap items-center gap-4 text-sm text-slate-500">
|
||||
<span>
|
||||
تخفیف: <span className="tabular-nums text-slate-700">{formatMoney(totals.discount)}</span>
|
||||
</span>
|
||||
<span>
|
||||
مالیات: <span className="tabular-nums text-slate-700">{formatMoney(totals.tax)}</span>
|
||||
</span>
|
||||
<span className="font-semibold text-slate-800">
|
||||
قابل پرداخت:{' '}
|
||||
<span className="tabular-nums text-brand-700">{formatMoney(totals.net)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDraft(null)
|
||||
setPreviousDraft(null)
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>ذخیره</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{draft && (
|
||||
<div className="space-y-4">
|
||||
{allowProformaConversion && !draft.id && proformas.length > 0 && (
|
||||
<Field label="تبدیل از پیشفاکتور">
|
||||
<Select value="" onChange={(e) => e.target.value && loadFromProforma(e.target.value)}>
|
||||
<option value="">— انتخاب پیشفاکتور —</option>
|
||||
{proformas
|
||||
.filter((p) => p.status === 'draft')
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{toFa(p.number)} · {partyName(p.partyId)} · {formatMoney(invoiceTotals(p).net)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{allowRelatedInvoice && !draft.id && saleInvoices.length > 0 && (
|
||||
<Field label="برگشت از فاکتور">
|
||||
<Select value="" onChange={(e) => e.target.value && loadFromInvoice(e.target.value)}>
|
||||
<option value="">— انتخاب فاکتور فروش —</option>
|
||||
{saleInvoices
|
||||
.filter((i) => i.status !== 'draft')
|
||||
.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
#{toFa(i.number)} · {partyName(i.partyId)} · {formatMoney(invoiceTotals(i).net)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label="شماره">
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.number}
|
||||
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="تاریخ">
|
||||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||
</Field>
|
||||
<Field label="مشتری">
|
||||
<Select value={draft.partyId} onChange={(e) => setDraft({ ...draft, partyId: e.target.value })}>
|
||||
{customerList.map((party) => (
|
||||
<option key={party.id} value={party.id}>
|
||||
{party.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="وضعیت">
|
||||
<Select
|
||||
value={draft.status}
|
||||
onChange={(e) => setDraft({ ...draft, status: e.target.value as InvoiceStatus })}
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
{activeSalesTypes.length > 0 && (
|
||||
<Field label="نوع فروش">
|
||||
<Select
|
||||
value={draft.salesTypeId ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, salesTypeId: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{activeSalesTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-start font-medium">کالا</th>
|
||||
<th className="px-3 py-2 text-end font-medium">تعداد</th>
|
||||
<th className="px-3 py-2 text-end font-medium">قیمت واحد</th>
|
||||
<th className="px-3 py-2 text-end font-medium">تخفیف</th>
|
||||
<th className="px-3 py-2 text-end font-medium">مالیات٪</th>
|
||||
<th className="px-3 py-2 text-end font-medium">جمع</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{draft.lines.map((line) => (
|
||||
<tr key={line.id}>
|
||||
<td className="min-w-[180px] px-2 py-1.5">
|
||||
<Select value={line.productId} onChange={(e) => onSelectProduct(line.id, e.target.value)}>
|
||||
<option value="">انتخاب کالا…</option>
|
||||
{data.products.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name} (موجودی: {toFa(product.stock)})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className="w-24 px-2 py-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
className="text-end"
|
||||
value={line.quantity}
|
||||
onChange={(e) => updateLine(line.id, { quantity: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</td>
|
||||
<td className="w-32 px-2 py-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
className="text-end"
|
||||
value={line.unitPrice}
|
||||
onChange={(e) => updateLine(line.id, { unitPrice: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</td>
|
||||
<td className="w-28 px-2 py-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
className="text-end"
|
||||
value={line.discount}
|
||||
onChange={(e) => updateLine(line.id, { discount: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</td>
|
||||
<td className="w-20 px-2 py-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
className="text-end"
|
||||
value={line.taxRate}
|
||||
onChange={(e) => updateLine(line.id, { taxRate: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-end tabular-nums text-slate-700">
|
||||
{formatMoney(lineTotals(line).net)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<button
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, lines: draft.lines.filter((l) => l.id !== line.id) })
|
||||
}
|
||||
className="text-slate-400 transition hover:text-rose-600"
|
||||
aria-label="حذف ردیف"
|
||||
disabled={draft.lines.length <= 1}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="+"
|
||||
onClick={() => setDraft({ ...draft, lines: [...draft.lines, newLine()] })}
|
||||
>
|
||||
افزودن ردیف
|
||||
</Button>
|
||||
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={draft.note}
|
||||
onChange={(e) => setDraft({ ...draft, note: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{documentType !== 'proforma' && draft.status !== 'draft' && (
|
||||
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
{isWithdrawalReturn(draft)
|
||||
? 'برداشت کیف پول — موجودی انبار تغییر نمیکند؛ سند حسابداری برداشت ثبت میشود.'
|
||||
: 'با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={toDelete !== null}
|
||||
title={`حذف ${documentTypeLabels[documentType]}`}
|
||||
message={`${documentTypeLabels[documentType]} شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
|
||||
onCancel={() => setToDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (toDelete) {
|
||||
const effects = invoiceDeleteEffects(toDelete, data)
|
||||
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||
for (const product of effects.products) {
|
||||
const original = data.products.find((p) => p.id === product.id)
|
||||
if (original && original.stock !== product.stock) upsertProduct(product)
|
||||
}
|
||||
removeInvoice(toDelete.id)
|
||||
}
|
||||
setToDelete(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
src/pages/sales/SalesDocuments.tsx
Normal file
33
src/pages/sales/SalesDocuments.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { SalesDocumentSection } from './SalesDocumentSection'
|
||||
|
||||
export function SalesProforma() {
|
||||
return (
|
||||
<SalesDocumentSection
|
||||
documentType="proforma"
|
||||
title="پیشفاکتور جدید"
|
||||
subtitle="صدور پیشنهاد قیمت — بدون تأثیر بر موجودی و حسابداری تا تبدیل به فاکتور"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SalesInvoice() {
|
||||
return (
|
||||
<SalesDocumentSection
|
||||
documentType="invoice"
|
||||
title="فاکتور فروش جدید"
|
||||
subtitle="ثبت فروش قطعی — کاهش موجودی انبار و صدور سند حسابداری"
|
||||
allowProformaConversion
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SalesReturn() {
|
||||
return (
|
||||
<SalesDocumentSection
|
||||
documentType="return"
|
||||
title="فاکتور برگشتی جدید"
|
||||
subtitle="برگشت کالا یا ثبت فاکتور برگشتی برای برداشتهای در انتظار مشتریان (از پنل مدیریت)"
|
||||
allowRelatedInvoice
|
||||
/>
|
||||
)
|
||||
}
|
||||
45
src/pages/sales/SalesLayout.tsx
Normal file
45
src/pages/sales/SalesLayout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { PageHeader } from '../../components/ui'
|
||||
import { salesNavItems } from './salesNavigation'
|
||||
|
||||
export function SalesLayout() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="مشتریان و فروش"
|
||||
subtitle="مدیریت فرایند فروش، فاکتورها، اعلامیهها و مرور فروش"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<nav className="lg:w-56 shrink-0">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-2 shadow-sm">
|
||||
<p className="px-3 py-2 text-xs font-semibold text-slate-400">زیرمنو فروش</p>
|
||||
<div className="space-y-0.5">
|
||||
{salesNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-50 text-brand-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
src/pages/sales/SalesProcess.tsx
Normal file
110
src/pages/sales/SalesProcess.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
import { Card, StatCard } from '../../components/ui'
|
||||
import { formatMoney, toFa } from '../../utils/format'
|
||||
import { salesNavItems } from './salesNavigation'
|
||||
import { salesSummary } from './salesUtils'
|
||||
|
||||
const processSteps = [
|
||||
{ step: 1, label: 'پیشفاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
|
||||
{ step: 2, label: 'فاکتور فروش', desc: 'ثبت فروش قطعی و کاهش موجودی', to: '/sales/invoice', icon: '🧾' },
|
||||
{ step: 3, label: 'دریافت', desc: 'تسویه از مشتری در ماژول دریافت و پرداخت', to: '/treasury', icon: '💰' },
|
||||
{ step: 4, label: 'فاکتور برگشتی', desc: 'برداشت در انتظار مشتریان و برگشت کالا', to: '/sales/return', icon: '↩️' },
|
||||
]
|
||||
|
||||
export function SalesProcess() {
|
||||
const { data } = useStore()
|
||||
const summary = useMemo(() => salesSummary(data), [data])
|
||||
|
||||
const quickLinks = salesNavItems.filter((item) => item.to !== '/sales')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-5">
|
||||
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
|
||||
<p className="mb-6 text-sm text-slate-500">
|
||||
جریان استاندارد فروش مشابه سپیدار: از پیشفاکتور تا فاکتور، دریافت و برگشت.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{processSteps.map((step, idx) => (
|
||||
<div key={step.step} className="relative">
|
||||
<Link
|
||||
to={step.to}
|
||||
className="flex flex-col rounded-xl border border-slate-200 bg-slate-50/50 p-4 transition hover:border-brand-200 hover:bg-brand-50/30"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-brand-100 text-xs font-bold text-brand-700">
|
||||
{toFa(step.step)}
|
||||
</span>
|
||||
<span className="text-lg">{step.icon}</span>
|
||||
</div>
|
||||
<p className="font-semibold text-slate-800">{step.label}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">{step.desc}</p>
|
||||
</Link>
|
||||
{idx < processSteps.length - 1 && (
|
||||
<span className="absolute -left-2 top-1/2 hidden -translate-y-1/2 text-slate-300 lg:block">←</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard label="پیشفاکتور" value={toFa(summary.proformaCount)} hint={`${toFa(summary.openProformas)} باز`} />
|
||||
<StatCard label="فاکتور فروش" value={toFa(summary.invoiceCount)} hint={formatMoney(summary.totalSales)} />
|
||||
<StatCard label="فاکتور برگشتی" value={toFa(summary.returnCount)} hint={formatMoney(summary.totalReturns)} />
|
||||
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} hint={`${toFa(summary.unpaidCount)} تسویهنشده`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card className="p-5">
|
||||
<h3 className="mb-3 font-bold text-slate-800">اتصال به سایر ماژولها</h3>
|
||||
<ul className="space-y-2 text-sm text-slate-600">
|
||||
<li className="flex items-start gap-2">
|
||||
<span>📦</span>
|
||||
<span>
|
||||
<strong>انبارداری:</strong> فاکتور فروش قطعی موجودی را کاهش و فاکتور برگشتی آن را افزایش میدهد.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span>📑</span>
|
||||
<span>
|
||||
<strong>اسناد حسابداری:</strong> با تأیید فاکتور، سند درآمد/مطالبات بهصورت خودکار صادر میشود.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span>💰</span>
|
||||
<span>
|
||||
<strong>دریافت و پرداخت:</strong> دریافت از مشتری مانده حساب را کاهش میدهد.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span>⚖️</span>
|
||||
<span>
|
||||
<strong>اعلامیه بدهکار/بستانکار:</strong> برای اصلاح مانده مشتری بدون فاکتور.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<h3 className="mb-3 font-bold text-slate-800">دسترسی سریع</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{quickLinks.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2.5 text-sm font-medium text-slate-700 transition hover:border-brand-200 hover:bg-brand-50/40"
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
src/pages/sales/SalesReview.tsx
Normal file
136
src/pages/sales/SalesReview.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Field,
|
||||
Select,
|
||||
StatCard,
|
||||
type Column,
|
||||
} from '../../components/ui'
|
||||
import type { Invoice, SalesDocumentType } from '../../types'
|
||||
import { formatDate, formatMoney, toFa } from '../../utils/format'
|
||||
import { invoiceTotals } from '../../utils/accounting'
|
||||
import {
|
||||
customers,
|
||||
documentTypeLabels,
|
||||
salesDocuments,
|
||||
salesSummary,
|
||||
statusLabels,
|
||||
} from './salesUtils'
|
||||
|
||||
export function SalesReview() {
|
||||
const { data } = useStore()
|
||||
const [filterType, setFilterType] = useState<SalesDocumentType | 'all'>('all')
|
||||
const [filterCustomer, setFilterCustomer] = useState<string>('all')
|
||||
|
||||
const customerList = useMemo(
|
||||
() => customers(data.parties).filter((p) => p.id),
|
||||
[data.parties],
|
||||
)
|
||||
const summary = useMemo(() => salesSummary(data), [data])
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
let docs = salesDocuments(data.invoices)
|
||||
if (filterType !== 'all') {
|
||||
docs = docs.filter((i) => (i.documentType ?? 'invoice') === filterType)
|
||||
}
|
||||
if (filterCustomer !== 'all') {
|
||||
docs = docs.filter((i) => i.partyId === filterCustomer)
|
||||
}
|
||||
return docs.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number)
|
||||
}, [data.invoices, filterType, filterCustomer])
|
||||
|
||||
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||
const salesTypeName = (id?: string) =>
|
||||
id ? data.salesTypes.find((t) => t.id === id)?.name ?? '—' : '—'
|
||||
|
||||
const docColumns: Array<Column<Invoice>> = [
|
||||
{
|
||||
key: 'type',
|
||||
header: 'نوع سند',
|
||||
render: (i) => <Badge tone="slate">{documentTypeLabels[i.documentType ?? 'invoice']}</Badge>,
|
||||
},
|
||||
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
||||
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
||||
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
||||
{ key: 'salesType', header: 'نوع فروش', render: (i) => salesTypeName(i.salesTypeId) },
|
||||
{
|
||||
key: 'net',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (i) => <span className="tabular-nums">{formatMoney(invoiceTotals(i).net)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (i) => (
|
||||
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : 'amber'}>
|
||||
{statusLabels[i.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard label="کل فروش" value={formatMoney(summary.totalSales)} />
|
||||
<StatCard label="برگشت" value={formatMoney(summary.totalReturns)} />
|
||||
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} />
|
||||
<StatCard label="مشتریان" value={toFa(summary.customerCount)} />
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex flex-wrap items-end gap-4">
|
||||
<Field label="نوع سند">
|
||||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value as SalesDocumentType | 'all')}>
|
||||
<option value="all">همه</option>
|
||||
{Object.entries(documentTypeLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="مشتری">
|
||||
<Select value={filterCustomer} onChange={(e) => setFilterCustomer(e.target.value)}>
|
||||
<option value="all">همه مشتریان</option>
|
||||
{customerList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="ms-auto flex gap-2">
|
||||
<Link to="/sales/customers">
|
||||
<Button size="sm" variant="secondary">
|
||||
مشتریان
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/sales/invoice">
|
||||
<Button size="sm" variant="secondary">
|
||||
فاکتور جدید
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/treasury">
|
||||
<Button size="sm" variant="secondary">
|
||||
دریافت
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={docColumns}
|
||||
rows={filteredDocs}
|
||||
rowKey={(i) => i.id}
|
||||
emptyTitle="سندی یافت نشد"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
src/pages/sales/SalesTypesPage.tsx
Normal file
153
src/pages/sales/SalesTypesPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
Textarea,
|
||||
type Column,
|
||||
} from '../../components/ui'
|
||||
import type { SalesType } from '../../types'
|
||||
import { createId } from '../../utils/id'
|
||||
import { toFa } from '../../utils/format'
|
||||
|
||||
export function SalesTypesPage() {
|
||||
const { data, upsertSalesType, removeSalesType } = useStore()
|
||||
const [draft, setDraft] = useState<SalesType | null>(null)
|
||||
const [toDelete, setToDelete] = useState<SalesType | null>(null)
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...data.salesTypes].sort((a, b) => a.code.localeCompare(b.code)),
|
||||
[data.salesTypes],
|
||||
)
|
||||
|
||||
const createDraft = (): SalesType => ({
|
||||
id: '',
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
active: true,
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (!draft || !draft.code.trim() || !draft.name.trim()) return
|
||||
upsertSalesType({ ...draft, id: draft.id || createId('st-') })
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const usageCount = (id: string) =>
|
||||
data.invoices.filter((i) => i.kind === 'sale' && i.salesTypeId === id).length
|
||||
|
||||
const columns: Array<Column<SalesType>> = [
|
||||
{ key: 'code', header: 'کد', render: (t) => <span className="font-mono">{t.code}</span> },
|
||||
{ key: 'name', header: 'نام', render: (t) => <span className="font-medium text-slate-800">{t.name}</span> },
|
||||
{ key: 'desc', header: 'توضیحات', render: (t) => <span className="text-slate-500">{t.description || '—'}</span> },
|
||||
{
|
||||
key: 'active',
|
||||
header: 'وضعیت',
|
||||
render: (t) => <Badge tone={t.active ? 'green' : 'slate'}>{t.active ? 'فعال' : 'غیرفعال'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
header: 'استفاده',
|
||||
align: 'center',
|
||||
render: (t) => toFa(usageCount(t.id)),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
render: (t) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...t })}>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">نوع فروش جدید</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
دستهبندی فروش برای فاکتورها — مشابه «نوع فروش» در سپیدار
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="+" onClick={() => setDraft(createDraft())}>
|
||||
نوع فروش جدید
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="نوع فروشی تعریف نشده است" />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={draft?.id ? 'ویرایش نوع فروش' : 'نوع فروش جدید'}
|
||||
onClose={() => setDraft(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>ذخیره</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{draft && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="کد">
|
||||
<Input value={draft.code} onChange={(e) => setDraft({ ...draft, code: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="نام">
|
||||
<Input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="توضیحات">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="وضعیت">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.active}
|
||||
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
فعال
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={toDelete !== null}
|
||||
title="حذف نوع فروش"
|
||||
message={`«${toDelete?.name}» حذف شود؟`}
|
||||
onCancel={() => setToDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (toDelete) removeSalesType(toDelete.id)
|
||||
setToDelete(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
49
src/pages/sales/invoiceEffects.ts
Normal file
49
src/pages/sales/invoiceEffects.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { AppData, Invoice, Product } from '../../types'
|
||||
import {
|
||||
applyStockChanges,
|
||||
buildInvoiceVoucher,
|
||||
findVoucherForInvoice,
|
||||
invoicesToSettle,
|
||||
partyAccountBalance,
|
||||
partyPayable,
|
||||
partyReceivable,
|
||||
stockDeltas,
|
||||
voucherSourceForInvoice,
|
||||
} from './salesUtils'
|
||||
|
||||
/** Applies stock + voucher side-effects when an invoice is saved (Sepidar-style). */
|
||||
export function invoiceSideEffects(
|
||||
saved: Invoice,
|
||||
previous: Invoice | null | undefined,
|
||||
data: AppData,
|
||||
): {
|
||||
products: Product[]
|
||||
voucher: ReturnType<typeof buildInvoiceVoucher>
|
||||
removeVoucherId?: string
|
||||
} {
|
||||
const products = applyStockChanges(data.products, saved, previous ?? null)
|
||||
const existingId = findVoucherForInvoice(data.vouchers, saved)?.id
|
||||
const voucher = buildInvoiceVoucher(saved, data)
|
||||
return { products, voucher, removeVoucherId: existingId }
|
||||
}
|
||||
|
||||
/** Reverts stock + removes voucher when an invoice is deleted. */
|
||||
export function invoiceDeleteEffects(invoice: Invoice, data: AppData): {
|
||||
products: Product[]
|
||||
removeVoucherId?: string
|
||||
} {
|
||||
const products = applyStockChanges(data.products, { ...invoice, status: 'draft' }, invoice)
|
||||
const removeVoucherId = findVoucherForInvoice(data.vouchers, invoice)?.id
|
||||
return { products, removeVoucherId }
|
||||
}
|
||||
|
||||
export {
|
||||
applyStockChanges,
|
||||
buildInvoiceVoucher,
|
||||
invoicesToSettle,
|
||||
partyAccountBalance,
|
||||
partyPayable,
|
||||
partyReceivable,
|
||||
stockDeltas,
|
||||
voucherSourceForInvoice,
|
||||
}
|
||||
18
src/pages/sales/salesNavigation.ts
Normal file
18
src/pages/sales/salesNavigation.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface SalesNavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: string
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
/** Sub-navigation for the مشتریان و فروش module (Sepidar-style). */
|
||||
export const salesNavItems: SalesNavItem[] = [
|
||||
{ to: '/sales', label: 'فرایند فروش', icon: '🔀', end: true },
|
||||
{ to: '/sales/proforma', label: 'پیشفاکتور جدید', icon: '📋' },
|
||||
{ to: '/sales/invoice', label: 'فاکتور فروش جدید', icon: '🧾' },
|
||||
{ to: '/sales/return', label: 'فاکتور برگشتی جدید', icon: '↩️' },
|
||||
{ to: '/sales/adjustments', label: 'اعلامیه بدهکار/بستانکار', icon: '⚖️' },
|
||||
{ to: '/sales/sales-types', label: 'نوع فروش جدید', icon: '🏷️' },
|
||||
{ to: '/sales/customers', label: 'مشتریان', icon: '👤' },
|
||||
{ to: '/sales/review', label: 'مرور فروش', icon: '🔍' },
|
||||
]
|
||||
284
src/pages/sales/salesUtils.test.ts
Normal file
284
src/pages/sales/salesUtils.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Integration tests for Sepidar-style party/invoice/treasury connections.
|
||||
* Run: npx tsx src/pages/sales/salesUtils.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AppData, Invoice, Party, Product } from '../../types'
|
||||
import {
|
||||
applyStockChanges,
|
||||
buildInvoiceVoucher,
|
||||
buildPurchaseVoucher,
|
||||
buildSalesVoucher,
|
||||
invoicesToSettle,
|
||||
isWithdrawalReturn,
|
||||
partyAccountBalance,
|
||||
partyPayable,
|
||||
partyReceivable,
|
||||
reconcileInvoicePaymentStatus,
|
||||
stockDeltas,
|
||||
} from './salesUtils'
|
||||
|
||||
const customer: Party = {
|
||||
id: 'c1',
|
||||
kind: 'customer',
|
||||
name: 'Customer A',
|
||||
phone: '',
|
||||
economicCode: '',
|
||||
address: '',
|
||||
openingBalance: 0,
|
||||
externalId: 'ext-c1',
|
||||
externalSource: 'funzone_customer',
|
||||
}
|
||||
|
||||
const supplier: Party = {
|
||||
id: 's1',
|
||||
kind: 'supplier',
|
||||
name: 'Owner A',
|
||||
phone: '',
|
||||
economicCode: '',
|
||||
address: '',
|
||||
openingBalance: 0,
|
||||
externalId: 'ext-s1',
|
||||
externalSource: 'funzone_owner',
|
||||
}
|
||||
|
||||
const product: Product = {
|
||||
id: 'p1',
|
||||
code: 'P1',
|
||||
name: 'Item',
|
||||
unit: 'عدد',
|
||||
salePrice: 100,
|
||||
purchasePrice: 80,
|
||||
stock: 10,
|
||||
reorderLevel: 0,
|
||||
}
|
||||
|
||||
const accounts = [
|
||||
{ id: 'a1002', code: '1002', name: 'Bank', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a1004', code: '1004', name: 'Receivable', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a1005', code: '1005', name: 'Inventory', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2001', code: '2001', name: 'Payable', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a4001', code: '4001', name: 'Sales', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
]
|
||||
|
||||
const baseData = (): AppData => ({
|
||||
accounts,
|
||||
vouchers: [],
|
||||
parties: [customer, supplier],
|
||||
products: [product],
|
||||
invoices: [],
|
||||
salesTypes: [],
|
||||
partyAdjustments: [],
|
||||
treasury: [],
|
||||
employees: [],
|
||||
payslips: [],
|
||||
settings: {
|
||||
name: 'Test',
|
||||
economicCode: '',
|
||||
fiscalYear: '1404',
|
||||
baseCurrency: 'تومان',
|
||||
taxRate: 0,
|
||||
address: '',
|
||||
phone: '',
|
||||
voucherAccounts: {
|
||||
bankAccountId: 'a1002',
|
||||
ownerPayableAccountId: 'a2001',
|
||||
customerPayableAccountId: 'a2001',
|
||||
ownerShareAccountId: 'a4001',
|
||||
salesIncomeAccountId: 'a4001',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const saleInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
||||
id: 'inv-sale',
|
||||
kind: 'sale',
|
||||
documentType: 'invoice',
|
||||
number: 1,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-01',
|
||||
status,
|
||||
note: '',
|
||||
lines: [{ id: 'l1', productId: 'p1', quantity: 2, unitPrice: 100, discount: 0, taxRate: 0 }],
|
||||
})
|
||||
|
||||
const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
||||
id: 'inv-purchase',
|
||||
kind: 'purchase',
|
||||
documentType: 'invoice',
|
||||
number: 1,
|
||||
partyId: 's1',
|
||||
date: '2026-01-01',
|
||||
status,
|
||||
note: '',
|
||||
lines: [{ id: 'l2', productId: 'p1', quantity: 5, unitPrice: 80, discount: 0, taxRate: 0 }],
|
||||
})
|
||||
|
||||
// Sales: confirmed invoice reduces stock
|
||||
{
|
||||
const inv = saleInvoice('confirmed')
|
||||
const deltas = stockDeltas(inv)
|
||||
assert.equal(deltas.get('p1'), -2)
|
||||
const updated = applyStockChanges([product], inv)
|
||||
assert.equal(updated[0].stock, 8)
|
||||
}
|
||||
|
||||
// Purchase: confirmed invoice increases stock
|
||||
{
|
||||
const inv = purchaseInvoice('confirmed')
|
||||
const deltas = stockDeltas(inv)
|
||||
assert.equal(deltas.get('p1'), 5)
|
||||
const updated = applyStockChanges([product], inv)
|
||||
assert.equal(updated[0].stock, 15)
|
||||
}
|
||||
|
||||
// Customer receivable + treasury receipt + auto settle
|
||||
{
|
||||
const data = baseData()
|
||||
data.invoices = [saleInvoice('confirmed')]
|
||||
assert.equal(partyReceivable('c1', data), 200)
|
||||
data.treasury.push({
|
||||
id: 't1',
|
||||
number: 1,
|
||||
kind: 'receipt',
|
||||
method: 'bank',
|
||||
date: '2026-01-02',
|
||||
partyId: 'c1',
|
||||
amount: 200,
|
||||
reference: '',
|
||||
description: '',
|
||||
})
|
||||
assert.equal(partyReceivable('c1', data), 0)
|
||||
const settled = invoicesToSettle('c1', data)
|
||||
assert.equal(settled.length, 1)
|
||||
assert.equal(settled[0].status, 'paid')
|
||||
}
|
||||
|
||||
// Supplier payable + payment
|
||||
{
|
||||
const data = baseData()
|
||||
data.invoices = [purchaseInvoice('confirmed')]
|
||||
assert.equal(partyPayable('s1', data), 400)
|
||||
data.treasury.push({
|
||||
id: 't2',
|
||||
number: 1,
|
||||
kind: 'payment',
|
||||
method: 'bank',
|
||||
date: '2026-01-02',
|
||||
partyId: 's1',
|
||||
amount: 400,
|
||||
reference: '',
|
||||
description: '',
|
||||
})
|
||||
assert.equal(partyAccountBalance('s1', data), 0)
|
||||
const settled = invoicesToSettle('s1', data)
|
||||
assert.equal(settled[0].status, 'paid')
|
||||
}
|
||||
|
||||
// Voucher generation
|
||||
{
|
||||
const data = baseData()
|
||||
const salesV = buildSalesVoucher(saleInvoice('confirmed'), data)
|
||||
assert.ok(salesV)
|
||||
assert.equal(salesV!.lines.reduce((s, l) => s + l.debit, 0), 200)
|
||||
|
||||
const purchaseV = buildPurchaseVoucher(purchaseInvoice('confirmed'), data)
|
||||
assert.ok(purchaseV)
|
||||
assert.equal(purchaseV!.lines.reduce((s, l) => s + l.debit, 0), 400)
|
||||
|
||||
assert.ok(buildInvoiceVoucher(saleInvoice('confirmed'), data))
|
||||
assert.ok(buildInvoiceVoucher(purchaseInvoice('confirmed'), data))
|
||||
}
|
||||
|
||||
// Withdrawal return: no stock impact
|
||||
{
|
||||
const withdrawalReturn: Invoice = {
|
||||
id: 'inv-wd',
|
||||
kind: 'sale',
|
||||
documentType: 'return',
|
||||
number: 1,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-03',
|
||||
status: 'confirmed',
|
||||
note: 'withdrawal:wd-1\nبرداشت کیف پول',
|
||||
lines: [{ id: 'l3', productId: '', quantity: 1, unitPrice: 500, discount: 0, taxRate: 0 }],
|
||||
}
|
||||
assert.ok(isWithdrawalReturn(withdrawalReturn))
|
||||
assert.equal(stockDeltas(withdrawalReturn).size, 0)
|
||||
const updated = applyStockChanges([product], withdrawalReturn)
|
||||
assert.equal(updated[0].stock, 10)
|
||||
const data = baseData()
|
||||
const voucher = buildSalesVoucher(withdrawalReturn, data)
|
||||
assert.ok(voucher)
|
||||
assert.equal(voucher!.source, 'withdrawal:wd-1')
|
||||
}
|
||||
|
||||
// FIFO settlement: partial payment covers first invoice only
|
||||
{
|
||||
const data = baseData()
|
||||
data.invoices = [
|
||||
{ ...saleInvoice('confirmed'), id: 'inv-a', number: 1 },
|
||||
{ ...saleInvoice('confirmed'), id: 'inv-b', number: 2 },
|
||||
]
|
||||
data.treasury.push({
|
||||
id: 't3',
|
||||
number: 1,
|
||||
kind: 'receipt',
|
||||
method: 'bank',
|
||||
date: '2026-01-02',
|
||||
partyId: 'c1',
|
||||
amount: 200,
|
||||
reference: '',
|
||||
description: '',
|
||||
})
|
||||
assert.equal(partyReceivable('c1', data), 200)
|
||||
const settled = invoicesToSettle('c1', data)
|
||||
assert.equal(settled.length, 1)
|
||||
assert.equal(settled[0].id, 'inv-a')
|
||||
}
|
||||
|
||||
// Return + receipt: FIFO marks both invoices paid when fully covered
|
||||
{
|
||||
const data = baseData()
|
||||
data.invoices = [
|
||||
{ ...saleInvoice('confirmed'), id: 'inv-a', number: 1 },
|
||||
{ ...saleInvoice('confirmed'), id: 'inv-b', number: 2 },
|
||||
{
|
||||
id: 'inv-ret',
|
||||
kind: 'sale',
|
||||
documentType: 'return',
|
||||
number: 3,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-02',
|
||||
status: 'confirmed',
|
||||
note: '',
|
||||
lines: [{ id: 'lr', productId: 'p1', quantity: 1, unitPrice: 50, discount: 0, taxRate: 0 }],
|
||||
},
|
||||
]
|
||||
data.treasury.push({
|
||||
id: 't4',
|
||||
number: 1,
|
||||
kind: 'receipt',
|
||||
method: 'bank',
|
||||
date: '2026-01-03',
|
||||
partyId: 'c1',
|
||||
amount: 350,
|
||||
reference: '',
|
||||
description: '',
|
||||
})
|
||||
assert.equal(partyReceivable('c1', data), 0)
|
||||
const settled = invoicesToSettle('c1', data)
|
||||
assert.equal(settled.length, 2)
|
||||
}
|
||||
|
||||
// Treasury delete reverts paid status
|
||||
{
|
||||
const data = baseData()
|
||||
data.invoices = [{ ...saleInvoice('confirmed'), status: 'paid' }]
|
||||
data.treasury = []
|
||||
const reverted = reconcileInvoicePaymentStatus('c1', data)
|
||||
assert.equal(reverted.length, 1)
|
||||
assert.equal(reverted[0].status, 'confirmed')
|
||||
}
|
||||
|
||||
console.log('All salesUtils integration tests passed.')
|
||||
559
src/pages/sales/salesUtils.ts
Normal file
559
src/pages/sales/salesUtils.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import type {
|
||||
Account,
|
||||
AppData,
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
Party,
|
||||
PartyAdjustment,
|
||||
Product,
|
||||
SalesDocumentType,
|
||||
TreasuryTxn,
|
||||
Voucher,
|
||||
VoucherLine,
|
||||
} from '../../types'
|
||||
import { invoiceTotals } from '../../utils/accounting'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
|
||||
/** Resolves a chart account by code, with optional settings fallback. */
|
||||
export function accountByCode(
|
||||
accounts: Account[],
|
||||
code: string,
|
||||
fallbackId?: string,
|
||||
): string | null {
|
||||
return accounts.find((a) => a.code === code)?.id ?? fallbackId ?? null
|
||||
}
|
||||
|
||||
export const documentTypeLabels: Record<SalesDocumentType, string> = {
|
||||
proforma: 'پیشفاکتور',
|
||||
invoice: 'فاکتور فروش',
|
||||
return: 'فاکتور برگشتی',
|
||||
}
|
||||
|
||||
export const statusLabels: Record<InvoiceStatus, string> = {
|
||||
draft: 'پیشنویس',
|
||||
confirmed: 'قطعی',
|
||||
paid: 'تسویهشده',
|
||||
}
|
||||
|
||||
/** Sales documents only (kind=sale). */
|
||||
export function salesDocuments(invoices: Invoice[]): Invoice[] {
|
||||
return invoices.filter((i) => i.kind === 'sale')
|
||||
}
|
||||
|
||||
export function byDocumentType(invoices: Invoice[], type: SalesDocumentType): Invoice[] {
|
||||
return salesDocuments(invoices).filter((i) => (i.documentType ?? 'invoice') === type)
|
||||
}
|
||||
|
||||
export function customers(parties: Party[]): Party[] {
|
||||
return parties.filter((p) => p.kind === 'customer' && p.externalSource === 'funzone_customer')
|
||||
}
|
||||
|
||||
export function suppliers(parties: Party[]): Party[] {
|
||||
return parties.filter((p) => p.kind === 'supplier' && p.externalSource === 'funzone_owner')
|
||||
}
|
||||
|
||||
/** Customer receivable balance: opening + sales - receipts - returns + debits - credits. */
|
||||
export function partyReceivable(
|
||||
partyId: string,
|
||||
data: Pick<AppData, 'parties' | 'invoices' | 'partyAdjustments' | 'treasury'>,
|
||||
): number {
|
||||
const party = data.parties.find((p) => p.id === partyId)
|
||||
if (party?.kind === 'supplier') return 0
|
||||
let balance = party?.openingBalance ?? 0
|
||||
|
||||
for (const inv of salesDocuments(data.invoices)) {
|
||||
if (inv.partyId !== partyId || inv.status === 'draft') continue
|
||||
const net = invoiceTotals(inv).net
|
||||
const docType = inv.documentType ?? 'invoice'
|
||||
if (docType === 'proforma') continue
|
||||
balance += docType === 'return' ? -net : net
|
||||
}
|
||||
|
||||
for (const adj of data.partyAdjustments) {
|
||||
if (adj.partyId !== partyId) continue
|
||||
balance += adj.kind === 'debit' ? adj.amount : -adj.amount
|
||||
}
|
||||
|
||||
for (const txn of data.treasury) {
|
||||
if (txn.partyId !== partyId) continue
|
||||
if (txn.kind === 'receipt') balance -= txn.amount
|
||||
else balance += txn.amount
|
||||
}
|
||||
|
||||
return balance
|
||||
}
|
||||
|
||||
/** Supplier payable balance: opening + purchases - payments. */
|
||||
export function partyPayable(
|
||||
partyId: string,
|
||||
data: Pick<AppData, 'parties' | 'invoices' | 'treasury'>,
|
||||
): number {
|
||||
const party = data.parties.find((p) => p.id === partyId)
|
||||
if (party?.kind !== 'supplier') return 0
|
||||
let balance = party.openingBalance ?? 0
|
||||
|
||||
for (const inv of data.invoices) {
|
||||
if (inv.kind !== 'purchase' || inv.partyId !== partyId || inv.status === 'draft') continue
|
||||
balance += invoiceTotals(inv).net
|
||||
}
|
||||
|
||||
for (const txn of data.treasury) {
|
||||
if (txn.partyId !== partyId) continue
|
||||
if (txn.kind === 'payment') balance -= txn.amount
|
||||
else balance += txn.amount
|
||||
}
|
||||
|
||||
return balance
|
||||
}
|
||||
|
||||
/** Sepidar-style running account balance for a party (receivable or payable). */
|
||||
export function partyAccountBalance(
|
||||
partyId: string,
|
||||
data: Pick<AppData, 'parties' | 'invoices' | 'partyAdjustments' | 'treasury'>,
|
||||
): number {
|
||||
const party = data.parties.find((p) => p.id === partyId)
|
||||
if (party?.kind === 'supplier') return partyPayable(partyId, data)
|
||||
return partyReceivable(partyId, data)
|
||||
}
|
||||
|
||||
export function purchaseDocuments(invoices: Invoice[]): Invoice[] {
|
||||
return invoices.filter((i) => i.kind === 'purchase')
|
||||
}
|
||||
|
||||
/** Whether stock/inventory should be affected for this document. */
|
||||
export function isWithdrawalReturn(invoice: Invoice): boolean {
|
||||
return (
|
||||
invoice.kind === 'sale' &&
|
||||
(invoice.documentType ?? 'invoice') === 'return' &&
|
||||
invoice.note.includes('withdrawal:')
|
||||
)
|
||||
}
|
||||
|
||||
export function withdrawalIdFromNote(note: string): string | null {
|
||||
const match = note.match(/withdrawal:([^\s\n]+)/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/** Whether stock/inventory should be affected for this document. */
|
||||
export function affectsStock(invoice: Invoice): boolean {
|
||||
if (invoice.status === 'draft') return false
|
||||
if (isWithdrawalReturn(invoice)) return false
|
||||
if (invoice.kind === 'purchase') return true
|
||||
const docType = invoice.documentType ?? 'invoice'
|
||||
return docType !== 'proforma'
|
||||
}
|
||||
|
||||
/** Computes stock delta per product (negative = outbound, positive = inbound). */
|
||||
export function stockDeltas(invoice: Invoice): Map<string, number> {
|
||||
const deltas = new Map<string, number>()
|
||||
if (!affectsStock(invoice)) return deltas
|
||||
|
||||
if (invoice.kind === 'purchase') {
|
||||
for (const line of invoice.lines) {
|
||||
if (!line.productId) continue
|
||||
deltas.set(line.productId, (deltas.get(line.productId) ?? 0) + line.quantity)
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
const docType = invoice.documentType ?? 'invoice'
|
||||
const sign = docType === 'return' ? 1 : -1
|
||||
for (const line of invoice.lines) {
|
||||
if (!line.productId) continue
|
||||
deltas.set(line.productId, (deltas.get(line.productId) ?? 0) + sign * line.quantity)
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
/** Applies stock changes when a sales document is saved; reverts previous if provided. */
|
||||
export function applyStockChanges(
|
||||
products: Product[],
|
||||
invoice: Invoice,
|
||||
previous?: Invoice | null,
|
||||
): Product[] {
|
||||
const updated = new Map(products.map((p) => [p.id, { ...p }]))
|
||||
|
||||
if (previous) {
|
||||
for (const [productId, delta] of stockDeltas(previous)) {
|
||||
const product = updated.get(productId)
|
||||
if (product) product.stock = Math.max(0, product.stock - delta)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [productId, delta] of stockDeltas(invoice)) {
|
||||
const product = updated.get(productId)
|
||||
if (product) product.stock = Math.max(0, product.stock + delta)
|
||||
}
|
||||
|
||||
return Array.from(updated.values())
|
||||
}
|
||||
|
||||
/** Builds an accounting voucher for a confirmed sales invoice or return. */
|
||||
export function buildSalesVoucher(
|
||||
invoice: Invoice,
|
||||
data: Pick<AppData, 'accounts' | 'settings' | 'vouchers' | 'parties' | 'products'>,
|
||||
): Voucher | null {
|
||||
if (invoice.kind !== 'sale' || invoice.status === 'draft') return null
|
||||
const docType = invoice.documentType ?? 'invoice'
|
||||
if (docType === 'proforma') return null
|
||||
|
||||
const { salesIncomeAccountId } = data.settings.voucherAccounts
|
||||
const receivableAccountId = accountByCode(data.accounts, '1004')
|
||||
|
||||
const net = invoiceTotals(invoice).net
|
||||
if (net <= 0) return null
|
||||
|
||||
const party = data.parties.find((p) => p.id === invoice.partyId)
|
||||
const label = `${documentTypeLabels[docType]} #${invoice.number} - ${party?.name ?? ''}`
|
||||
|
||||
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||
id: createId('vl-'),
|
||||
accountId,
|
||||
description: desc,
|
||||
debit,
|
||||
credit,
|
||||
})
|
||||
|
||||
if (isWithdrawalReturn(invoice)) {
|
||||
const { customerPayableAccountId, bankAccountId: bankSettingId } = data.settings.voucherAccounts
|
||||
const bankId = accountByCode(data.accounts, '1002', bankSettingId)
|
||||
const payableId = customerPayableAccountId || receivableAccountId
|
||||
if (!bankId || !payableId) return null
|
||||
|
||||
const withdrawalId = withdrawalIdFromNote(invoice.note)
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: invoice.date,
|
||||
description: `برداشت کیف پول - ${label}`,
|
||||
status: 'posted',
|
||||
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice.id),
|
||||
lines: [
|
||||
makeLine(payableId, net, 0, `برداشت کیف پول - ${label}`),
|
||||
makeLine(bankId, 0, net, `پرداخت از بانک - ${label}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!salesIncomeAccountId || !receivableAccountId) return null
|
||||
|
||||
const isReturn = docType === 'return'
|
||||
const lines: VoucherLine[] = isReturn
|
||||
? [
|
||||
makeLine(salesIncomeAccountId, net, 0, `برگشت فروش - ${label}`),
|
||||
makeLine(receivableAccountId, 0, net, `کاهش مطالبات - ${label}`),
|
||||
]
|
||||
: [
|
||||
makeLine(receivableAccountId, net, 0, `مطالبات مشتری - ${label}`),
|
||||
makeLine(salesIncomeAccountId, 0, net, `درآمد فروش - ${label}`),
|
||||
]
|
||||
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: invoice.date,
|
||||
description: label,
|
||||
status: 'posted',
|
||||
source: voucherSourceForInvoice(invoice.id),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds an accounting voucher for a confirmed purchase invoice. */
|
||||
export function buildPurchaseVoucher(
|
||||
invoice: Invoice,
|
||||
data: Pick<AppData, 'accounts' | 'vouchers' | 'parties'>,
|
||||
): Voucher | null {
|
||||
if (invoice.kind !== 'purchase' || invoice.status === 'draft') return null
|
||||
|
||||
const inventoryAccountId = accountByCode(data.accounts, '1005')
|
||||
const payableAccountId = accountByCode(data.accounts, '2001')
|
||||
if (!inventoryAccountId || !payableAccountId) return null
|
||||
|
||||
const net = invoiceTotals(invoice).net
|
||||
if (net <= 0) return null
|
||||
|
||||
const party = data.parties.find((p) => p.id === invoice.partyId)
|
||||
const label = `فاکتور خرید #${invoice.number} - ${party?.name ?? ''}`
|
||||
|
||||
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||
id: createId('vl-'),
|
||||
accountId,
|
||||
description: desc,
|
||||
debit,
|
||||
credit,
|
||||
})
|
||||
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: invoice.date,
|
||||
description: label,
|
||||
status: 'posted',
|
||||
source: voucherSourceForInvoice(invoice.id),
|
||||
lines: [
|
||||
makeLine(inventoryAccountId, net, 0, `افزایش موجودی - ${label}`),
|
||||
makeLine(payableAccountId, 0, net, `بدهی به تأمینکننده - ${label}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the appropriate voucher for any confirmed invoice. */
|
||||
export function buildInvoiceVoucher(
|
||||
invoice: Invoice,
|
||||
data: Pick<AppData, 'accounts' | 'settings' | 'vouchers' | 'parties' | 'products'>,
|
||||
): Voucher | null {
|
||||
if (invoice.kind === 'sale') return buildSalesVoucher(invoice, data)
|
||||
if (invoice.kind === 'purchase') return buildPurchaseVoucher(invoice, data)
|
||||
return null
|
||||
}
|
||||
|
||||
/** Confirmed sale/purchase invoices eligible for treasury settlement (excludes returns & proforma). */
|
||||
function settlementCandidates(
|
||||
partyId: string,
|
||||
data: Pick<AppData, 'parties' | 'invoices'>,
|
||||
): Invoice[] {
|
||||
const party = data.parties.find((p) => p.id === partyId)
|
||||
if (!party) return []
|
||||
|
||||
const kind = party.kind === 'supplier' ? 'purchase' : 'sale'
|
||||
return data.invoices
|
||||
.filter((inv) => {
|
||||
if (inv.kind !== kind || inv.partyId !== partyId || inv.status !== 'confirmed') return false
|
||||
if (kind === 'sale') {
|
||||
const docType = inv.documentType ?? 'invoice'
|
||||
return docType === 'invoice'
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date) || a.number - b.number)
|
||||
}
|
||||
|
||||
/** Marks confirmed invoices as paid using FIFO allocation of treasury/adjustment/return credits. */
|
||||
export function invoicesToSettle(
|
||||
partyId: string,
|
||||
data: Pick<AppData, 'parties' | 'invoices' | 'partyAdjustments' | 'treasury'>,
|
||||
): Invoice[] {
|
||||
const candidates = settlementCandidates(partyId, data)
|
||||
if (candidates.length === 0) return []
|
||||
|
||||
const totalInvoiceNet = candidates.reduce((sum, inv) => sum + invoiceTotals(inv).net, 0)
|
||||
const balance = partyAccountBalance(partyId, data)
|
||||
const amountApplied = totalInvoiceNet - Math.max(0, balance)
|
||||
if (amountApplied <= 0.001) return []
|
||||
|
||||
let remaining = amountApplied
|
||||
const toSettle: Invoice[] = []
|
||||
for (const inv of candidates) {
|
||||
const net = invoiceTotals(inv).net
|
||||
if (remaining + 0.001 >= net) {
|
||||
toSettle.push({ ...inv, status: 'paid' })
|
||||
remaining -= net
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return toSettle
|
||||
}
|
||||
|
||||
/** Syncs invoice paid/confirmed status after treasury or adjustment changes. */
|
||||
export function reconcileInvoicePaymentStatus(
|
||||
partyId: string,
|
||||
data: Pick<AppData, 'parties' | 'invoices' | 'partyAdjustments' | 'treasury'>,
|
||||
): Invoice[] {
|
||||
const party = data.parties.find((p) => p.id === partyId)
|
||||
if (!party) return []
|
||||
|
||||
const shouldBePaidIds = new Set(invoicesToSettle(partyId, data).map((inv) => inv.id))
|
||||
const kind = party.kind === 'supplier' ? 'purchase' : 'sale'
|
||||
|
||||
const updates: Invoice[] = []
|
||||
for (const inv of data.invoices) {
|
||||
if (inv.kind !== kind || inv.partyId !== partyId || inv.status === 'draft') continue
|
||||
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
|
||||
|
||||
const shouldPaid = shouldBePaidIds.has(inv.id)
|
||||
if (inv.status === 'paid' && !shouldPaid) updates.push({ ...inv, status: 'confirmed' })
|
||||
else if (inv.status === 'confirmed' && shouldPaid) updates.push({ ...inv, status: 'paid' })
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
export function findVoucherForInvoice(
|
||||
vouchers: Voucher[],
|
||||
invoice: Invoice,
|
||||
): Voucher | undefined {
|
||||
const byInvoice = vouchers.find((v) => v.source === voucherSourceForInvoice(invoice.id))
|
||||
if (byInvoice) return byInvoice
|
||||
|
||||
const withdrawalId = withdrawalIdFromNote(invoice.note)
|
||||
if (withdrawalId) {
|
||||
return vouchers.find((v) => v.source === `withdrawal:${withdrawalId}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Builds an accounting voucher for a debit/credit notice (اعلامیه). */
|
||||
export function buildAdjustmentVoucher(
|
||||
adjustment: PartyAdjustment,
|
||||
data: Pick<AppData, 'accounts' | 'settings' | 'vouchers' | 'parties'>,
|
||||
): Voucher | null {
|
||||
const receivableAccountId = accountByCode(data.accounts, '1004')
|
||||
const { salesIncomeAccountId } = data.settings.voucherAccounts
|
||||
if (!receivableAccountId || !salesIncomeAccountId || adjustment.amount <= 0) return null
|
||||
|
||||
const party = data.parties.find((p) => p.id === adjustment.partyId)
|
||||
const kindLabel = adjustment.kind === 'debit' ? 'بدهکار' : 'بستانکار'
|
||||
const label = `اعلامیه ${kindLabel} #${adjustment.number} - ${party?.name ?? ''}`
|
||||
|
||||
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||
id: createId('vl-'),
|
||||
accountId,
|
||||
description: desc,
|
||||
debit,
|
||||
credit,
|
||||
})
|
||||
|
||||
const amount = adjustment.amount
|
||||
const lines: VoucherLine[] =
|
||||
adjustment.kind === 'debit'
|
||||
? [
|
||||
makeLine(receivableAccountId, amount, 0, `افزایش مطالبات - ${label}`),
|
||||
makeLine(salesIncomeAccountId, 0, amount, `اصلاح درآمد - ${label}`),
|
||||
]
|
||||
: [
|
||||
makeLine(salesIncomeAccountId, amount, 0, `اصلاح درآمد - ${label}`),
|
||||
makeLine(receivableAccountId, 0, amount, `کاهش مطالبات - ${label}`),
|
||||
]
|
||||
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: adjustment.date,
|
||||
description: adjustment.description || label,
|
||||
status: 'posted',
|
||||
source: voucherSourceForAdjustment(adjustment.id),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds an accounting voucher for a treasury receipt or payment. */
|
||||
export function buildTreasuryVoucher(
|
||||
txn: TreasuryTxn,
|
||||
data: Pick<AppData, 'accounts' | 'settings' | 'vouchers' | 'parties'>,
|
||||
): Voucher | null {
|
||||
if (txn.amount <= 0) return null
|
||||
|
||||
const { bankAccountId, salesIncomeAccountId, ownerPayableAccountId } = data.settings.voucherAccounts
|
||||
const bankId = accountByCode(data.accounts, '1002', bankAccountId)
|
||||
if (!bankId) return null
|
||||
|
||||
const party = txn.partyId ? data.parties.find((p) => p.id === txn.partyId) : undefined
|
||||
const counterAccountId =
|
||||
party?.kind === 'supplier'
|
||||
? accountByCode(data.accounts, '2005', ownerPayableAccountId) ??
|
||||
accountByCode(data.accounts, '2001')
|
||||
: accountByCode(data.accounts, '1004')
|
||||
|
||||
const taxAccountId = accountByCode(data.accounts, '2002')
|
||||
const ownerPayableId = accountByCode(data.accounts, '2005', ownerPayableAccountId)
|
||||
const platformIncomeId = accountByCode(data.accounts, '4001', salesIncomeAccountId)
|
||||
|
||||
const kindLabel = txn.kind === 'receipt' ? 'دریافت' : 'پرداخت'
|
||||
const label = `${kindLabel} #${txn.number}${party ? ` - ${party.name}` : ''}`
|
||||
|
||||
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||
id: createId('vl-'),
|
||||
accountId,
|
||||
description: desc,
|
||||
debit,
|
||||
credit,
|
||||
})
|
||||
|
||||
const amount = txn.amount
|
||||
const tax = txn.taxAmount ?? 0
|
||||
const profit = txn.platformProfitAmount ?? 0
|
||||
const ownerNet = txn.ownerNetAmount ?? 0
|
||||
const hasEventSplit =
|
||||
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
|
||||
|
||||
if (hasEventSplit) {
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: txn.date,
|
||||
description: txn.description || label,
|
||||
status: 'posted',
|
||||
source: voucherSourceForTreasury(txn.id),
|
||||
lines: [
|
||||
makeLine(bankId, amount, 0, `واریز بابت رویداد - ${label}`),
|
||||
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
|
||||
makeLine(platformIncomeId!, 0, profit, `سود پلتفرم (۱۴٪) - ${label}`),
|
||||
makeLine(taxAccountId!, 0, tax, `مالیات (۱۰٪) - ${label}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!counterAccountId) return null
|
||||
|
||||
const lines: VoucherLine[] =
|
||||
txn.kind === 'receipt'
|
||||
? [
|
||||
makeLine(bankId, amount, 0, `واریز - ${label}`),
|
||||
makeLine(counterAccountId, 0, amount, `تسویه مطالبات - ${label}`),
|
||||
]
|
||||
: [
|
||||
makeLine(counterAccountId, amount, 0, `تسویه بدهی - ${label}`),
|
||||
makeLine(bankId, 0, amount, `برداشت - ${label}`),
|
||||
]
|
||||
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: txn.date,
|
||||
description: txn.description || label,
|
||||
status: 'posted',
|
||||
source: voucherSourceForTreasury(txn.id),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
export function voucherSourceForInvoice(invoiceId: string): string {
|
||||
return `invoice:${invoiceId}`
|
||||
}
|
||||
|
||||
export function voucherSourceForAdjustment(adjustmentId: string): string {
|
||||
return `adjustment:${adjustmentId}`
|
||||
}
|
||||
|
||||
export function voucherSourceForTreasury(txnId: string): string {
|
||||
return `treasury:${txnId}`
|
||||
}
|
||||
|
||||
/** Summary stats for the sales process dashboard. */
|
||||
export function salesSummary(data: AppData) {
|
||||
const proformas = byDocumentType(data.invoices, 'proforma')
|
||||
const invoices = byDocumentType(data.invoices, 'invoice')
|
||||
const returns = byDocumentType(data.invoices, 'return')
|
||||
|
||||
const sumNet = (list: Invoice[]) =>
|
||||
list.filter((i) => i.status !== 'draft').reduce((s, i) => s + invoiceTotals(i).net, 0)
|
||||
|
||||
const unpaid = invoices.filter((i) => i.status === 'confirmed')
|
||||
const customersList = customers(data.parties)
|
||||
|
||||
return {
|
||||
proformaCount: proformas.length,
|
||||
invoiceCount: invoices.length,
|
||||
returnCount: returns.length,
|
||||
adjustmentCount: data.partyAdjustments.length,
|
||||
salesTypeCount: data.salesTypes.length,
|
||||
customerCount: customersList.length,
|
||||
totalSales: sumNet(invoices),
|
||||
totalReturns: sumNet(returns),
|
||||
netSales: sumNet(invoices) - sumNet(returns),
|
||||
unpaidCount: unpaid.length,
|
||||
unpaidAmount: sumNet(unpaid),
|
||||
openProformas: proformas.filter((p) => p.status === 'draft').length,
|
||||
}
|
||||
}
|
||||
44
src/pages/sales/usePendingCustomerWithdrawals.ts
Normal file
44
src/pages/sales/usePendingCustomerWithdrawals.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiGet } from '../../api/client'
|
||||
import { ENDPOINTS } from '../../api/config'
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
|
||||
/** Pending customer wallet withdrawals from admin panel accounting API. */
|
||||
export function usePendingCustomerWithdrawals(enabled = true) {
|
||||
const [withdrawals, setWithdrawals] = useState<ApiWithdrawal[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
|
||||
const pending = (res.withdrawals ?? []).filter(
|
||||
(w) => w.user_type === 'customer' && w.status === 'pending',
|
||||
)
|
||||
setWithdrawals(pending)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'خطا در بارگذاری برداشتها')
|
||||
setWithdrawals([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) void load()
|
||||
}, [enabled, load])
|
||||
|
||||
return { withdrawals, loading, error, reload: load }
|
||||
}
|
||||
|
||||
export const withdrawalNoteTag = (withdrawalId: string): string => `withdrawal:${withdrawalId}`
|
||||
|
||||
export function invoiceForWithdrawal(
|
||||
invoices: { note: string; status: string }[],
|
||||
withdrawalId: string,
|
||||
): boolean {
|
||||
const tag = withdrawalNoteTag(withdrawalId)
|
||||
return invoices.some((inv) => inv.note.includes(tag) && inv.status !== 'draft')
|
||||
}
|
||||
151
src/pages/treasury/funzoneTreasurySync.test.ts
Normal file
151
src/pages/treasury/funzoneTreasurySync.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Run: npx tsx src/pages/treasury/funzoneTreasurySync.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
||||
import type { Party, TreasuryTxn } from '../../types'
|
||||
import { splitEventPayment, ownerTxnExcludedFromTreasury } from '../../utils/funzoneFees'
|
||||
import {
|
||||
buildFunZoneTreasurySync,
|
||||
buildTreasuryFromCustomerPayment,
|
||||
buildTreasuryFromOwnerTransaction,
|
||||
} from './funzoneTreasurySync'
|
||||
|
||||
// Fee split matches admin panel (10% tax + 14% profit)
|
||||
{
|
||||
const split = splitEventPayment(100_000)
|
||||
assert.equal(split.tax, 10_000)
|
||||
assert.equal(split.platformProfit, 14_000)
|
||||
assert.equal(split.ownerNet, 76_000)
|
||||
}
|
||||
|
||||
assert.ok(ownerTxnExcludedFromTreasury('deposit'))
|
||||
assert.ok(ownerTxnExcludedFromTreasury('booking_payment'))
|
||||
assert.equal(ownerTxnExcludedFromTreasury('withdraw'), false)
|
||||
|
||||
const customer: Party = {
|
||||
id: 'c1',
|
||||
kind: 'customer',
|
||||
name: 'Ali',
|
||||
phone: '',
|
||||
economicCode: '',
|
||||
address: '',
|
||||
openingBalance: 0,
|
||||
externalId: 'cust-1',
|
||||
externalSource: 'funzone_customer',
|
||||
}
|
||||
|
||||
const owner: Party = {
|
||||
id: 'o1',
|
||||
kind: 'supplier',
|
||||
name: 'Owner',
|
||||
phone: '',
|
||||
economicCode: '',
|
||||
address: '',
|
||||
openingBalance: 0,
|
||||
externalId: 'own-1',
|
||||
externalSource: 'funzone_owner',
|
||||
}
|
||||
|
||||
// Event payment → receipt with fee split
|
||||
{
|
||||
const item: ApiPaymentOrRefund = {
|
||||
id: 'pay-1',
|
||||
type: 'payment',
|
||||
customer_id: 'cust-1',
|
||||
customer_name: 'Ali',
|
||||
trace_no: 'T1',
|
||||
ref_num: 'R1',
|
||||
amount: 100_000,
|
||||
event_name: 'Football Night',
|
||||
event_date: '2026-01-01',
|
||||
event_time: '18:00',
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
reservation_id: 'res-1',
|
||||
refund_status: null,
|
||||
}
|
||||
const txn = buildTreasuryFromCustomerPayment(item, 'c1', 1)!
|
||||
assert.equal(txn.kind, 'receipt')
|
||||
assert.equal(txn.platformProfitAmount, 14_000)
|
||||
assert.equal(txn.taxAmount, 10_000)
|
||||
assert.equal(txn.ownerNetAmount, 76_000)
|
||||
}
|
||||
|
||||
// Owner deposit & booking_payment excluded from sync
|
||||
{
|
||||
const ownerTxns: ApiOwnerTransaction[] = [
|
||||
{
|
||||
id: 'ot-deposit',
|
||||
owner_id: 'own-1',
|
||||
type: 'deposit',
|
||||
amount: 50_000,
|
||||
status: 'completed',
|
||||
description: 'deposit',
|
||||
created_at: '2026-01-02T10:00:00Z',
|
||||
updated_at: '2026-01-02T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ot-booking',
|
||||
owner_id: 'own-1',
|
||||
type: 'booking_payment',
|
||||
amount: 100_000,
|
||||
status: 'completed',
|
||||
description: 'booking',
|
||||
created_at: '2026-01-02T11:00:00Z',
|
||||
updated_at: '2026-01-02T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ot-withdraw',
|
||||
owner_id: 'own-1',
|
||||
type: 'withdraw',
|
||||
amount: 76_000,
|
||||
status: 'completed',
|
||||
description: 'withdraw',
|
||||
created_at: '2026-01-03T10:00:00Z',
|
||||
updated_at: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const stale: TreasuryTxn = {
|
||||
id: 'tr-stale',
|
||||
number: 1,
|
||||
kind: 'payment',
|
||||
method: 'bank',
|
||||
date: '2026-01-02',
|
||||
partyId: 'o1',
|
||||
amount: 100_000,
|
||||
reference: '',
|
||||
description: 'old',
|
||||
source: 'funzone:owner-txn:ot-booking',
|
||||
}
|
||||
|
||||
const result = buildFunZoneTreasurySync(
|
||||
{ parties: [customer, owner], treasury: [stale] },
|
||||
[],
|
||||
ownerTxns,
|
||||
)
|
||||
|
||||
assert.equal(result.excludedOwnerTxns, 2)
|
||||
assert.equal(result.toRemove.length, 1)
|
||||
assert.equal(result.toRemove[0].id, 'tr-stale')
|
||||
assert.equal(result.toUpsert.length, 1)
|
||||
assert.equal(result.toUpsert[0].source, 'funzone:owner-txn:ot-withdraw')
|
||||
}
|
||||
|
||||
assert.equal(buildTreasuryFromOwnerTransaction(
|
||||
{
|
||||
id: 'x',
|
||||
owner_id: 'own-1',
|
||||
type: 'deposit',
|
||||
amount: 1,
|
||||
status: 'completed',
|
||||
description: '',
|
||||
created_at: '2026-01-01',
|
||||
updated_at: '2026-01-01',
|
||||
},
|
||||
'o1',
|
||||
'Owner',
|
||||
1,
|
||||
), null)
|
||||
|
||||
console.log('funzoneTreasurySync tests passed.')
|
||||
257
src/pages/treasury/funzoneTreasurySync.ts
Normal file
257
src/pages/treasury/funzoneTreasurySync.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
||||
import type { AppData, TreasuryTxn } from '../../types'
|
||||
import { findExistingParty } from '../../parties/funzonePartySync'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
import {
|
||||
isEventCustomerPayment,
|
||||
ownerTxnExcludedFromTreasury,
|
||||
ownerTxnIncludedInTreasury,
|
||||
splitEventPayment,
|
||||
} from '../../utils/funzoneFees'
|
||||
|
||||
const ownerTypeLabels: Record<string, string> = {
|
||||
withdraw: 'برداشت مالک به بانک',
|
||||
refund: 'بازپرداخت/کسر از مالک',
|
||||
}
|
||||
|
||||
export function treasurySourceCustomerPayment(id: string): string {
|
||||
return `funzone:cust-pay:${id}`
|
||||
}
|
||||
|
||||
export function treasurySourceOwnerTxn(id: string): string {
|
||||
return `funzone:owner-txn:${id}`
|
||||
}
|
||||
|
||||
export function isFunZoneTreasuryTxn(txn: TreasuryTxn): boolean {
|
||||
return Boolean(txn.source?.startsWith('funzone:'))
|
||||
}
|
||||
|
||||
export function findTreasuryBySource(treasury: TreasuryTxn[], source: string): TreasuryTxn | undefined {
|
||||
return treasury.find((t) => t.source === source)
|
||||
}
|
||||
|
||||
function isoDateFromApi(iso: string): string {
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
|
||||
function eventLabel(eventName: string | null | undefined): string {
|
||||
if (!eventName) return '—'
|
||||
return eventName.toLowerCase() === 'wallet' ? 'کیف پول' : eventName
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'completed') return 'انجامشده'
|
||||
if (status === 'pending') return 'در انتظار'
|
||||
if (status === 'failed') return 'ناموفق'
|
||||
return status
|
||||
}
|
||||
|
||||
/** Customer platform payment → دریافت; cancellation/refund → پرداخت. */
|
||||
export function buildTreasuryFromCustomerPayment(
|
||||
item: ApiPaymentOrRefund,
|
||||
partyId: string,
|
||||
number: number,
|
||||
existing?: TreasuryTxn,
|
||||
): TreasuryTxn | null {
|
||||
const amount = Math.abs(item.amount)
|
||||
if (amount <= 0) return null
|
||||
|
||||
const isPayment = item.type === 'payment'
|
||||
const kind = isPayment ? 'receipt' : 'payment'
|
||||
const isEvent = isPayment && isEventCustomerPayment(item.event_name)
|
||||
const split = isEvent ? splitEventPayment(amount) : null
|
||||
|
||||
const lines = [
|
||||
isPayment
|
||||
? `دریافت از مشتری — ${item.customer_name}`
|
||||
: `بازپرداخت به مشتری — ${item.customer_name}`,
|
||||
`نوع: ${isPayment ? 'پرداخت مشتری' : 'لغو/بازپرداخت'}`,
|
||||
`رویداد: ${eventLabel(item.event_name)}`,
|
||||
split
|
||||
? [
|
||||
`مبلغ کل: ${split.gross}`,
|
||||
`مالیات (۱۰٪): ${split.tax}`,
|
||||
`سود پلتفرم (۱۴٪): ${split.platformProfit}`,
|
||||
`سهم مالک (خالص): ${split.ownerNet}`,
|
||||
].join('\n')
|
||||
: null,
|
||||
item.event_date ? `تاریخ رویداد: ${item.event_date}${item.event_time ? ` ${item.event_time}` : ''}` : null,
|
||||
item.reservation_id ? `شناسه رزرو: ${item.reservation_id}` : null,
|
||||
item.trace_no ? `شماره پیگیری: ${item.trace_no}` : null,
|
||||
item.ref_num ? `مرجع بانکی: ${item.ref_num}` : null,
|
||||
item.refund_status ? `وضعیت بازپرداخت: ${statusLabel(item.refund_status)}` : null,
|
||||
'منبع: فانزون',
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
id: existing?.id ?? '',
|
||||
number: existing?.number ?? number,
|
||||
kind,
|
||||
method: 'bank',
|
||||
date: isoDateFromApi(item.created_at),
|
||||
partyId,
|
||||
amount,
|
||||
taxAmount: split?.tax ?? 0,
|
||||
platformProfitAmount: split?.platformProfit ?? 0,
|
||||
ownerNetAmount: split?.ownerNet ?? 0,
|
||||
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
||||
description: lines.join('\n'),
|
||||
source: treasurySourceCustomerPayment(item.id),
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner bank payout or refund only (excludes deposit & booking_payment). */
|
||||
export function buildTreasuryFromOwnerTransaction(
|
||||
txn: ApiOwnerTransaction,
|
||||
partyId: string,
|
||||
partyName: string,
|
||||
number: number,
|
||||
existing?: TreasuryTxn,
|
||||
): TreasuryTxn | null {
|
||||
if (!ownerTxnIncludedInTreasury(txn.type)) return null
|
||||
|
||||
const amount = Math.abs(txn.amount)
|
||||
if (amount <= 0) return null
|
||||
|
||||
const lines = [
|
||||
`${ownerTypeLabels[txn.type] ?? txn.type} — ${partyName}`,
|
||||
`وضعیت: ${statusLabel(txn.status)}`,
|
||||
txn.description ? `توضیحات: ${txn.description}` : null,
|
||||
'منبع: فانزون',
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
id: existing?.id ?? '',
|
||||
number: existing?.number ?? number,
|
||||
kind: 'payment',
|
||||
method: 'bank',
|
||||
date: isoDateFromApi(txn.created_at),
|
||||
partyId,
|
||||
amount,
|
||||
taxAmount: 0,
|
||||
platformProfitAmount: 0,
|
||||
ownerNetAmount: amount,
|
||||
reference: txn.id.slice(0, 8),
|
||||
description: lines.join('\n'),
|
||||
source: treasurySourceOwnerTxn(txn.id),
|
||||
}
|
||||
}
|
||||
|
||||
export interface FunZoneTreasurySyncResult {
|
||||
toUpsert: TreasuryTxn[]
|
||||
toRemove: TreasuryTxn[]
|
||||
skipped: number
|
||||
unmatchedCustomers: number
|
||||
unmatchedOwners: number
|
||||
excludedOwnerTxns: number
|
||||
}
|
||||
|
||||
/** Builds treasury rows from FunZone APIs; skips unchanged existing imports. */
|
||||
export function buildFunZoneTreasurySync(
|
||||
data: Pick<AppData, 'parties' | 'treasury'>,
|
||||
customerPayments: ApiPaymentOrRefund[],
|
||||
ownerTransactions: ApiOwnerTransaction[],
|
||||
): FunZoneTreasurySyncResult {
|
||||
const toUpsert: TreasuryTxn[] = []
|
||||
let skipped = 0
|
||||
let unmatchedCustomers = 0
|
||||
let unmatchedOwners = 0
|
||||
let excludedOwnerTxns = 0
|
||||
|
||||
const staleSources = new Set(
|
||||
ownerTransactions
|
||||
.filter((t) => ownerTxnExcludedFromTreasury(t.type))
|
||||
.map((t) => treasurySourceOwnerTxn(t.id)),
|
||||
)
|
||||
const toRemove = data.treasury.filter((t) => t.source && staleSources.has(t.source))
|
||||
|
||||
let receiptNumber = nextNumber(data.treasury.filter((t) => t.kind === 'receipt'))
|
||||
let paymentNumber = nextNumber(data.treasury.filter((t) => t.kind === 'payment'))
|
||||
|
||||
for (const item of customerPayments) {
|
||||
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
|
||||
if (!party?.id) {
|
||||
unmatchedCustomers += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const source = treasurySourceCustomerPayment(item.id)
|
||||
const existing = findTreasuryBySource(data.treasury, source)
|
||||
const built = buildTreasuryFromCustomerPayment(
|
||||
item,
|
||||
party.id,
|
||||
existing?.number ?? receiptNumber,
|
||||
existing,
|
||||
)
|
||||
if (!built) continue
|
||||
|
||||
if (existing && treasuryUnchanged(existing, built)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
toUpsert.push(built)
|
||||
if (!existing && built.kind === 'receipt') receiptNumber += 1
|
||||
if (!existing && built.kind === 'payment') paymentNumber += 1
|
||||
}
|
||||
|
||||
for (const txn of ownerTransactions) {
|
||||
if (txn.status === 'failed') continue
|
||||
|
||||
if (ownerTxnExcludedFromTreasury(txn.type)) {
|
||||
excludedOwnerTxns += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!ownerTxnIncludedInTreasury(txn.type)) continue
|
||||
|
||||
const party = findExistingParty(data.parties, txn.owner_id, 'funzone_owner')
|
||||
if (!party?.id) {
|
||||
unmatchedOwners += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const source = treasurySourceOwnerTxn(txn.id)
|
||||
const existing = findTreasuryBySource(data.treasury, source)
|
||||
const built = buildTreasuryFromOwnerTransaction(
|
||||
txn,
|
||||
party.id,
|
||||
party.name,
|
||||
existing?.number ?? paymentNumber,
|
||||
existing,
|
||||
)
|
||||
if (!built) continue
|
||||
|
||||
if (existing && treasuryUnchanged(existing, built)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
toUpsert.push(built)
|
||||
if (!existing) paymentNumber += 1
|
||||
}
|
||||
|
||||
return { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns }
|
||||
}
|
||||
|
||||
function treasuryUnchanged(a: TreasuryTxn, b: TreasuryTxn): boolean {
|
||||
return (
|
||||
a.kind === b.kind &&
|
||||
a.method === b.method &&
|
||||
a.date === b.date &&
|
||||
a.partyId === b.partyId &&
|
||||
Math.abs(a.amount - b.amount) < 0.01 &&
|
||||
Math.abs((a.taxAmount ?? 0) - (b.taxAmount ?? 0)) < 0.01 &&
|
||||
Math.abs((a.platformProfitAmount ?? 0) - (b.platformProfitAmount ?? 0)) < 0.01 &&
|
||||
Math.abs((a.ownerNetAmount ?? 0) - (b.ownerNetAmount ?? 0)) < 0.01 &&
|
||||
a.reference === b.reference &&
|
||||
a.description === b.description
|
||||
)
|
||||
}
|
||||
|
||||
export function assignTreasuryIds(items: TreasuryTxn[]): TreasuryTxn[] {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
id: item.id || createId('tr-'),
|
||||
}))
|
||||
}
|
||||
140
src/pages/treasury/useFunZoneTreasurySync.ts
Normal file
140
src/pages/treasury/useFunZoneTreasurySync.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { apiGet, asList } from '../../api/client'
|
||||
import { ENDPOINTS } from '../../api/config'
|
||||
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
||||
import type { AppData, TreasuryTxn } from '../../types'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
import { buildTreasuryVoucher, reconcileInvoicePaymentStatus, voucherSourceForTreasury } from '../sales/salesUtils'
|
||||
import {
|
||||
assignTreasuryIds,
|
||||
buildFunZoneTreasurySync,
|
||||
} from './funzoneTreasurySync'
|
||||
|
||||
export interface FunZoneTreasurySyncStats {
|
||||
created: number
|
||||
updated: number
|
||||
removed: number
|
||||
skipped: number
|
||||
unmatchedCustomers: number
|
||||
unmatchedOwners: number
|
||||
excludedOwnerTxns: number
|
||||
customerPaymentCount: number
|
||||
ownerTxnCount: number
|
||||
}
|
||||
|
||||
export function useFunZoneTreasurySync() {
|
||||
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stats, setStats] = useState<FunZoneTreasurySyncStats | null>(null)
|
||||
const autoSynced = useRef(false)
|
||||
|
||||
const persistTreasuryBatch = useCallback(
|
||||
(baseData: AppData, items: TreasuryTxn[]): FunZoneTreasurySyncStats => {
|
||||
const saved = assignTreasuryIds(items)
|
||||
let created = 0
|
||||
let updated = 0
|
||||
let working = { ...baseData }
|
||||
|
||||
for (const txn of saved) {
|
||||
const isNew = !baseData.treasury.some((t) => t.id === txn.id)
|
||||
if (isNew) created += 1
|
||||
else updated += 1
|
||||
|
||||
upsertTreasury(txn)
|
||||
|
||||
const existingVoucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(txn.id))
|
||||
const voucher = buildTreasuryVoucher(txn, working)
|
||||
if (existingVoucher) removeVoucher(existingVoucher.id)
|
||||
if (voucher) upsertVoucher(voucher)
|
||||
|
||||
working = {
|
||||
...working,
|
||||
treasury: [...working.treasury.filter((t) => t.id !== txn.id && t.source !== txn.source), txn],
|
||||
vouchers: [
|
||||
...working.vouchers.filter((v) => v.id !== existingVoucher?.id),
|
||||
...(voucher ? [voucher] : []),
|
||||
],
|
||||
}
|
||||
|
||||
if (txn.partyId) {
|
||||
for (const inv of reconcileInvoicePaymentStatus(txn.partyId, working)) {
|
||||
upsertInvoice(inv)
|
||||
working = {
|
||||
...working,
|
||||
invoices: working.invoices.map((i) => (i.id === inv.id ? inv : i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
removed: 0,
|
||||
skipped: 0,
|
||||
unmatchedCustomers: 0,
|
||||
unmatchedOwners: 0,
|
||||
excludedOwnerTxns: 0,
|
||||
customerPaymentCount: 0,
|
||||
ownerTxnCount: 0,
|
||||
}
|
||||
},
|
||||
[upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice],
|
||||
)
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [customerPayments, ownerTransactions] = await Promise.all([
|
||||
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
|
||||
(res) => res.payments_and_refunds ?? [],
|
||||
),
|
||||
apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
|
||||
])
|
||||
|
||||
const { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns } =
|
||||
buildFunZoneTreasurySync(data, customerPayments, ownerTransactions)
|
||||
|
||||
let working = { ...data }
|
||||
for (const stale of toRemove) {
|
||||
const voucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(stale.id))
|
||||
if (voucher) removeVoucher(voucher.id)
|
||||
removeTreasury(stale.id)
|
||||
working = {
|
||||
...working,
|
||||
treasury: working.treasury.filter((t) => t.id !== stale.id),
|
||||
vouchers: working.vouchers.filter((v) => v.id !== voucher?.id),
|
||||
}
|
||||
}
|
||||
|
||||
const batchStats = persistTreasuryBatch(working, toUpsert)
|
||||
|
||||
setStats({
|
||||
...batchStats,
|
||||
removed: toRemove.length,
|
||||
skipped,
|
||||
unmatchedCustomers,
|
||||
unmatchedOwners,
|
||||
excludedOwnerTxns,
|
||||
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
|
||||
ownerTxnCount: ownerTransactions.filter((t) => t.status !== 'failed' && t.type === 'withdraw').length,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'خطا در همگامسازی خزانه')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [data, persistTreasuryBatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSynced.current || data.parties.length === 0) return
|
||||
const hasFunZoneParties = data.parties.some((p) => p.externalSource?.startsWith('funzone_'))
|
||||
if (!hasFunZoneParties) return
|
||||
autoSynced.current = true
|
||||
void sync()
|
||||
}, [data.parties, sync])
|
||||
|
||||
return { sync, loading, error, stats }
|
||||
}
|
||||
538
src/parties/FunZonePartySection.tsx
Normal file
538
src/parties/FunZonePartySection.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { apiGet, asList } from '../api/client'
|
||||
import { ENDPOINTS } from '../api/config'
|
||||
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
|
||||
import { useVoucherSync } from '../accounting/voucherSync'
|
||||
import { useStore } from '../store/AppStore'
|
||||
import type { Party } from '../types'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
StatCard,
|
||||
type Column,
|
||||
} from '../components/ui'
|
||||
import { formatDate, formatDateTime, formatMoney, toFa } from '../utils/format'
|
||||
import {
|
||||
type FunZonePartyConfig,
|
||||
getPartyFullName,
|
||||
useFunZoneParties,
|
||||
usePartyWithdrawals,
|
||||
} from './useFunZoneParties'
|
||||
import { partyAccountBalance, salesDocuments } from '../pages/sales/salesUtils'
|
||||
|
||||
const txnTypeLabels: Record<string, string> = {
|
||||
deposit: 'واریز',
|
||||
withdraw: 'برداشت',
|
||||
booking_payment: 'پرداخت رزرو',
|
||||
refund: 'بازپرداخت',
|
||||
payment: 'پرداخت',
|
||||
cancellation: 'لغو/بازپرداخت',
|
||||
}
|
||||
|
||||
const PARTY_ACTIONS_WIDTH = '16rem'
|
||||
|
||||
export function FunZonePartySection({ config }: { config: FunZonePartyConfig }) {
|
||||
const { data, upsertPartyAsync } = useStore()
|
||||
const { parties, loading, syncing, error, reload, syncToAccounting } = useFunZoneParties(config)
|
||||
const withdrawals = usePartyWithdrawals(config.kind)
|
||||
const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<Party | null>(null)
|
||||
const [editAccounting, setEditAccounting] = useState<Party | null>(null)
|
||||
const [syncBusy, setSyncBusy] = useState(false)
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (!query) return parties
|
||||
return parties.filter((p) => {
|
||||
const name = getPartyFullName(p).toLowerCase()
|
||||
return (
|
||||
name.includes(query) ||
|
||||
(p.username ?? '').toLowerCase().includes(query) ||
|
||||
(p.phone ?? '').includes(query) ||
|
||||
(p.nationalCode ?? '').includes(query)
|
||||
)
|
||||
})
|
||||
}, [parties, search])
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalWallet = parties.reduce((s, p) => s + (p.walletBalance ?? 0), 0)
|
||||
return { count: parties.length, totalWallet }
|
||||
}, [parties])
|
||||
|
||||
const handleBulkSync = async () => {
|
||||
setSyncBusy(true)
|
||||
setSyncMsg(null)
|
||||
try {
|
||||
if (config.kind === 'supplier') {
|
||||
const all = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>)
|
||||
const res = await syncOwnerTransactions(all)
|
||||
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
||||
} else {
|
||||
const all = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
||||
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
|
||||
).then((res) => res.payments_and_refunds ?? [])
|
||||
const res = await syncCustomerActivity(all)
|
||||
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
||||
}
|
||||
} catch (err) {
|
||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
||||
} finally {
|
||||
setSyncBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAccounting = async () => {
|
||||
if (!editAccounting?.id) return
|
||||
await upsertPartyAsync(editAccounting)
|
||||
setEditAccounting(null)
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<PartyDetail
|
||||
party={selected}
|
||||
config={config}
|
||||
withdrawals={withdrawals}
|
||||
onBack={() => setSelected(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: Array<Column<Party>> = [
|
||||
{
|
||||
key: 'name',
|
||||
header: config.kind === 'supplier' ? 'نام تأمینکننده' : 'نام مشتری',
|
||||
truncate: true,
|
||||
className: 'w-[14rem]',
|
||||
headerClassName: 'w-[14rem]',
|
||||
render: (p) => {
|
||||
const name = getPartyFullName(p)
|
||||
return (
|
||||
<span className="block truncate font-medium text-slate-800" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'username',
|
||||
header: 'نام کاربری',
|
||||
truncate: true,
|
||||
className: 'w-[8rem]',
|
||||
headerClassName: 'w-[8rem]',
|
||||
render: (p) => (
|
||||
<span className="block truncate text-slate-600" title={p.username || undefined}>
|
||||
{p.username || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
header: 'موبایل',
|
||||
className: 'w-[9.5rem] overflow-hidden whitespace-nowrap',
|
||||
headerClassName: 'w-[9.5rem]',
|
||||
render: (p) => (
|
||||
<span className="block truncate tabular-nums" dir="ltr" title={p.phone || undefined}>
|
||||
{toFa(p.phone) || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'national',
|
||||
header: 'کد ملی',
|
||||
className: 'w-[9rem] overflow-hidden whitespace-nowrap',
|
||||
headerClassName: 'w-[9rem]',
|
||||
render: (p) => (
|
||||
<span className="block truncate font-mono text-sm text-slate-600">{toFa(p.nationalCode ?? '') || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'economic',
|
||||
header: 'کد اقتصادی',
|
||||
className: 'w-[8rem] overflow-hidden whitespace-nowrap',
|
||||
headerClassName: 'w-[8rem]',
|
||||
render: (p) => (
|
||||
<span className="block truncate font-mono text-sm text-slate-600">{toFa(p.economicCode) || '—'}</span>
|
||||
),
|
||||
},
|
||||
...(config.kind === 'supplier'
|
||||
? [
|
||||
{
|
||||
key: 'venues',
|
||||
header: 'مجموعهها',
|
||||
className: 'w-[6rem] whitespace-nowrap text-center',
|
||||
headerClassName: 'w-[6rem]',
|
||||
align: 'center' as const,
|
||||
render: (p: Party) => toFa(p.venues?.length ?? 0),
|
||||
} as Column<Party>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'iban',
|
||||
header: 'شبا',
|
||||
truncate: true,
|
||||
className: 'w-[12rem]',
|
||||
headerClassName: 'w-[12rem]',
|
||||
render: (p) => {
|
||||
const iban = p.iban?.trim()
|
||||
if (!iban) return <span className="text-slate-400">—</span>
|
||||
return (
|
||||
<span className="block truncate font-mono text-sm text-slate-600" dir="ltr" title={iban}>
|
||||
{iban}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'wallet',
|
||||
header: 'موجودی کیف پول',
|
||||
align: 'end',
|
||||
className: 'w-[10.5rem] overflow-hidden whitespace-nowrap tabular-nums',
|
||||
headerClassName: 'w-[10.5rem]',
|
||||
render: (p) => (
|
||||
<span className="block truncate" title={formatMoney(p.walletBalance ?? 0)}>
|
||||
{formatMoney(p.walletBalance ?? 0)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'balance',
|
||||
header: 'مانده حساب',
|
||||
align: 'end',
|
||||
className: 'w-[10.5rem] overflow-hidden whitespace-nowrap tabular-nums',
|
||||
headerClassName: 'w-[10.5rem]',
|
||||
render: (p) => {
|
||||
const value = p.id ? formatMoney(partyAccountBalance(p.id, data)) : '—'
|
||||
return (
|
||||
<span className="block truncate font-semibold text-slate-700" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
header: 'وضعیت',
|
||||
sticky: { left: PARTY_ACTIONS_WIDTH },
|
||||
className: 'w-[7rem] whitespace-nowrap',
|
||||
headerClassName: 'w-[7rem]',
|
||||
render: (p) => <Badge tone={p.isActive ? 'green' : 'slate'}>{p.isActive ? 'فعال' : 'غیرفعال'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
sticky: { left: '0' },
|
||||
className: 'w-[16rem] whitespace-nowrap',
|
||||
headerClassName: 'w-[16rem]',
|
||||
render: (p) => (
|
||||
<div className="flex justify-end gap-2" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
|
||||
{p.id && (
|
||||
<Button variant="ghost" onClick={() => setEditAccounting({ ...p })}>
|
||||
حسابداری
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => setSelected(p)}>
|
||||
{config.kind === 'supplier' ? 'تراکنشها' : 'پرداختها'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const tableMinWidth = config.kind === 'supplier' ? '1720px' : '1660px'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">{config.title}</h2>
|
||||
<p className="text-sm text-slate-500">{config.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
|
||||
{syncBusy ? 'در حال صدور…' : 'صدور سند همه تراکنشها'}
|
||||
</Button>
|
||||
<Button variant="secondary" icon="↻" disabled={loading || syncing} onClick={() => { void reload(); void syncToAccounting() }}>
|
||||
{syncing ? 'همگامسازی…' : 'بروزرسانی'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncMsg && <p className="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{syncMsg}</p>}
|
||||
{error && <p className="mb-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
|
||||
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard label="تعداد" value={toFa(summary.count)} icon="👤" tone="brand" />
|
||||
<StatCard label="مجموع کیف پول" value={formatMoney(summary.totalWallet)} icon="💼" tone="green" />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="جستجو بر اساس نام، موبایل، کد ملی…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="mb-4 max-w-sm"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری از فانزون…</p>
|
||||
) : (
|
||||
<div className="-mx-2 overflow-hidden rounded-xl border border-slate-200 sm:-mx-3">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={filtered}
|
||||
rowKey={(p) => p.externalId ?? p.id}
|
||||
onRowClick={(p) => setSelected(p)}
|
||||
size="comfortable"
|
||||
minWidth={tableMinWidth}
|
||||
emptyTitle={config.kind === 'supplier' ? 'تأمینکنندهای یافت نشد' : 'مشتریای یافت نشد'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={editAccounting !== null}
|
||||
title="اطلاعات حسابداری"
|
||||
onClose={() => setEditAccounting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setEditAccounting(null)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={() => void handleSaveAccounting()}>ذخیره</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{editAccounting && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="کد اقتصادی">
|
||||
<Input
|
||||
value={editAccounting.economicCode}
|
||||
onChange={(e) => setEditAccounting({ ...editAccounting, economicCode: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="مانده اولیه (تومان)">
|
||||
<Input
|
||||
type="number"
|
||||
value={editAccounting.openingBalance}
|
||||
onChange={(e) =>
|
||||
setEditAccounting({ ...editAccounting, openingBalance: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<p className="sm:col-span-2 text-xs text-slate-500">
|
||||
سایر فیلدها از اپلیکیشن فانزون خوانده میشوند و در اینجا قابل ویرایش نیستند.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PartyDetail({
|
||||
party,
|
||||
config,
|
||||
withdrawals,
|
||||
onBack,
|
||||
}: {
|
||||
party: Party
|
||||
config: FunZonePartyConfig
|
||||
withdrawals: ReturnType<typeof usePartyWithdrawals>
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { data } = useStore()
|
||||
const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync()
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||
const [syncBusy, setSyncBusy] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ownerTxns, setOwnerTxns] = useState<ApiOwnerTransaction[]>([])
|
||||
const [customerActivity, setCustomerActivity] = useState<ApiPaymentOrRefund[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const externalId = party.externalId ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!externalId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const load = async () => {
|
||||
try {
|
||||
if (config.kind === 'supplier') {
|
||||
const rows = await apiGet<unknown>(ENDPOINTS.OWNER_TRANSACTIONS(externalId)).then(
|
||||
asList<ApiOwnerTransaction>,
|
||||
)
|
||||
setOwnerTxns(rows)
|
||||
} else {
|
||||
const res = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
||||
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(externalId),
|
||||
)
|
||||
setCustomerActivity(res.payments_and_refunds ?? [])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'خطا در بارگذاری')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
}, [config.kind, externalId])
|
||||
|
||||
const partyWithdrawals = withdrawals.filter((w) => w.user_id === externalId)
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncBusy(true)
|
||||
try {
|
||||
if (config.kind === 'supplier') {
|
||||
const res = await syncOwnerTransactions(ownerTxns)
|
||||
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
||||
} else {
|
||||
const res = await syncCustomerActivity(customerActivity)
|
||||
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
||||
}
|
||||
} catch (err) {
|
||||
setSyncMsg(err instanceof Error ? err.message : 'خطا')
|
||||
} finally {
|
||||
setSyncBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceCount =
|
||||
party.id ? salesDocuments(data.invoices).filter((i) => i.partyId === party.id).length : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" icon="→" onClick={onBack}>
|
||||
بازگشت
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">{getPartyFullName(party)}</h1>
|
||||
<p className="text-sm text-slate-500">{toFa(party.phone)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" icon="📑" disabled={syncBusy || loading} onClick={() => void handleSync()}>
|
||||
{syncBusy ? 'در حال صدور…' : 'صدور سند'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{syncMsg && <Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard label="موجودی کیف پول" value={formatMoney(party.walletBalance ?? 0)} icon="💼" tone="brand" />
|
||||
<StatCard
|
||||
label="مانده حساب"
|
||||
value={party.id ? formatMoney(partyAccountBalance(party.id, data)) : '—'}
|
||||
icon="📊"
|
||||
tone="green"
|
||||
/>
|
||||
<StatCard label="فاکتورها" value={toFa(invoiceCount)} icon="🧾" tone="brand" />
|
||||
<StatCard label="برداشتها" value={toFa(partyWithdrawals.length)} icon="🏧" tone="amber" />
|
||||
</div>
|
||||
|
||||
<Card className="p-5">
|
||||
<h3 className="mb-4 font-bold text-slate-800">مشخصات {config.kind === 'supplier' ? 'تأمینکننده' : 'مشتری'}</h3>
|
||||
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<InfoItem label="نام کاربری" value={party.username} />
|
||||
<InfoItem label="ایمیل" value={party.email} />
|
||||
<InfoItem label="کد ملی" value={party.nationalCode ? toFa(party.nationalCode) : undefined} />
|
||||
<InfoItem label="کد اقتصادی" value={party.economicCode ? toFa(party.economicCode) : undefined} />
|
||||
<InfoItem label="شبا" value={party.iban} />
|
||||
<InfoItem label="آدرس" value={party.address} />
|
||||
<InfoItem label="تاریخ تولد" value={party.birthday ? formatDate(party.birthday) : undefined} />
|
||||
{config.kind === 'supplier' && (
|
||||
<InfoItem label="مجموعهها" value={party.venues?.join('، ') || '—'} />
|
||||
)}
|
||||
<InfoItem label="وضعیت" value={party.isActive ? 'فعال' : 'غیرفعال'} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="mb-3 text-lg font-bold text-slate-800">
|
||||
{config.kind === 'supplier' ? 'گردش حساب کیف پول' : 'پرداختها و بازپرداختها'}
|
||||
</h2>
|
||||
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
||||
) : config.kind === 'supplier' ? (
|
||||
<DataTable
|
||||
columns={ownerTxnColumns}
|
||||
rows={ownerTxns}
|
||||
rowKey={(t) => t.id}
|
||||
emptyTitle="تراکنشی ثبت نشده است"
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={customerActivityColumns}
|
||||
rows={customerActivity}
|
||||
rowKey={(r) => r.id}
|
||||
emptyTitle="پرداختی ثبت نشده است"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-slate-400">{label}</dt>
|
||||
<dd className="font-medium text-slate-700">{value || '—'}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ownerTxnColumns: Array<Column<ApiOwnerTransaction>> = [
|
||||
{ key: 'date', header: 'تاریخ', render: (t) => formatDateTime(t.created_at) },
|
||||
{
|
||||
key: 'type',
|
||||
header: 'نوع',
|
||||
render: (t) => <Badge tone="blue">{txnTypeLabels[t.type] ?? t.type}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (t) => <span className="tabular-nums font-semibold">{formatMoney(t.amount)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (t) => (
|
||||
<Badge tone={t.status === 'completed' ? 'green' : t.status === 'pending' ? 'amber' : 'rose'}>
|
||||
{t.status === 'completed' ? 'انجامشده' : t.status === 'pending' ? 'در انتظار' : 'ناموفق'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'desc', header: 'توضیحات', render: (t) => t.description || '—' },
|
||||
]
|
||||
|
||||
const customerActivityColumns: Array<Column<ApiPaymentOrRefund>> = [
|
||||
{ key: 'date', header: 'تاریخ', render: (r) => formatDateTime(r.created_at) },
|
||||
{
|
||||
key: 'type',
|
||||
header: 'نوع',
|
||||
render: (r) => (
|
||||
<Badge tone={r.type === 'payment' ? 'blue' : 'amber'}>{txnTypeLabels[r.type] ?? r.type}</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'event', header: 'رویداد', render: (r) => (r.event_name === 'wallet' ? 'کیف پول' : r.event_name) },
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (r) => <span className="tabular-nums font-semibold">{formatMoney(r.amount)}</span>,
|
||||
},
|
||||
]
|
||||
51
src/parties/GlobalPartySync.tsx
Normal file
51
src/parties/GlobalPartySync.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { acct, asList, apiGet } from '../api/client'
|
||||
import { ENDPOINTS, ACCOUNTING_ENDPOINTS } from '../api/config'
|
||||
import type { ApiCustomer, ApiOwner } from '../api/types'
|
||||
import type { Party } from '../types'
|
||||
import { useStore } from '../store/AppStore'
|
||||
import { createId } from '../utils/id'
|
||||
import { findExistingParty, mergeFunZoneParties, partyNeedsSync } from './funzonePartySync'
|
||||
|
||||
/** Syncs FunZone owners/customers into accounting parties on app load. */
|
||||
export function GlobalPartySync() {
|
||||
const { ready, upsertPartyAsync, reload } = useStore()
|
||||
const synced = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || synced.current) return
|
||||
synced.current = true
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const [owners, customersRaw, currentParties] = await Promise.all([
|
||||
apiGet<unknown>(ENDPOINTS.OWNERS).then(asList<ApiOwner>),
|
||||
apiGet<unknown>(ENDPOINTS.CUSTOMERS),
|
||||
acct.get(ACCOUNTING_ENDPOINTS.PARTIES).then(asList<Party>),
|
||||
])
|
||||
|
||||
const customers: ApiCustomer[] = Array.isArray(customersRaw)
|
||||
? (customersRaw as ApiCustomer[])
|
||||
: ((customersRaw as { results?: ApiCustomer[] }).results ?? [])
|
||||
|
||||
const batches = [
|
||||
...mergeFunZoneParties(owners, currentParties, 'supplier'),
|
||||
...mergeFunZoneParties(customers, currentParties, 'customer'),
|
||||
]
|
||||
|
||||
for (const incoming of batches) {
|
||||
const existing = findExistingParty(currentParties, incoming.externalId!, incoming.externalSource)
|
||||
if (existing && !partyNeedsSync(existing, incoming)) continue
|
||||
await upsertPartyAsync({ ...incoming, id: existing?.id ?? createId('party-') })
|
||||
}
|
||||
reload()
|
||||
} catch {
|
||||
synced.current = false
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
}, [ready, upsertPartyAsync, reload])
|
||||
|
||||
return null
|
||||
}
|
||||
106
src/parties/funzonePartySync.ts
Normal file
106
src/parties/funzonePartySync.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { ApiCustomer, ApiOwner } from '../api/types'
|
||||
import { customerFullName, ownerFullName } from '../api/types'
|
||||
import type { Party, PartyKind } from '../types'
|
||||
|
||||
export function partyDisplayName(party: Party): string {
|
||||
if (party.firstName || party.lastName) {
|
||||
return `${party.firstName ?? ''} ${party.lastName ?? ''}`.trim()
|
||||
}
|
||||
return party.name
|
||||
}
|
||||
|
||||
export function ownerToParty(owner: ApiOwner, existing?: Party): Party {
|
||||
return {
|
||||
id: existing?.id ?? '',
|
||||
kind: 'supplier',
|
||||
name: ownerFullName(owner),
|
||||
phone: owner.mobile_number ?? '',
|
||||
economicCode: existing?.economicCode ?? '',
|
||||
address: owner.address ?? '',
|
||||
openingBalance: existing?.openingBalance ?? 0,
|
||||
externalId: owner.id,
|
||||
externalSource: 'funzone_owner',
|
||||
firstName: owner.f_name ?? '',
|
||||
lastName: owner.l_name ?? '',
|
||||
username: owner.username ?? '',
|
||||
email: owner.email ?? '',
|
||||
nationalCode: owner.national_code ?? '',
|
||||
iban: owner.credit_number ?? '',
|
||||
walletBalance: owner.balance ?? 0,
|
||||
isActive: owner.is_active,
|
||||
isVerified: owner.is_verified ?? false,
|
||||
birthday: owner.birthday ?? null,
|
||||
venues: owner.social_hubs ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function customerToParty(customer: ApiCustomer, existing?: Party): Party {
|
||||
const nationalCode =
|
||||
customer.national_code != null ? String(customer.national_code) : ''
|
||||
|
||||
return {
|
||||
id: existing?.id ?? '',
|
||||
kind: 'customer',
|
||||
name: customerFullName(customer),
|
||||
phone: customer.mobile_number ?? '',
|
||||
economicCode: existing?.economicCode ?? '',
|
||||
address: customer.address ?? '',
|
||||
openingBalance: existing?.openingBalance ?? 0,
|
||||
externalId: customer.id,
|
||||
externalSource: 'funzone_customer',
|
||||
firstName: customer.f_name ?? '',
|
||||
lastName: customer.l_name ?? '',
|
||||
username: customer.username ?? '',
|
||||
email: customer.email ?? '',
|
||||
nationalCode,
|
||||
iban: customer.iban ?? '',
|
||||
walletBalance: customer.balance ?? 0,
|
||||
isActive: customer.is_active,
|
||||
isVerified: customer.is_verified ?? false,
|
||||
birthday: customer.birthday ?? null,
|
||||
venues: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function findExistingParty(
|
||||
parties: Party[],
|
||||
externalId: string,
|
||||
externalSource: Party['externalSource'],
|
||||
): Party | undefined {
|
||||
return parties.find((p) => p.externalId === externalId && p.externalSource === externalSource)
|
||||
}
|
||||
|
||||
export function mergeFunZoneParties(
|
||||
apiRecords: Array<ApiOwner | ApiCustomer>,
|
||||
existingParties: Party[],
|
||||
kind: PartyKind,
|
||||
): Party[] {
|
||||
return apiRecords.map((record) => {
|
||||
const externalSource = kind === 'supplier' ? 'funzone_owner' : 'funzone_customer'
|
||||
const existing = findExistingParty(existingParties, record.id, externalSource)
|
||||
return kind === 'supplier'
|
||||
? ownerToParty(record as ApiOwner, existing)
|
||||
: customerToParty(record as ApiCustomer, existing)
|
||||
})
|
||||
}
|
||||
|
||||
/** Returns true when party data from FunZone differs from the stored accounting record. */
|
||||
export function partyNeedsSync(stored: Party, incoming: Party): boolean {
|
||||
const keys: Array<keyof Party> = [
|
||||
'name',
|
||||
'phone',
|
||||
'address',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'username',
|
||||
'email',
|
||||
'nationalCode',
|
||||
'iban',
|
||||
'walletBalance',
|
||||
'isActive',
|
||||
'isVerified',
|
||||
'birthday',
|
||||
]
|
||||
if (JSON.stringify(stored.venues ?? []) !== JSON.stringify(incoming.venues ?? [])) return true
|
||||
return keys.some((key) => stored[key] !== incoming[key])
|
||||
}
|
||||
107
src/parties/useFunZoneParties.ts
Normal file
107
src/parties/useFunZoneParties.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { apiGet, asList } from '../api/client'
|
||||
import { ENDPOINTS } from '../api/config'
|
||||
import type { ApiCustomer, ApiOwner } from '../api/types'
|
||||
import { useStore } from '../store/AppStore'
|
||||
import type { PartyKind } from '../types'
|
||||
import { createId } from '../utils/id'
|
||||
import {
|
||||
findExistingParty,
|
||||
mergeFunZoneParties,
|
||||
partyDisplayName,
|
||||
partyNeedsSync,
|
||||
} from './funzonePartySync'
|
||||
import { partyAccountBalance, partyReceivable, salesDocuments } from '../pages/sales/salesUtils'
|
||||
|
||||
export interface FunZonePartyConfig {
|
||||
kind: PartyKind
|
||||
title: string
|
||||
subtitle: string
|
||||
externalSource: 'funzone_customer' | 'funzone_owner'
|
||||
}
|
||||
|
||||
async function fetchApiRecords(kind: PartyKind): Promise<Array<ApiOwner | ApiCustomer>> {
|
||||
if (kind === 'supplier') {
|
||||
return apiGet<unknown>(ENDPOINTS.OWNERS).then(asList<ApiOwner>)
|
||||
}
|
||||
const data = await apiGet<unknown>(ENDPOINTS.CUSTOMERS)
|
||||
if (Array.isArray(data)) return data as ApiCustomer[]
|
||||
const record = data as { results?: ApiCustomer[] }
|
||||
return record.results ?? []
|
||||
}
|
||||
|
||||
export function useFunZoneParties(config: FunZonePartyConfig) {
|
||||
const { data, upsertPartyAsync, ready } = useStore()
|
||||
const [apiRecords, setApiRecords] = useState<Array<ApiOwner | ApiCustomer>>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
setApiRecords(await fetchApiRecords(config.kind))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'خطا در بارگذاری از فانزون')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [config.kind])
|
||||
|
||||
const syncToAccounting = useCallback(async () => {
|
||||
if (apiRecords.length === 0) return
|
||||
setSyncing(true)
|
||||
try {
|
||||
const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)
|
||||
for (const incoming of merged) {
|
||||
const existing = findExistingParty(data.parties, incoming.externalId!, incoming.externalSource)
|
||||
if (existing && !partyNeedsSync(existing, incoming)) continue
|
||||
await upsertPartyAsync({ ...incoming, id: existing?.id ?? createId('party-') })
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}, [apiRecords, config.kind, data.parties, upsertPartyAsync])
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) void load()
|
||||
}, [ready, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && apiRecords.length > 0) void syncToAccounting()
|
||||
}, [ready, apiRecords, syncToAccounting])
|
||||
|
||||
const parties = useMemo(() => {
|
||||
const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)
|
||||
return merged.map((incoming) => {
|
||||
const stored = findExistingParty(data.parties, incoming.externalId!, incoming.externalSource)
|
||||
return stored
|
||||
? { ...incoming, id: stored.id, openingBalance: stored.openingBalance, economicCode: stored.economicCode }
|
||||
: incoming
|
||||
})
|
||||
}, [apiRecords, data.parties, config.kind])
|
||||
|
||||
return { parties, loading, syncing, error, reload: load, syncToAccounting }
|
||||
}
|
||||
|
||||
export function usePartyWithdrawals(kind: PartyKind) {
|
||||
const [withdrawals, setWithdrawals] = useState<
|
||||
import('../api/types').ApiWithdrawal[]
|
||||
>([])
|
||||
useEffect(() => {
|
||||
apiGet<{ withdrawals: import('../api/types').ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
|
||||
.then((res) => {
|
||||
const userType = kind === 'supplier' ? 'owner' : 'customer'
|
||||
setWithdrawals((res.withdrawals ?? []).filter((w) => w.user_type === userType))
|
||||
})
|
||||
.catch(() => setWithdrawals([]))
|
||||
}, [kind])
|
||||
return withdrawals
|
||||
}
|
||||
|
||||
export function getPartyFullName(party: import('../types').Party): string {
|
||||
return partyDisplayName(party) || party.name
|
||||
}
|
||||
|
||||
export { partyAccountBalance, partyReceivable, salesDocuments }
|
||||
@@ -15,8 +15,10 @@ import type {
|
||||
Employee,
|
||||
Invoice,
|
||||
Party,
|
||||
PartyAdjustment,
|
||||
Payslip,
|
||||
Product,
|
||||
SalesType,
|
||||
TreasuryTxn,
|
||||
Voucher,
|
||||
} from '../types'
|
||||
@@ -33,6 +35,8 @@ type CollectionKey =
|
||||
| 'parties'
|
||||
| 'products'
|
||||
| 'invoices'
|
||||
| 'salesTypes'
|
||||
| 'partyAdjustments'
|
||||
| 'treasury'
|
||||
| 'employees'
|
||||
| 'payslips'
|
||||
@@ -43,6 +47,8 @@ const ENDPOINT: Record<CollectionKey, string> = {
|
||||
parties: ACCOUNTING_ENDPOINTS.PARTIES,
|
||||
products: ACCOUNTING_ENDPOINTS.PRODUCTS,
|
||||
invoices: ACCOUNTING_ENDPOINTS.INVOICES,
|
||||
salesTypes: ACCOUNTING_ENDPOINTS.SALES_TYPES,
|
||||
partyAdjustments: ACCOUNTING_ENDPOINTS.PARTY_ADJUSTMENTS,
|
||||
treasury: ACCOUNTING_ENDPOINTS.TREASURY,
|
||||
employees: ACCOUNTING_ENDPOINTS.EMPLOYEES,
|
||||
payslips: ACCOUNTING_ENDPOINTS.PAYSLIPS,
|
||||
@@ -71,6 +77,8 @@ const EMPTY_DATA: AppData = {
|
||||
parties: [],
|
||||
products: [],
|
||||
invoices: [],
|
||||
salesTypes: [],
|
||||
partyAdjustments: [],
|
||||
treasury: [],
|
||||
employees: [],
|
||||
payslips: [],
|
||||
@@ -90,11 +98,16 @@ interface AppStoreValue {
|
||||
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
|
||||
createVouchers: (items: Voucher[]) => Promise<number>
|
||||
upsertParty: (item: Party) => void
|
||||
upsertPartyAsync: (item: Party) => Promise<void>
|
||||
removeParty: (id: string) => void
|
||||
upsertProduct: (item: Product) => void
|
||||
removeProduct: (id: string) => void
|
||||
upsertInvoice: (item: Invoice) => void
|
||||
removeInvoice: (id: string) => void
|
||||
upsertSalesType: (item: SalesType) => void
|
||||
removeSalesType: (id: string) => void
|
||||
upsertPartyAdjustment: (item: PartyAdjustment) => void
|
||||
removePartyAdjustment: (id: string) => void
|
||||
upsertTreasury: (item: TreasuryTxn) => void
|
||||
removeTreasury: (id: string) => void
|
||||
upsertEmployee: (item: Employee) => void
|
||||
@@ -154,19 +167,21 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [accounts, vouchers, parties, products, invoices, treasury, employees, payslips, settings] =
|
||||
const [accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings] =
|
||||
await Promise.all([
|
||||
acct.get(ENDPOINT.accounts).then(asList<Account>),
|
||||
acct.get(ENDPOINT.vouchers).then(asList<Voucher>),
|
||||
acct.get(ENDPOINT.parties).then(asList<Party>),
|
||||
acct.get(ENDPOINT.products).then(asList<Product>),
|
||||
acct.get(ENDPOINT.invoices).then(asList<Invoice>),
|
||||
acct.get(ENDPOINT.salesTypes).then(asList<SalesType>),
|
||||
acct.get(ENDPOINT.partyAdjustments).then(asList<PartyAdjustment>),
|
||||
acct.get(ENDPOINT.treasury).then(asList<TreasuryTxn>),
|
||||
acct.get(ENDPOINT.employees).then(asList<Employee>),
|
||||
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
|
||||
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
|
||||
])
|
||||
setData({ accounts, vouchers, parties, products, invoices, treasury, employees, payslips, settings })
|
||||
setData({ accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings })
|
||||
setReady(true)
|
||||
} catch (err) {
|
||||
setReady(false)
|
||||
@@ -273,11 +288,16 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
removeVoucher: (id) => void remove('vouchers', id),
|
||||
createVouchers,
|
||||
upsertParty: (item) => void upsert('parties', item),
|
||||
upsertPartyAsync: (item) => upsert('parties', item),
|
||||
removeParty: (id) => void remove('parties', id),
|
||||
upsertProduct: (item) => void upsert('products', item),
|
||||
removeProduct: (id) => void remove('products', id),
|
||||
upsertInvoice: (item) => void upsert('invoices', item),
|
||||
removeInvoice: (id) => void remove('invoices', id),
|
||||
upsertSalesType: (item) => void upsert('salesTypes', item),
|
||||
removeSalesType: (id) => void remove('salesTypes', id),
|
||||
upsertPartyAdjustment: (item) => void upsert('partyAdjustments', item),
|
||||
removePartyAdjustment: (id) => void remove('partyAdjustments', id),
|
||||
upsertTreasury: (item) => void upsert('treasury', item),
|
||||
removeTreasury: (id) => void remove('treasury', id),
|
||||
upsertEmployee: (item) => void upsert('employees', item),
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface Voucher {
|
||||
|
||||
export type PartyKind = 'customer' | 'supplier'
|
||||
|
||||
export type PartyExternalSource = '' | 'funzone_customer' | 'funzone_owner'
|
||||
|
||||
export interface Party {
|
||||
id: ID
|
||||
kind: PartyKind
|
||||
@@ -43,6 +45,21 @@ export interface Party {
|
||||
economicCode: string
|
||||
address: string
|
||||
openingBalance: number
|
||||
/** FunZone user id when synced from the platform. */
|
||||
externalId?: ID
|
||||
externalSource?: PartyExternalSource
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
username?: string
|
||||
email?: string
|
||||
nationalCode?: string
|
||||
iban?: string
|
||||
walletBalance?: number
|
||||
isActive?: boolean
|
||||
isVerified?: boolean
|
||||
birthday?: string | null
|
||||
/** Venue names for owner/supplier records. */
|
||||
venues?: string[]
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
@@ -56,6 +73,29 @@ export interface Product {
|
||||
reorderLevel: number
|
||||
}
|
||||
|
||||
export type SalesDocumentType = 'proforma' | 'invoice' | 'return'
|
||||
|
||||
export interface SalesType {
|
||||
id: ID
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type AdjustmentKind = 'debit' | 'credit'
|
||||
|
||||
/** Debit/credit notice adjusting customer receivable (اعلامیه بدهکار/بستانکار). */
|
||||
export interface PartyAdjustment {
|
||||
id: ID
|
||||
number: number
|
||||
partyId: ID
|
||||
kind: AdjustmentKind
|
||||
date: string
|
||||
amount: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface InvoiceLine {
|
||||
id: ID
|
||||
productId: ID
|
||||
@@ -71,8 +111,11 @@ export type InvoiceStatus = 'draft' | 'confirmed' | 'paid'
|
||||
export interface Invoice {
|
||||
id: ID
|
||||
kind: InvoiceKind
|
||||
documentType: SalesDocumentType
|
||||
number: number
|
||||
partyId: ID
|
||||
salesTypeId?: ID
|
||||
relatedInvoiceId?: ID
|
||||
date: string
|
||||
status: InvoiceStatus
|
||||
note: string
|
||||
@@ -90,8 +133,14 @@ export interface TreasuryTxn {
|
||||
date: string
|
||||
partyId: ID | null
|
||||
amount: number
|
||||
/** Event ticket fee split (FunZone): tax 10%, platform profit 14%, owner net 76%. */
|
||||
taxAmount?: number
|
||||
platformProfitAmount?: number
|
||||
ownerNetAmount?: number
|
||||
reference: string
|
||||
description: string
|
||||
/** Set when imported from FunZone platform (dedup key). */
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
@@ -148,6 +197,8 @@ export interface AppData {
|
||||
parties: Party[]
|
||||
products: Product[]
|
||||
invoices: Invoice[]
|
||||
salesTypes: SalesType[]
|
||||
partyAdjustments: PartyAdjustment[]
|
||||
treasury: TreasuryTxn[]
|
||||
employees: Employee[]
|
||||
payslips: Payslip[]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseISODateLocal, todayISO } from './solarHijriCalendar'
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat('fa-IR', {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
@@ -33,9 +35,9 @@ export function formatNumber(value: number): string {
|
||||
return numberFormatter.format(value)
|
||||
}
|
||||
|
||||
/** Formats an ISO date string to a Jalali (Persian) date. */
|
||||
/** Formats an ISO date string (yyyy-mm-dd) to Jalali (Persian) display. */
|
||||
export function formatDate(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const date = parseISODateLocal(iso)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return jalaliDateFormatter.format(date)
|
||||
}
|
||||
@@ -46,10 +48,7 @@ export function formatDateTime(iso: string): string {
|
||||
return jalaliDateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
/** Returns today's date as an ISO yyyy-mm-dd string for date inputs. */
|
||||
export function todayISO(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
export { todayISO }
|
||||
|
||||
const toPersianDigits = (input: string): string =>
|
||||
input.replace(/[0-9]/g, (digit) => '۰۱۲۳۴۵۶۷۸۹'[Number(digit)])
|
||||
@@ -57,3 +56,14 @@ const toPersianDigits = (input: string): string =>
|
||||
export function toFa(value: string | number): string {
|
||||
return toPersianDigits(String(value))
|
||||
}
|
||||
|
||||
/** Coerces a numeric field to a finite number (defaults to 0). */
|
||||
export function toAmount(value: number | string | null | undefined): number {
|
||||
const n = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
/** Sums an array of amounts safely. */
|
||||
export function sumAmounts(values: number[]): number {
|
||||
return values.reduce((sum, v) => sum + toAmount(v), 0)
|
||||
}
|
||||
|
||||
36
src/utils/funzoneFees.ts
Normal file
36
src/utils/funzoneFees.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** FunZone event payment fee rates (aligned with admin panel & owner export). */
|
||||
export const FUNZONE_EVENT_TAX_RATE = 0.1
|
||||
export const FUNZONE_PLATFORM_PROFIT_RATE = 0.14
|
||||
|
||||
export interface EventPaymentSplit {
|
||||
gross: number
|
||||
tax: number
|
||||
platformProfit: number
|
||||
ownerNet: number
|
||||
}
|
||||
|
||||
function roundMoney(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
/** Splits gross event ticket payment: tax 10% + platform profit 14% + owner net 76%. */
|
||||
export function splitEventPayment(gross: number): EventPaymentSplit {
|
||||
const tax = roundMoney(gross * FUNZONE_EVENT_TAX_RATE)
|
||||
const platformProfit = roundMoney(gross * FUNZONE_PLATFORM_PROFIT_RATE)
|
||||
const ownerNet = roundMoney(gross - tax - platformProfit)
|
||||
return { gross, tax, platformProfit, ownerNet }
|
||||
}
|
||||
|
||||
export function isEventCustomerPayment(eventName: string | null | undefined): boolean {
|
||||
return Boolean(eventName && eventName.toLowerCase() !== 'wallet')
|
||||
}
|
||||
|
||||
/** Owner wallet credits already reflected in customer receipts — exclude from treasury. */
|
||||
export function ownerTxnExcludedFromTreasury(type: string): boolean {
|
||||
return type === 'deposit' || type === 'booking_payment'
|
||||
}
|
||||
|
||||
/** Actual payouts / chargebacks shown in پرداخت. */
|
||||
export function ownerTxnIncludedInTreasury(type: string): boolean {
|
||||
return type === 'withdraw' || type === 'refund'
|
||||
}
|
||||
44
src/utils/solarHijriCalendar.ts
Normal file
44
src/utils/solarHijriCalendar.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Solar Hijri (Persian) calendar helpers — aligned with FunZone frontends.
|
||||
* Stores dates as Gregorian ISO (yyyy-mm-dd); displays and picks in Jalali.
|
||||
*/
|
||||
|
||||
import { toGregorian, toJalaali } from 'jalaali-js'
|
||||
|
||||
/** Parses yyyy-mm-dd (or ISO datetime) as local calendar date (no UTC shift). */
|
||||
export function parseISODateLocal(iso: string): Date {
|
||||
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
|
||||
if (!y || !m || !d) return new Date(NaN)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
/** Formats a Date as local Gregorian yyyy-mm-dd for storage/API. */
|
||||
export function dateToISO(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/** Today's date as yyyy-mm-dd in local timezone. */
|
||||
export function todayISO(): string {
|
||||
return dateToISO(new Date())
|
||||
}
|
||||
|
||||
/** Gregorian ISO → Jalali { year, month, day }. */
|
||||
export function gregorianISOToJalali(iso: string): { year: number; month: number; day: number } {
|
||||
const date = parseISODateLocal(iso)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
const now = new Date()
|
||||
const j = toJalaali(now.getFullYear(), now.getMonth() + 1, now.getDate())
|
||||
return { year: j.jy, month: j.jm, day: j.jd }
|
||||
}
|
||||
const j = toJalaali(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
||||
return { year: j.jy, month: j.jm, day: j.jd }
|
||||
}
|
||||
|
||||
/** Jalali y/m/d → Gregorian ISO yyyy-mm-dd. */
|
||||
export function jalaliToGregorianISO(year: number, month: number, day: number): string {
|
||||
const g = toGregorian(year, month, day)
|
||||
return dateToISO(new Date(g.gy, g.gm - 1, g.gd))
|
||||
}
|
||||
@@ -18,5 +18,6 @@
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/accounting/vouchersync.ts","./src/api/client.ts","./src/api/config.ts","./src/api/types.ts","./src/components/business/connectionrequired.tsx","./src/components/business/trademodule.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/navigation.ts","./src/components/ui/confirmdialog.tsx","./src/components/ui/datatable.tsx","./src/components/ui/modal.tsx","./src/components/ui/index.ts","./src/components/ui/primitives.tsx","./src/connection/connectioncontext.tsx","./src/hooks/useapiresource.ts","./src/pages/accounts.tsx","./src/pages/customers.tsx","./src/pages/dashboard.tsx","./src/pages/inventory.tsx","./src/pages/login.tsx","./src/pages/owners.tsx","./src/pages/payroll.tsx","./src/pages/purchases.tsx","./src/pages/reports.tsx","./src/pages/sales.tsx","./src/pages/settings.tsx","./src/pages/treasury.tsx","./src/pages/vouchers.tsx","./src/store/appstore.tsx","./src/types/index.ts","./src/utils/accounting.ts","./src/utils/format.ts","./src/utils/id.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/accounting/vouchersync.ts","./src/api/client.ts","./src/api/config.ts","./src/api/types.ts","./src/components/business/connectionrequired.tsx","./src/components/business/trademodule.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/navigation.ts","./src/components/ui/confirmdialog.tsx","./src/components/ui/datatable.tsx","./src/components/ui/jalalidateinput.tsx","./src/components/ui/modal.tsx","./src/components/ui/index.ts","./src/components/ui/primitives.tsx","./src/connection/connectioncontext.tsx","./src/hooks/useapiresource.ts","./src/pages/accounts.tsx","./src/pages/dashboard.tsx","./src/pages/inventory.tsx","./src/pages/login.tsx","./src/pages/payroll.tsx","./src/pages/purchases.tsx","./src/pages/reports.tsx","./src/pages/sales.tsx","./src/pages/settings.tsx","./src/pages/treasury.tsx","./src/pages/vouchers.tsx","./src/pages/purchases/purchasesuppliers.tsx","./src/pages/purchases/purchaseslayout.tsx","./src/pages/sales/partyadjustmentspage.tsx","./src/pages/sales/pendingwithdrawalspanel.tsx","./src/pages/sales/salescustomers.tsx","./src/pages/sales/salesdocumentsection.tsx","./src/pages/sales/salesdocuments.tsx","./src/pages/sales/saleslayout.tsx","./src/pages/sales/salesprocess.tsx","./src/pages/sales/salesreview.tsx","./src/pages/sales/salestypespage.tsx","./src/pages/sales/invoiceeffects.ts","./src/pages/sales/salesnavigation.ts","./src/pages/sales/salesutils.ts","./src/pages/sales/usependingcustomerwithdrawals.ts","./src/pages/treasury/funzonetreasurysync.ts","./src/pages/treasury/usefunzonetreasurysync.ts","./src/parties/funzonepartysection.tsx","./src/parties/globalpartysync.tsx","./src/parties/funzonepartysync.ts","./src/parties/usefunzoneparties.ts","./src/store/appstore.tsx","./src/types/index.ts","./src/utils/accounting.ts","./src/utils/format.ts","./src/utils/funzonefees.ts","./src/utils/id.ts","./src/utils/solarhijricalendar.ts"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user