Expand sales, treasury, and parties modules with Jalali calendar and FunZone integrations

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shayan Azadi
2026-07-03 03:15:52 +03:30
parent d7252f1d21
commit 4508be582a
55 changed files with 5083 additions and 799 deletions

49
package-lock.json generated
View File

@@ -9,11 +9,14 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001799", "caniuse-lite": "^1.0.30001799",
"jalaali-js": "^2.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jalaali-js": "^1.2.0",
"@types/node": "^22.7.4", "@types/node": "^22.7.4",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@@ -1217,6 +1220,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.20.0", "version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
@@ -1807,6 +1817,15 @@
"node": ">=0.12.0" "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": { "node_modules/jiti": {
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -2236,6 +2255,12 @@
"node": ">=0.10.0" "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": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -2249,6 +2274,30 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@@ -8,15 +8,19 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test:integration": "npx tsx src/pages/sales/salesUtils.test.ts"
}, },
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001799", "caniuse-lite": "^1.0.30001799",
"jalaali-js": "^2.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jalaali-js": "^1.2.0",
"@types/node": "^22.7.4", "@types/node": "^22.7.4",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",

View File

@@ -11,8 +11,6 @@ import { Purchases } from './pages/Purchases'
import { Inventory } from './pages/Inventory' import { Inventory } from './pages/Inventory'
import { Treasury } from './pages/Treasury' import { Treasury } from './pages/Treasury'
import { Payroll } from './pages/Payroll' import { Payroll } from './pages/Payroll'
import { Owners } from './pages/Owners'
import { Customers } from './pages/Customers'
import { Reports } from './pages/Reports' import { Reports } from './pages/Reports'
import { Settings } from './pages/Settings' import { Settings } from './pages/Settings'
@@ -30,13 +28,11 @@ function AppRoutes() {
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/accounts" element={<Accounts />} /> <Route path="/accounts" element={<Accounts />} />
<Route path="/vouchers" element={<Vouchers />} /> <Route path="/vouchers" element={<Vouchers />} />
<Route path="/sales" element={<Sales />} /> <Route path="/sales/*" element={<Sales />} />
<Route path="/purchases" element={<Purchases />} /> <Route path="/purchases/*" element={<Purchases />} />
<Route path="/inventory" element={<Inventory />} /> <Route path="/inventory" element={<Inventory />} />
<Route path="/treasury" element={<Treasury />} /> <Route path="/treasury" element={<Treasury />} />
<Route path="/payroll" element={<Payroll />} /> <Route path="/payroll" element={<Payroll />} />
<Route path="/owners" element={<Owners />} />
<Route path="/customers" element={<Customers />} />
<Route path="/reports" element={<Reports />} /> <Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -1,8 +1,10 @@
import { useCallback } from 'react' 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 type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
import { createId, nextNumber } from '../utils/id' import { createId, nextNumber } from '../utils/id'
import { invoiceTotals } from '../utils/accounting'
import { isWithdrawalReturn } from '../pages/sales/salesUtils'
export interface SyncResult { export interface SyncResult {
created: number created: number
@@ -61,6 +63,7 @@ function buildOwnerVouchers(
for (const txn of records) { for (const txn of records) {
if (txn.status !== 'completed') continue if (txn.status !== 'completed') continue
if (txn.type === 'deposit' || txn.type === 'booking_payment') continue
const amount = Math.abs(txn.amount) const amount = Math.abs(txn.amount)
if (amount <= 0) continue if (amount <= 0) continue
@@ -73,13 +76,6 @@ function buildOwnerVouchers(
let lines: VoucherLine[] let lines: VoucherLine[]
let description: string let description: string
switch (txn.type) { switch (txn.type) {
case 'deposit':
lines = [
makeLine(map.bankAccountId, amount, 0, 'واریز کیف پول مالک'),
makeLine(map.ownerPayableAccountId, 0, amount, 'بدهی به مالک'),
]
description = 'واریز به کیف پول مالک'
break
case 'withdraw': case 'withdraw':
lines = [ lines = [
makeLine(map.ownerPayableAccountId, amount, 0, 'تسویه با مالک'), makeLine(map.ownerPayableAccountId, amount, 0, 'تسویه با مالک'),
@@ -87,13 +83,6 @@ function buildOwnerVouchers(
] ]
description = 'برداشت مالک از کیف پول' description = 'برداشت مالک از کیف پول'
break break
case 'booking_payment':
lines = [
makeLine(map.ownerShareAccountId, amount, 0, 'سهم مالک از رزرو'),
makeLine(map.ownerPayableAccountId, 0, amount, 'بدهی به مالک'),
]
description = 'سهم مالک از فروش رزرو'
break
case 'refund': case 'refund':
lines = [ lines = [
makeLine(map.ownerPayableAccountId, amount, 0, 'بازپرداخت به مالک'), makeLine(map.ownerPayableAccountId, amount, 0, 'بازپرداخت به مالک'),
@@ -112,11 +101,29 @@ function buildOwnerVouchers(
return { vouchers, skipped } 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( function buildCustomerVouchers(
records: ApiPaymentOrRefund[], records: ApiPaymentOrRefund[],
map: VoucherAccountMap, map: VoucherAccountMap,
existingSources: Set<string>, existingSources: Set<string>,
startNumber: number, startNumber: number,
invoices: Invoice[],
parties: Party[],
): { vouchers: Voucher[]; skipped: number } { ): { vouchers: Voucher[]; skipped: number } {
const vouchers: Voucher[] = [] const vouchers: Voucher[] = []
let skipped = 0 let skipped = 0
@@ -152,6 +159,10 @@ function buildCustomerVouchers(
description = `فروش/رزرو - ${label}` description = `فروش/رزرو - ${label}`
} }
} else if (isWallet) { } else if (isWallet) {
if (walletWithdrawAlreadyBooked(invoices, parties, label, amount)) {
skipped += 1
continue
}
lines = [ lines = [
makeLine(map.customerPayableAccountId, amount, 0, 'برداشت کیف پول'), makeLine(map.customerPayableAccountId, amount, 0, 'برداشت کیف پول'),
makeLine(map.bankAccountId, 0, amount, 'پرداخت از بانک'), makeLine(map.bankAccountId, 0, amount, 'پرداخت از بانک'),
@@ -210,8 +221,10 @@ export function useVoucherSync() {
const syncCustomerActivity = useCallback( const syncCustomerActivity = useCallback(
(records: ApiPaymentOrRefund[]): Promise<SyncResult> => (records: ApiPaymentOrRefund[]): Promise<SyncResult> =>
run((map, sources, start) => buildCustomerVouchers(records, map, sources, start)), run((map, sources, start) =>
[run], buildCustomerVouchers(records, map, sources, start, data.invoices, data.parties),
),
[run, data.invoices, data.parties],
) )
return { syncOwnerTransactions, syncCustomerActivity } return { syncOwnerTransactions, syncCustomerActivity }

View File

@@ -50,6 +50,8 @@ export const ACCOUNTING_ENDPOINTS = {
PRODUCTS: '/accounting/products/', PRODUCTS: '/accounting/products/',
VOUCHERS: '/accounting/vouchers/', VOUCHERS: '/accounting/vouchers/',
INVOICES: '/accounting/invoices/', INVOICES: '/accounting/invoices/',
SALES_TYPES: '/accounting/sales-types/',
PARTY_ADJUSTMENTS: '/accounting/party-adjustments/',
TREASURY: '/accounting/treasury/', TREASURY: '/accounting/treasury/',
EMPLOYEES: '/accounting/employees/', EMPLOYEES: '/accounting/employees/',
PAYSLIPS: '/accounting/payslips/', PAYSLIPS: '/accounting/payslips/',

View File

@@ -6,10 +6,14 @@ export interface ApiOwner {
mobile_number: string | null mobile_number: string | null
email: string | null email: string | null
address: string | null address: string | null
national_code: string | null
balance: number balance: number
credit_number: string | null credit_number: string | null
is_active: boolean is_active: boolean
is_verified?: boolean
created_at: string created_at: string
birthday?: string | null
avatar?: string | null
social_hubs?: string[] social_hubs?: string[]
} }
@@ -21,10 +25,17 @@ export interface ApiCustomer {
mobile_number: string | null mobile_number: string | null
email: string | null email: string | null
address: string | null address: string | null
national_code: number | string | null
balance: number balance: number
iban: string | null iban: string | null
is_active: boolean is_active: boolean
is_verified?: boolean
created_at: string 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' export type WalletTxnType = 'deposit' | 'withdraw' | 'booking_payment' | 'refund'
@@ -69,3 +80,9 @@ export interface ApiWithdrawal {
iban: string iban: string
created_at: 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 || 'بدون نام'

View File

@@ -8,6 +8,7 @@ import {
DataTable, DataTable,
Field, Field,
Input, Input,
JalaliDateInput,
Modal, Modal,
PageHeader, PageHeader,
Select, Select,
@@ -16,8 +17,10 @@ import {
} from '../ui' } from '../ui'
import type { Invoice, InvoiceKind, InvoiceLine, InvoiceStatus, Party } from '../../types' import type { Invoice, InvoiceKind, InvoiceLine, InvoiceStatus, Party } from '../../types'
import { createId, nextNumber } from '../../utils/id' 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 { invoiceTotals, lineTotals } from '../../utils/accounting'
import { customers, reconcileInvoicePaymentStatus, suppliers } from '../../pages/sales/salesUtils'
import { invoiceSideEffects, invoiceDeleteEffects } from '../../pages/sales/invoiceEffects'
interface TradeConfig { interface TradeConfig {
kind: InvoiceKind kind: InvoiceKind
@@ -25,6 +28,7 @@ interface TradeConfig {
subtitle: string subtitle: string
partyLabel: string partyLabel: string
partyTabLabel: string partyTabLabel: string
hidePartyTab?: boolean
} }
const statusLabels: Record<InvoiceStatus, string> = { const statusLabels: Record<InvoiceStatus, string> = {
@@ -40,22 +44,28 @@ const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
} }
export function TradeModule({ config }: { config: TradeConfig }) { export function TradeModule({ config }: { config: TradeConfig }) {
const [tab, setTab] = useState<'invoices' | 'parties'>('invoices') const [tab, setTab] = useState<'invoices' | 'parties'>(config.hidePartyTab ? 'invoices' : 'invoices')
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader title={config.title} subtitle={config.subtitle} /> <PageHeader title={config.title} subtitle={config.subtitle} />
<div className="inline-flex rounded-xl bg-slate-200/60 p-1"> {!config.hidePartyTab && (
<TabButton active={tab === 'invoices'} onClick={() => setTab('invoices')}> <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')}> </TabButton>
{config.partyTabLabel} <TabButton active={tab === 'parties'} onClick={() => setTab('parties')}>
</TabButton> {config.partyTabLabel}
</div> </TabButton>
</div>
)}
{tab === 'invoices' ? <InvoiceSection config={config} /> : <PartySection config={config} />} {tab === 'invoices' || config.hidePartyTab ? (
<InvoiceSection config={config} />
) : (
<PartySection config={config} />
)}
</div> </div>
) )
} }
@@ -93,12 +103,14 @@ const newInvoiceLine = (): InvoiceLine => ({
}) })
function InvoiceSection({ config }: { config: TradeConfig }) { 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 [draft, setDraft] = useState<Invoice | null>(null)
const [toDelete, setToDelete] = useState<Invoice | null>(null) const [toDelete, setToDelete] = useState<Invoice | null>(null)
const [previousDraft, setPreviousDraft] = useState<Invoice | null>(null)
const parties = useMemo( 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], [data.parties, config.kind],
) )
const invoices = useMemo( const invoices = useMemo(
@@ -114,9 +126,10 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
const createDraft = (): Invoice => ({ const createDraft = (): Invoice => ({
id: '', id: '',
kind: config.kind, kind: config.kind,
documentType: 'invoice',
number: nextNumber(data.invoices.filter((i) => i.kind === config.kind)), number: nextNumber(data.invoices.filter((i) => i.kind === config.kind)),
partyId: parties[0]?.id ?? '', partyId: parties[0]?.id ?? '',
date: new Date().toISOString().slice(0, 10), date: todayISO(),
status: 'draft', status: 'draft',
note: '', note: '',
lines: [newInvoiceLine()], lines: [newInvoiceLine()],
@@ -143,8 +156,37 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
if (!draft || !draft.partyId) return if (!draft || !draft.partyId) return
const cleanLines = draft.lines.filter((line) => line.productId && line.quantity > 0) const cleanLines = draft.lines.filter((line) => line.productId && line.quantity > 0)
if (cleanLines.length === 0) return 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) setDraft(null)
setPreviousDraft(null)
} }
const totals = draft ? invoiceTotals(draft) : null const totals = draft ? invoiceTotals(draft) : null
@@ -172,7 +214,10 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
align: 'end', align: 'end',
render: (i) => ( render: (i) => (
<div className="flex justify-end gap-1"> <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>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(i)}> <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"> <Card className="p-4">
<div className="mb-4 flex justify-end"> <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> </Button>
</div> </div>
{parties.length === 0 ? ( {parties.length === 0 ? (
<p className="py-10 text-center text-sm text-slate-400"> <p className="py-10 text-center text-sm text-slate-400">
ابتدا حداقل یک {config.partyLabel} تعریف کنید. {config.kind === 'sale'
? 'مشتری‌ای از فان‌زون همگام نشده است. به بخش مشتریان بروید و «بروزرسانی» را بزنید.'
: 'تأمین‌کننده‌ای از فان‌زون همگام نشده است. به بخش تأمین‌کنندگان بروید و «بروزرسانی» را بزنید.'}
</p> </p>
) : ( ) : (
<DataTable <DataTable
@@ -208,7 +255,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
<Modal <Modal
open={draft !== null} open={draft !== null}
title={draft?.id ? `ویرایش فاکتور #${toFa(draft.number)}` : 'فاکتور جدید'} title={draft?.id ? `ویرایش فاکتور #${toFa(draft.number)}` : 'فاکتور جدید'}
onClose={() => setDraft(null)} onClose={() => { setDraft(null); setPreviousDraft(null) }}
size="xl" size="xl"
footer={ footer={
<> <>
@@ -221,7 +268,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
</span> </span>
</div> </div>
)} )}
<Button variant="secondary" onClick={() => setDraft(null)}> <Button variant="secondary" onClick={() => { setDraft(null); setPreviousDraft(null) }}>
انصراف انصراف
</Button> </Button>
<Button onClick={handleSave}>ذخیره فاکتور</Button> <Button onClick={handleSave}>ذخیره فاکتور</Button>
@@ -239,7 +286,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
/> />
</Field> </Field>
<Field label="تاریخ"> <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>
<Field label={config.partyLabel}> <Field label={config.partyLabel}>
<Select value={draft.partyId} onChange={(e) => setDraft({ ...draft, partyId: e.target.value })}> <Select value={draft.partyId} onChange={(e) => setDraft({ ...draft, partyId: e.target.value })}>
@@ -360,6 +407,12 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
placeholder="توضیحات فاکتور…" placeholder="توضیحات فاکتور…"
/> />
</Field> </Field>
{draft.status !== 'draft' && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.
</p>
)}
</div> </div>
)} )}
</Modal> </Modal>
@@ -370,7 +423,15 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
message={`فاکتور شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`} message={`فاکتور شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
onConfirm={() => { 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) setToDelete(null)
}} }}
/> />
@@ -387,8 +448,8 @@ function PartySection({ config }: { config: TradeConfig }) {
const partyKind = config.kind === 'sale' ? 'customer' : 'supplier' const partyKind = config.kind === 'sale' ? 'customer' : 'supplier'
const parties = useMemo( const parties = useMemo(
() => data.parties.filter((party) => party.kind === partyKind), () => (config.kind === 'sale' ? customers(data.parties) : suppliers(data.parties)),
[data.parties, partyKind], [data.parties, config.kind],
) )
const createDraft = (): Party => ({ const createDraft = (): Party => ({

View File

@@ -5,6 +5,7 @@ import { navItems } from './navigation'
import { useConnection } from '../../connection/ConnectionContext' import { useConnection } from '../../connection/ConnectionContext'
import { useStore } from '../../store/AppStore' import { useStore } from '../../store/AppStore'
import { ConnectionRequired } from '../business/ConnectionRequired' import { ConnectionRequired } from '../business/ConnectionRequired'
import { GlobalPartySync } from '../../parties/GlobalPartySync'
import { Button, Card } from '../ui' import { Button, Card } from '../ui'
function DataGate({ children }: { children: React.ReactNode }) { function DataGate({ children }: { children: React.ReactNode }) {
@@ -51,6 +52,15 @@ function DataGate({ children }: { children: React.ReactNode }) {
return <>{children}</> return <>{children}</>
} }
function DataGateInner({ children }: { children: React.ReactNode }) {
return (
<>
<GlobalPartySync />
{children}
</>
)
}
export function Layout() { export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
const location = useLocation() const location = useLocation()
@@ -96,10 +106,12 @@ export function Layout() {
</div> </div>
</header> </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"> <div className="animate-fade-in">
<DataGate> <DataGate>
<Outlet /> <DataGateInner>
<Outlet />
</DataGateInner>
</DataGate> </DataGate>
</div> </div>
</main> </main>

View File

@@ -1,7 +1,13 @@
import { NavLink } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { navItems, type NavItem } from './navigation' import { navItems, type NavItem } from './navigation'
import { salesNavItems } from '../../pages/sales/salesNavigation'
import { useStore } from '../../store/AppStore' import { useStore } from '../../store/AppStore'
const purchaseNavItems = [
{ to: '/purchases/suppliers', label: 'تأمین‌کنندگان', icon: '🏢' },
{ to: '/purchases/invoices', label: 'فاکتورهای خرید', icon: '🛒' },
]
function groupItems(items: NavItem[]): Array<[string, NavItem[]]> { function groupItems(items: NavItem[]): Array<[string, NavItem[]]> {
const map = new Map<string, NavItem[]>() const map = new Map<string, NavItem[]>()
for (const item of items) { 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 }) { export function Sidebar({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
const { data } = useStore() const { data } = useStore()
const location = useLocation()
const groups = groupItems(navItems) 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 ( return (
<aside <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> <p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">{group}</p>
<div className="space-y-1"> <div className="space-y-1">
{items.map((item) => ( {items.map((item) => (
<NavLink <div key={item.to}>
key={item.to} <NavLink
to={item.to} to={item.to}
end={item.to === '/'} end={item.to === '/'}
onClick={onNavigate} onClick={onNavigate}
className={({ isActive }) => className={({ isActive }) => linkClass(isActive)}
`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors ${ >
isActive <span className="text-lg">{item.icon}</span>
? 'bg-brand-50 text-brand-700' <span>{item.label}</span>
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' </NavLink>
}`
} {item.to === '/sales' && salesExpanded && (
> <div className="me-2 mt-1 space-y-0.5 border-r-2 border-brand-100 pr-2">
<span className="text-lg">{item.icon}</span> {salesNavItems.map((sub) => (
<span>{item.label}</span> <NavLink
</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>
</div> </div>

View File

@@ -18,9 +18,6 @@ export const navItems: NavItem[] = [
{ to: '/treasury', label: 'دریافت و پرداخت', icon: '💰', group: 'عملیات' }, { to: '/treasury', label: 'دریافت و پرداخت', icon: '💰', group: 'عملیات' },
{ to: '/payroll', label: 'حقوق و دستمزد', icon: '👥', group: 'عملیات' }, { to: '/payroll', label: 'حقوق و دستمزد', icon: '👥', group: 'عملیات' },
{ to: '/owners', label: 'مالکان مجموعه‌ها', icon: '🏢', group: 'مدیریت فان‌زون' },
{ to: '/customers', label: 'کاربران', icon: '🧑‍🤝‍🧑', group: 'مدیریت فان‌زون' },
{ to: '/reports', label: 'گزارش‌ها', icon: '📊', group: 'تحلیل' }, { to: '/reports', label: 'گزارش‌ها', icon: '📊', group: 'تحلیل' },
{ to: '/settings', label: 'تنظیمات', icon: '⚙️', group: 'سیستم' }, { to: '/settings', label: 'تنظیمات', icon: '⚙️', group: 'سیستم' },
] ]

View File

@@ -1,12 +1,19 @@
import type { ReactNode } from 'react' import type { CSSProperties, ReactNode } from 'react'
import { EmptyState } from './primitives' import { EmptyState } from './primitives'
export type ColumnSticky = boolean | { left?: string }
export interface Column<T> { export interface Column<T> {
key: string key: string
header: ReactNode header: ReactNode
render: (row: T) => ReactNode render: (row: T) => ReactNode
align?: 'start' | 'center' | 'end' align?: 'start' | 'center' | 'end'
className?: string 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> { interface DataTableProps<T> {
@@ -17,6 +24,11 @@ interface DataTableProps<T> {
emptyTitle?: string emptyTitle?: string
emptyDescription?: string emptyDescription?: string
footer?: ReactNode 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 = { const alignClass = {
@@ -25,6 +37,38 @@ const alignClass = {
end: 'text-end', end: 'text-end',
} as const } 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>({ export function DataTable<T>({
columns, columns,
rows, rows,
@@ -33,20 +77,31 @@ export function DataTable<T>({
emptyTitle = 'موردی یافت نشد', emptyTitle = 'موردی یافت نشد',
emptyDescription, emptyDescription,
footer, footer,
minWidth = '720px',
size = 'default',
className,
}: DataTableProps<T>) { }: DataTableProps<T>) {
if (rows.length === 0) { if (rows.length === 0) {
return <EmptyState title={emptyTitle} description={emptyDescription} /> 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 ( return (
<div className="overflow-x-auto"> <div className={`overflow-x-auto ${className ?? ''}`}>
<table className="w-full border-collapse text-sm"> <table className={`w-full table-fixed border-collapse ${density.table}`} style={{ minWidth }}>
<thead> <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) => ( {columns.map((column) => (
<th <th
key={column.key} 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} {column.header}
</th> </th>
@@ -58,12 +113,13 @@ export function DataTable<T>({
<tr <tr
key={rowKey(row)} key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined} 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) => ( {columns.map((column) => (
<td <td
key={column.key} 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)} {column.render(row)}
</td> </td>

View 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}
/>
)
}

View File

@@ -13,3 +13,4 @@ export {
export { Modal } from './Modal' export { Modal } from './Modal'
export { DataTable, type Column } from './DataTable' export { DataTable, type Column } from './DataTable'
export { ConfirmDialog } from './ConfirmDialog' export { ConfirmDialog } from './ConfirmDialog'
export { JalaliDateInput } from './JalaliDateInput'

View File

@@ -60,3 +60,18 @@ body {
transform: translateY(0); 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;
}

View File

@@ -2,6 +2,8 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import './index.css' 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( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>

View File

@@ -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>
)
}

View File

@@ -16,6 +16,10 @@ interface MonthBucket {
purchases: number 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( function monthlyBuckets(
invoices: ReturnType<typeof useStore>['data']['invoices'], invoices: ReturnType<typeof useStore>['data']['invoices'],
): MonthBucket[] { ): MonthBucket[] {
@@ -33,10 +37,10 @@ function monthlyBuckets(
buckets.push({ buckets.push({
label: formatter.format(date), label: formatter.format(date),
sales: inMonth sales: inMonth
.filter((i) => i.kind === 'sale') .filter(isSaleInvoice)
.reduce((sum, i) => sum + invoiceTotals(i).net, 0), .reduce((sum, i) => sum + invoiceTotals(i).net, 0),
purchases: inMonth purchases: inMonth
.filter((i) => i.kind === 'purchase') .filter((i) => i.kind === 'purchase' && i.status !== 'draft')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0), .reduce((sum, i) => sum + invoiceTotals(i).net, 0),
}) })
} }
@@ -53,16 +57,18 @@ export function Dashboard() {
.reduce((sum, b) => sum + b.balance, 0) .reduce((sum, b) => sum + b.balance, 0)
const sales = data.invoices const sales = data.invoices
.filter((i) => i.kind === 'sale') .filter(isSaleInvoice)
.reduce((sum, i) => sum + invoiceTotals(i).net, 0) .reduce((sum, i) => sum + invoiceTotals(i).net, 0)
const purchases = data.invoices 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) .reduce((sum, i) => sum + invoiceTotals(i).net, 0)
const income = incomeStatement(balances) const income = incomeStatement(balances)
const sheet = balanceSheet(balances) const sheet = balanceSheet(balances)
const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel) 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 } return { liquidity, sales, purchases, income, sheet, lowStock, unpaidSales }
}, [data]) }, [data])
@@ -88,7 +94,7 @@ export function Dashboard() {
<StatCard <StatCard
label="فروش کل" label="فروش کل"
value={formatMoney(metrics.sales)} value={formatMoney(metrics.sales)}
hint={`${toFa(data.invoices.filter((i) => i.kind === 'sale').length)} فاکتور فروش`} hint={`${toFa(data.invoices.filter(isSaleInvoice).length)} فاکتور فروش`}
icon="🧾" icon="🧾"
tone="green" tone="green"
/> />
@@ -190,8 +196,8 @@ export function Dashboard() {
<Card className="p-5"> <Card className="p-5">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">فاکتورهای فروش تسویهنشده</h2> <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> </Link>
</div> </div>
{metrics.unpaidSales.length === 0 ? ( {metrics.unpaidSales.length === 0 ? (

View File

@@ -15,7 +15,7 @@ import {
} from '../components/ui' } from '../components/ui'
import type { Product } from '../types' import type { Product } from '../types'
import { createId } from '../utils/id' import { createId } from '../utils/id'
import { formatMoney, toFa } from '../utils/format' import { formatMoney, sumAmounts, toAmount, toFa } from '../utils/format'
const emptyDraft = (): Product => ({ const emptyDraft = (): Product => ({
id: '', id: '',
@@ -36,7 +36,7 @@ export function Inventory() {
const [toDelete, setToDelete] = useState<Product | null>(null) const [toDelete, setToDelete] = useState<Product | null>(null)
const summary = useMemo(() => { 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 const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel).length
return { value, lowStock, count: data.products.length } return { value, lowStock, count: data.products.length }
}, [data.products]) }, [data.products])

View File

@@ -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>
)
}

View File

@@ -8,6 +8,7 @@ import {
DataTable, DataTable,
Field, Field,
Input, Input,
JalaliDateInput,
Modal, Modal,
PageHeader, PageHeader,
Select, Select,
@@ -16,7 +17,7 @@ import {
} from '../components/ui' } from '../components/ui'
import type { Employee, Payslip } from '../types' import type { Employee, Payslip } from '../types'
import { createId } from '../utils/id' import { createId } from '../utils/id'
import { formatMoney, toFa } from '../utils/format' import { formatMoney, todayISO, toFa } from '../utils/format'
import { payslipNet } from '../utils/accounting' import { payslipNet } from '../utils/accounting'
export function Payroll() { export function Payroll() {
@@ -57,7 +58,7 @@ const emptyEmployee = (): Employee => ({
position: '', position: '',
baseSalary: 0, baseSalary: 0,
insuranceNo: '', insuranceNo: '',
hireDate: new Date().toISOString().slice(0, 10), hireDate: todayISO(),
active: true, active: true,
}) })
@@ -152,7 +153,7 @@ function EmployeeSection() {
<Input value={draft.insuranceNo} onChange={(e) => setDraft({ ...draft, insuranceNo: e.target.value })} /> <Input value={draft.insuranceNo} onChange={(e) => setDraft({ ...draft, insuranceNo: e.target.value })} />
</Field> </Field>
<Field label="تاریخ استخدام"> <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>
<Field label="وضعیت"> <Field label="وضعیت">
<Select value={draft.active ? 'active' : 'inactive'} onChange={(e) => setDraft({ ...draft, active: e.target.value === 'active' })}> <Select value={draft.active ? 'active' : 'inactive'} onChange={(e) => setDraft({ ...draft, active: e.target.value === 'active' })}>

View File

@@ -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' import { TradeModule } from '../components/business/TradeModule'
export function Purchases() { export function Purchases() {
return ( return (
<TradeModule <Routes>
config={{ <Route element={<PurchasesLayout />}>
kind: 'purchase', <Route index element={<Navigate to="/purchases/suppliers" replace />} />
title: 'تأمین‌کنندگان و خرید', <Route path="suppliers" element={<PurchaseSuppliers />} />
subtitle: 'مدیریت تأمین‌کنندگان و ثبت فاکتورهای خرید', <Route
partyLabel: 'تأمین‌کننده', path="invoices"
partyTabLabel: 'تأمین‌کنندگان', element={
}} <TradeModule
/> config={{
kind: 'purchase',
title: 'فاکتورهای خرید',
subtitle: 'ثبت فاکتور خرید از تأمین‌کنندگان (مالکان)',
partyLabel: 'تأمین‌کننده',
partyTabLabel: 'تأمین‌کنندگان',
hidePartyTab: true,
}}
/>
}
/>
<Route path="*" element={<Navigate to="/purchases/suppliers" replace />} />
</Route>
</Routes>
) )
} }

View File

@@ -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() { export function Sales() {
return ( return (
<TradeModule <Routes>
config={{ <Route element={<SalesLayout />}>
kind: 'sale', <Route index element={<SalesProcess />} />
title: 'مشتریان و فروش', <Route path="proforma" element={<SalesProforma />} />
subtitle: 'مدیریت مشتریان و صدور فاکتورهای فروش', <Route path="invoice" element={<SalesInvoice />} />
partyLabel: 'مشتری', <Route path="return" element={<SalesReturn />} />
partyTabLabel: 'مشتریان', <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>
) )
} }

View File

@@ -1,198 +1,701 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
import { import {
Badge, Badge,
Button, Button,
Card, Card,
ConfirmDialog, ConfirmDialog,
DataTable, DataTable,
Field, Field,
Input, Input,
JalaliDateInput,
Modal, Modal,
PageHeader, PageHeader,
Select, Select,
StatCard, StatCard,
Textarea, Textarea,
type Column, type Column,
} from '../components/ui' } from '../components/ui'
import type { TreasuryKind, TreasuryMethod, TreasuryTxn } from '../types' import type { TreasuryKind, TreasuryMethod, TreasuryTxn } from '../types'
import { createId, nextNumber } from '../utils/id' 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 kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
const methodLabels: Record<TreasuryMethod, string> = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' } const methodLabels: Record<TreasuryMethod, string> = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' }
export function Treasury() { 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 [draft, setDraft] = useState<TreasuryTxn | null>(null)
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null) const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
const partyName = (id: string | null) => const partyName = (id: string | null) =>
id ? data.parties.find((p) => p.id === id)?.name ?? '—' : '—' 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) const funZoneCounts = useMemo(() => {
return { receipts, payments, net: receipts - payments }
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]) }, [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( 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], [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 => ({ const createDraft = (kind: TreasuryKind): TreasuryTxn => ({
id: '', id: '',
number: nextNumber(data.treasury.filter((t) => t.kind === kind)), number: nextNumber(data.treasury.filter((t) => t.kind === kind)),
kind, kind,
method: 'bank', method: 'bank',
date: new Date().toISOString().slice(0, 10),
date: todayISO(),
partyId: null, partyId: null,
amount: 0, amount: 0,
reference: '', reference: '',
description: '', description: '',
source: '',
}) })
const handleSave = () => { const handleSave = () => {
if (!draft || draft.amount <= 0) return 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) setDraft(null)
} }
const columns: Array<Column<TreasuryTxn>> = [ const columns: Array<Column<TreasuryTxn>> = [
{ key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) }, { key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) },
{ {
key: 'kind', key: 'kind',
header: 'نوع', header: 'نوع',
render: (t) => <Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>, 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: 'method', header: 'روش', render: (t) => <Badge tone="slate">{methodLabels[t.method]}</Badge> },
{ key: 'party', header: 'طرف حساب', render: (t) => partyName(t.partyId) }, { 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: 'مبلغ', key: 'details',
align: 'end',
header: 'شرح',
render: (t) => ( 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> </span>
), ),
}, },
{ key: 'ref', header: 'مرجع', render: (t) => <span className="font-mono text-slate-500">{t.reference || '—'}</span> },
{ {
key: 'actions',
header: '', key: 'amount',
header: 'مبلغ',
align: 'end', align: 'end',
render: (t) => ( render: (t) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...t })}> <span className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}>
ویرایش
</Button> {t.kind === 'receipt' ? '+' : ''} {formatMoney(t.amount)}
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
حذف </span>
</Button>
</div>
), ),
}, },
{
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="دریافت و پرداخت (خزانه‌داری)" title="دریافت و پرداخت (خزانه‌داری)"
subtitle="ثبت دریافت‌ها و پرداخت‌های نقدی، بانکی و چک"
subtitle="دریافت از مشتریان (فان‌زون) و پرداخت به مالکان — همگام با سپیدار (سند + تسویه فاکتور)"
actions={ actions={
<> <>
<Button variant="secondary" icon="↻" onClick={() => void sync()} disabled={syncLoading}>
{syncLoading ? 'همگام‌سازی…' : 'همگام‌سازی فان‌زون'}
</Button>
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}> <Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
دریافت جدید
دریافت دستی
</Button> </Button>
<Button variant="danger" icon="↑" onClick={() => setDraft(createDraft('payment'))}> <Button variant="danger" icon="↑" onClick={() => setDraft(createDraft('payment'))}>
پرداخت جدید
پرداخت دستی
</Button> </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> </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 <Modal
open={draft !== null} open={draft !== null}
title={draft ? `${kindLabels[draft.kind]} جدید` : ''}
onClose={() => setDraft(null)} title={
footer={
<> draft
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف ? isFunZoneTreasuryTxn(draft)
</Button>
<Button onClick={handleSave}>ذخیره</Button> ? `${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 && ( {draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <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 })} /> {isFunZoneTreasuryTxn(draft) ? (
</Field>
<Field label="روش"> <div className="sm:col-span-2">
<Select value={draft.method} onChange={(e) => setDraft({ ...draft, method: e.target.value as TreasuryMethod })}>
{Object.entries(methodLabels).map(([value, label]) => ( <pre className="whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm text-slate-700">
<option key={value} value={value}>
{label} {draft.description}
</option>
))} </pre>
</Select>
</Field> <dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
<Field label="طرف حساب">
<Select <div>
value={draft.partyId ?? ''}
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })} <dt className="text-slate-400">طرف حساب</dt>
>
<option value=""> بدون طرف حساب </option> <dd className="font-medium">{partyName(draft.partyId)}</dd>
{data.parties.map((party) => (
<option key={party.id} value={party.id}> </div>
{party.name} ({party.kind === 'customer' ? 'مشتری' : 'تأمین‌کننده'})
</option> <div>
))}
</Select> <dt className="text-slate-400">مبلغ</dt>
</Field>
<Field label="مبلغ (تومان)"> <dd className="font-semibold tabular-nums">{formatMoney(draft.amount)}</dd>
<Input type="number" value={draft.amount} onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })} />
</Field> </div>
<Field label="شماره مرجع / چک">
<Input value={draft.reference} onChange={(e) => setDraft({ ...draft, reference: e.target.value })} /> <div>
</Field>
<div className="sm:col-span-2"> <dt className="text-slate-400">تاریخ</dt>
<Field label="توضیحات">
<Textarea rows={2} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /> <dd>{formatDate(draft.date)}</dd>
</Field>
</div> </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> </div>
)} )}
</Modal> </Modal>
<ConfirmDialog <ConfirmDialog
open={toDelete !== null} open={toDelete !== null}
title="حذف تراکنش" title="حذف تراکنش"
message="این تراکنش خزانه حذف شود؟" message="این تراکنش خزانه حذف شود؟"
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
onConfirm={() => { 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) setToDelete(null)
}} }}
/> />
</div> </div>
) )
} }

View File

@@ -8,6 +8,7 @@ import {
DataTable, DataTable,
Field, Field,
Input, Input,
JalaliDateInput,
Modal, Modal,
PageHeader, PageHeader,
Select, Select,
@@ -15,7 +16,7 @@ import {
} from '../components/ui' } from '../components/ui'
import type { Voucher, VoucherLine } from '../types' import type { Voucher, VoucherLine } from '../types'
import { createId, nextNumber } from '../utils/id' 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' import { voucherBalance } from '../utils/accounting'
const newLine = (): VoucherLine => ({ const newLine = (): VoucherLine => ({
@@ -30,7 +31,7 @@ function createDraft(items: Voucher[]): Voucher {
return { return {
id: '', id: '',
number: nextNumber(items), number: nextNumber(items),
date: new Date().toISOString().slice(0, 10), date: todayISO(),
description: '', description: '',
status: 'draft', status: 'draft',
lines: [newLine(), newLine()], lines: [newLine(), newLine()],
@@ -74,18 +75,45 @@ export function Vouchers() {
} }
const columns: Array<Column<Voucher>> = [ 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: 'number',
{ key: 'desc', header: 'شرح', render: (v) => <span className="text-slate-700">{v.description || '—'}</span> }, 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', key: 'amount',
header: 'مبلغ', header: 'مبلغ',
align: 'end', 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', key: 'status',
header: 'وضعیت', header: 'وضعیت',
sticky: { left: '9rem' },
headerClassName: 'w-[6.5rem]',
className: 'w-[6.5rem] whitespace-nowrap',
render: (v) => render: (v) =>
v.status === 'posted' ? <Badge tone="green">ثبت قطعی</Badge> : <Badge tone="amber">پیشنویس</Badge>, v.status === 'posted' ? <Badge tone="green">ثبت قطعی</Badge> : <Badge tone="amber">پیشنویس</Badge>,
}, },
@@ -93,6 +121,9 @@ export function Vouchers() {
key: 'actions', key: 'actions',
header: '', header: '',
align: 'end', align: 'end',
sticky: { left: '0' },
headerClassName: 'w-[9rem]',
className: 'w-[9rem] whitespace-nowrap',
render: (v) => ( render: (v) => (
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...v, lines: v.lines.map((l) => ({ ...l })) })}> <Button size="sm" variant="ghost" onClick={() => setDraft({ ...v, lines: v.lines.map((l) => ({ ...l })) })}>
@@ -171,7 +202,7 @@ export function Vouchers() {
/> />
</Field> </Field>
<Field label="تاریخ"> <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>
<Field label="شرح سند"> <Field label="شرح سند">
<Input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /> <Input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />

View File

@@ -0,0 +1,14 @@
import { FunZonePartySection } from '../../parties/FunZonePartySection'
export function PurchaseSuppliers() {
return (
<FunZonePartySection
config={{
kind: 'supplier',
title: 'تأمین‌کنندگان',
subtitle: 'مالکان مجموعه‌ها از اپلیکیشن مالکان — منبع داده فان‌زون، مشابه «تأمین‌کنندگان» در سپیدار',
externalSource: 'funzone_owner',
}}
/>
)
}

View 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>
)
}

View 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)
}}
/>
</>
)
}

View 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>
)
}

View File

@@ -0,0 +1,14 @@
import { FunZonePartySection } from '../../parties/FunZonePartySection'
export function SalesCustomers() {
return (
<FunZonePartySection
config={{
kind: 'customer',
title: 'مشتریان',
subtitle: 'لیست کاربران اپلیکیشن مشتریان — منبع داده فان‌زون، مشابه «مشتریان» در سپیدار',
externalSource: 'funzone_customer',
}}
/>
)
}

View 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)
}}
/>
</>
)
}

View 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
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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)
}}
/>
</>
)
}

View 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,
}

View 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: '🔍' },
]

View 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.')

View 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,
}
}

View 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')
}

View 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.')

View 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-'),
}))
}

View 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 }
}

View 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>,
},
]

View 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
}

View 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])
}

View 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 }

View File

@@ -15,8 +15,10 @@ import type {
Employee, Employee,
Invoice, Invoice,
Party, Party,
PartyAdjustment,
Payslip, Payslip,
Product, Product,
SalesType,
TreasuryTxn, TreasuryTxn,
Voucher, Voucher,
} from '../types' } from '../types'
@@ -33,6 +35,8 @@ type CollectionKey =
| 'parties' | 'parties'
| 'products' | 'products'
| 'invoices' | 'invoices'
| 'salesTypes'
| 'partyAdjustments'
| 'treasury' | 'treasury'
| 'employees' | 'employees'
| 'payslips' | 'payslips'
@@ -43,6 +47,8 @@ const ENDPOINT: Record<CollectionKey, string> = {
parties: ACCOUNTING_ENDPOINTS.PARTIES, parties: ACCOUNTING_ENDPOINTS.PARTIES,
products: ACCOUNTING_ENDPOINTS.PRODUCTS, products: ACCOUNTING_ENDPOINTS.PRODUCTS,
invoices: ACCOUNTING_ENDPOINTS.INVOICES, invoices: ACCOUNTING_ENDPOINTS.INVOICES,
salesTypes: ACCOUNTING_ENDPOINTS.SALES_TYPES,
partyAdjustments: ACCOUNTING_ENDPOINTS.PARTY_ADJUSTMENTS,
treasury: ACCOUNTING_ENDPOINTS.TREASURY, treasury: ACCOUNTING_ENDPOINTS.TREASURY,
employees: ACCOUNTING_ENDPOINTS.EMPLOYEES, employees: ACCOUNTING_ENDPOINTS.EMPLOYEES,
payslips: ACCOUNTING_ENDPOINTS.PAYSLIPS, payslips: ACCOUNTING_ENDPOINTS.PAYSLIPS,
@@ -71,6 +77,8 @@ const EMPTY_DATA: AppData = {
parties: [], parties: [],
products: [], products: [],
invoices: [], invoices: [],
salesTypes: [],
partyAdjustments: [],
treasury: [], treasury: [],
employees: [], employees: [],
payslips: [], payslips: [],
@@ -90,11 +98,16 @@ interface AppStoreValue {
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */ /** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
createVouchers: (items: Voucher[]) => Promise<number> createVouchers: (items: Voucher[]) => Promise<number>
upsertParty: (item: Party) => void upsertParty: (item: Party) => void
upsertPartyAsync: (item: Party) => Promise<void>
removeParty: (id: string) => void removeParty: (id: string) => void
upsertProduct: (item: Product) => void upsertProduct: (item: Product) => void
removeProduct: (id: string) => void removeProduct: (id: string) => void
upsertInvoice: (item: Invoice) => void upsertInvoice: (item: Invoice) => void
removeInvoice: (id: string) => 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 upsertTreasury: (item: TreasuryTxn) => void
removeTreasury: (id: string) => void removeTreasury: (id: string) => void
upsertEmployee: (item: Employee) => void upsertEmployee: (item: Employee) => void
@@ -154,19 +167,21 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { 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([ await Promise.all([
acct.get(ENDPOINT.accounts).then(asList<Account>), acct.get(ENDPOINT.accounts).then(asList<Account>),
acct.get(ENDPOINT.vouchers).then(asList<Voucher>), acct.get(ENDPOINT.vouchers).then(asList<Voucher>),
acct.get(ENDPOINT.parties).then(asList<Party>), acct.get(ENDPOINT.parties).then(asList<Party>),
acct.get(ENDPOINT.products).then(asList<Product>), acct.get(ENDPOINT.products).then(asList<Product>),
acct.get(ENDPOINT.invoices).then(asList<Invoice>), 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.treasury).then(asList<TreasuryTxn>),
acct.get(ENDPOINT.employees).then(asList<Employee>), acct.get(ENDPOINT.employees).then(asList<Employee>),
acct.get(ENDPOINT.payslips).then(asList<Payslip>), acct.get(ENDPOINT.payslips).then(asList<Payslip>),
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS), 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) setReady(true)
} catch (err) { } catch (err) {
setReady(false) setReady(false)
@@ -273,11 +288,16 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
removeVoucher: (id) => void remove('vouchers', id), removeVoucher: (id) => void remove('vouchers', id),
createVouchers, createVouchers,
upsertParty: (item) => void upsert('parties', item), upsertParty: (item) => void upsert('parties', item),
upsertPartyAsync: (item) => upsert('parties', item),
removeParty: (id) => void remove('parties', id), removeParty: (id) => void remove('parties', id),
upsertProduct: (item) => void upsert('products', item), upsertProduct: (item) => void upsert('products', item),
removeProduct: (id) => void remove('products', id), removeProduct: (id) => void remove('products', id),
upsertInvoice: (item) => void upsert('invoices', item), upsertInvoice: (item) => void upsert('invoices', item),
removeInvoice: (id) => void remove('invoices', id), 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), upsertTreasury: (item) => void upsert('treasury', item),
removeTreasury: (id) => void remove('treasury', id), removeTreasury: (id) => void remove('treasury', id),
upsertEmployee: (item) => void upsert('employees', item), upsertEmployee: (item) => void upsert('employees', item),

View File

@@ -35,6 +35,8 @@ export interface Voucher {
export type PartyKind = 'customer' | 'supplier' export type PartyKind = 'customer' | 'supplier'
export type PartyExternalSource = '' | 'funzone_customer' | 'funzone_owner'
export interface Party { export interface Party {
id: ID id: ID
kind: PartyKind kind: PartyKind
@@ -43,6 +45,21 @@ export interface Party {
economicCode: string economicCode: string
address: string address: string
openingBalance: number 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 { export interface Product {
@@ -56,6 +73,29 @@ export interface Product {
reorderLevel: number 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 { export interface InvoiceLine {
id: ID id: ID
productId: ID productId: ID
@@ -71,8 +111,11 @@ export type InvoiceStatus = 'draft' | 'confirmed' | 'paid'
export interface Invoice { export interface Invoice {
id: ID id: ID
kind: InvoiceKind kind: InvoiceKind
documentType: SalesDocumentType
number: number number: number
partyId: ID partyId: ID
salesTypeId?: ID
relatedInvoiceId?: ID
date: string date: string
status: InvoiceStatus status: InvoiceStatus
note: string note: string
@@ -90,8 +133,14 @@ export interface TreasuryTxn {
date: string date: string
partyId: ID | null partyId: ID | null
amount: number amount: number
/** Event ticket fee split (FunZone): tax 10%, platform profit 14%, owner net 76%. */
taxAmount?: number
platformProfitAmount?: number
ownerNetAmount?: number
reference: string reference: string
description: string description: string
/** Set when imported from FunZone platform (dedup key). */
source?: string
} }
export interface Employee { export interface Employee {
@@ -148,6 +197,8 @@ export interface AppData {
parties: Party[] parties: Party[]
products: Product[] products: Product[]
invoices: Invoice[] invoices: Invoice[]
salesTypes: SalesType[]
partyAdjustments: PartyAdjustment[]
treasury: TreasuryTxn[] treasury: TreasuryTxn[]
employees: Employee[] employees: Employee[]
payslips: Payslip[] payslips: Payslip[]

View File

@@ -1,3 +1,5 @@
import { parseISODateLocal, todayISO } from './solarHijriCalendar'
const currencyFormatter = new Intl.NumberFormat('fa-IR', { const currencyFormatter = new Intl.NumberFormat('fa-IR', {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}) })
@@ -33,9 +35,9 @@ export function formatNumber(value: number): string {
return numberFormatter.format(value) 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 { export function formatDate(iso: string): string {
const date = new Date(iso) const date = parseISODateLocal(iso)
if (Number.isNaN(date.getTime())) return '-' if (Number.isNaN(date.getTime())) return '-'
return jalaliDateFormatter.format(date) return jalaliDateFormatter.format(date)
} }
@@ -46,10 +48,7 @@ export function formatDateTime(iso: string): string {
return jalaliDateTimeFormatter.format(date) return jalaliDateTimeFormatter.format(date)
} }
/** Returns today's date as an ISO yyyy-mm-dd string for date inputs. */ export { todayISO }
export function todayISO(): string {
return new Date().toISOString().slice(0, 10)
}
const toPersianDigits = (input: string): string => const toPersianDigits = (input: string): string =>
input.replace(/[0-9]/g, (digit) => '۰۱۲۳۴۵۶۷۸۹'[Number(digit)]) input.replace(/[0-9]/g, (digit) => '۰۱۲۳۴۵۶۷۸۹'[Number(digit)])
@@ -57,3 +56,14 @@ const toPersianDigits = (input: string): string =>
export function toFa(value: string | number): string { export function toFa(value: string | number): string {
return toPersianDigits(String(value)) 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
View 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'
}

View 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))
}

View File

@@ -18,5 +18,6 @@
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": ["src"], "include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

File diff suppressed because one or more lines are too long

View File

@@ -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"}