Expand sales, treasury, and parties modules with Jalali calendar and FunZone integrations
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
49
package-lock.json
generated
49
package-lock.json
generated
@@ -9,11 +9,14 @@
|
|||||||
"version": "0.1.0",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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/',
|
||||||
|
|||||||
@@ -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 || 'بدون نام'
|
||||||
|
|||||||
@@ -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,12 +44,13 @@ 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} />
|
||||||
|
|
||||||
|
{!config.hidePartyTab && (
|
||||||
<div className="inline-flex rounded-xl bg-slate-200/60 p-1">
|
<div className="inline-flex rounded-xl bg-slate-200/60 p-1">
|
||||||
<TabButton active={tab === 'invoices'} onClick={() => setTab('invoices')}>
|
<TabButton active={tab === 'invoices'} onClick={() => setTab('invoices')}>
|
||||||
فاکتورها
|
فاکتورها
|
||||||
@@ -54,8 +59,13 @@ export function TradeModule({ config }: { config: TradeConfig }) {
|
|||||||
{config.partyTabLabel}
|
{config.partyTabLabel}
|
||||||
</TabButton>
|
</TabButton>
|
||||||
</div>
|
</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 => ({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<DataGateInner>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</DataGateInner>
|
||||||
</DataGate>
|
</DataGate>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { navItems, type NavItem } from './navigation'
|
import { 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) => (
|
||||||
|
<div key={item.to}>
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
|
||||||
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
|
|
||||||
? 'bg-brand-50 text-brand-700'
|
|
||||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span className="text-lg">{item.icon}</span>
|
<span className="text-lg">{item.icon}</span>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{item.to === '/sales' && salesExpanded && (
|
||||||
|
<div className="me-2 mt-1 space-y-0.5 border-r-2 border-brand-100 pr-2">
|
||||||
|
{salesNavItems.map((sub) => (
|
||||||
|
<NavLink
|
||||||
|
key={sub.to}
|
||||||
|
to={sub.to}
|
||||||
|
end={sub.end}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={({ isActive }) => subLinkClass(isActive)}
|
||||||
|
>
|
||||||
|
<span>{sub.icon}</span>
|
||||||
|
<span>{sub.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.to === '/purchases' && purchasesExpanded && (
|
||||||
|
<div className="me-2 mt-1 space-y-0.5 border-r-2 border-brand-100 pr-2">
|
||||||
|
{purchaseNavItems.map((sub) => (
|
||||||
|
<NavLink
|
||||||
|
key={sub.to}
|
||||||
|
to={sub.to}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={({ isActive }) => subLinkClass(isActive)}
|
||||||
|
>
|
||||||
|
<span>{sub.icon}</span>
|
||||||
|
<span>{sub.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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: 'سیستم' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
61
src/components/ui/JalaliDateInput.tsx
Normal file
61
src/components/ui/JalaliDateInput.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import DatePicker from 'react-multi-date-picker'
|
||||||
|
import type { DateObject } from 'react-multi-date-picker'
|
||||||
|
import persian from 'react-date-object/calendars/persian'
|
||||||
|
import persian_fa from 'react-date-object/locales/persian_fa'
|
||||||
|
import { dateToISO, parseISODateLocal } from '../../utils/solarHijriCalendar'
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100'
|
||||||
|
|
||||||
|
interface JalaliDateInputProps {
|
||||||
|
value: string
|
||||||
|
onChange: (iso: string) => void
|
||||||
|
className?: string
|
||||||
|
disabled?: boolean
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persian (Solar Hijri) date picker — same UX as FunZone SolarHijriDatePicker. */
|
||||||
|
export function JalaliDateInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
disabled = false,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
placeholder = 'انتخاب تاریخ',
|
||||||
|
}: JalaliDateInputProps) {
|
||||||
|
const pickerValue = useMemo(() => {
|
||||||
|
if (!value) return undefined
|
||||||
|
const date = parseISODateLocal(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? undefined : date
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleChange = (date: DateObject | DateObject[] | null) => {
|
||||||
|
if (!date || Array.isArray(date)) {
|
||||||
|
onChange('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(dateToISO(date.toDate()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
value={pickerValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
calendar={persian}
|
||||||
|
locale={persian_fa}
|
||||||
|
format="YYYY/MM/DD"
|
||||||
|
calendarPosition="bottom-right"
|
||||||
|
disabled={disabled}
|
||||||
|
minDate={min ? parseISODateLocal(min) : undefined}
|
||||||
|
maxDate={max ? parseISODateLocal(max) : undefined}
|
||||||
|
containerClassName="w-full"
|
||||||
|
inputClass={`${inputClassName} ${className}`.trim()}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@ export {
|
|||||||
export { Modal } from './Modal'
|
export { 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'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useConnection } from '../connection/ConnectionContext'
|
|
||||||
import { useApiResource } from '../hooks/useApiResource'
|
|
||||||
import { apiGet } from '../api/client'
|
|
||||||
import { ENDPOINTS } from '../api/config'
|
|
||||||
import type { ApiCustomer, ApiPaymentOrRefund, ApiWithdrawal } from '../api/types'
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
DataTable,
|
|
||||||
Input,
|
|
||||||
PageHeader,
|
|
||||||
StatCard,
|
|
||||||
type Column,
|
|
||||||
} from '../components/ui'
|
|
||||||
import { ConnectionRequired } from '../components/business/ConnectionRequired'
|
|
||||||
import { useVoucherSync } from '../accounting/voucherSync'
|
|
||||||
import { formatDateTime, formatMoney, toFa } from '../utils/format'
|
|
||||||
|
|
||||||
const customerFullName = (customer: ApiCustomer): string =>
|
|
||||||
`${customer.f_name ?? ''} ${customer.l_name ?? ''}`.trim() || customer.username || 'بدون نام'
|
|
||||||
|
|
||||||
export function Customers() {
|
|
||||||
const { connected } = useConnection()
|
|
||||||
const { syncCustomerActivity } = useVoucherSync()
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [selected, setSelected] = useState<ApiCustomer | null>(null)
|
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const customers = useApiResource(
|
|
||||||
() =>
|
|
||||||
apiGet<unknown>(ENDPOINTS.CUSTOMERS).then((data) => {
|
|
||||||
if (Array.isArray(data)) return data as ApiCustomer[]
|
|
||||||
const record = data as { results?: ApiCustomer[] }
|
|
||||||
return record.results ?? []
|
|
||||||
}),
|
|
||||||
[connected],
|
|
||||||
connected,
|
|
||||||
)
|
|
||||||
const withdrawals = useApiResource(
|
|
||||||
() =>
|
|
||||||
apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS).then((res) => res.withdrawals ?? []),
|
|
||||||
[connected],
|
|
||||||
connected,
|
|
||||||
)
|
|
||||||
|
|
||||||
const list = customers.data ?? []
|
|
||||||
const customerWithdrawals = useMemo(
|
|
||||||
() => (withdrawals.data ?? []).filter((w) => w.user_type === 'customer'),
|
|
||||||
[withdrawals.data],
|
|
||||||
)
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
const totalBalance = list.reduce((sum, c) => sum + (c.balance || 0), 0)
|
|
||||||
const pending = customerWithdrawals.filter((w) => w.status === 'pending').length
|
|
||||||
return { count: list.length, totalBalance, pending }
|
|
||||||
}, [list, customerWithdrawals])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const query = search.trim().toLowerCase()
|
|
||||||
if (!query) return list
|
|
||||||
return list.filter((c) => {
|
|
||||||
const name = customerFullName(c).toLowerCase()
|
|
||||||
return (
|
|
||||||
name.includes(query) ||
|
|
||||||
(c.username ?? '').toLowerCase().includes(query) ||
|
|
||||||
(c.mobile_number ?? '').includes(query)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [list, search])
|
|
||||||
|
|
||||||
const handleBulkSync = async () => {
|
|
||||||
setSyncBusy(true)
|
|
||||||
setSyncMsg(null)
|
|
||||||
try {
|
|
||||||
const all = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
|
||||||
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
|
|
||||||
).then((res) => res.payments_and_refunds ?? [])
|
|
||||||
const res = await syncCustomerActivity(all)
|
|
||||||
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
|
|
||||||
} catch (err) {
|
|
||||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
|
||||||
} finally {
|
|
||||||
setSyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!connected) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="کاربران (مشتریان)" subtitle="اطلاعات مالی کاربران از اپلیکیشن مشتریان" />
|
|
||||||
<ConnectionRequired />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected) {
|
|
||||||
return <CustomerDetail customer={selected} withdrawals={customerWithdrawals} onBack={() => setSelected(null)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: Array<Column<ApiCustomer>> = [
|
|
||||||
{ key: 'name', header: 'نام کاربر', render: (c) => <span className="font-medium text-slate-800">{customerFullName(c)}</span> },
|
|
||||||
{ key: 'username', header: 'نام کاربری', render: (c) => c.username || '—' },
|
|
||||||
{ key: 'mobile', header: 'موبایل', render: (c) => <span className="tabular-nums">{toFa(c.mobile_number ?? '') || '—'}</span> },
|
|
||||||
{ key: 'iban', header: 'شبا', render: (c) => <span className="font-mono text-xs text-slate-500">{c.iban || '—'}</span> },
|
|
||||||
{
|
|
||||||
key: 'balance',
|
|
||||||
header: 'موجودی کیف پول',
|
|
||||||
align: 'end',
|
|
||||||
render: (c) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(c.balance || 0)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
align: 'end',
|
|
||||||
render: (c) => (
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setSelected(c)}>
|
|
||||||
مشاهده پرداختها
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="کاربران (مشتریان)"
|
|
||||||
subtitle="اطلاعات مالی کاربران از اپلیکیشن مشتریان"
|
|
||||||
actions={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
|
|
||||||
{syncBusy ? 'در حال صدور…' : 'صدور سند همه پرداختها'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
icon="↻"
|
|
||||||
onClick={() => {
|
|
||||||
customers.reload()
|
|
||||||
withdrawals.reload()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
بروزرسانی
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{syncMsg && (
|
|
||||||
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<StatCard label="تعداد کاربران" value={toFa(summary.count)} icon="👥" tone="brand" />
|
|
||||||
<StatCard label="مجموع موجودی کیف پولها" value={formatMoney(summary.totalBalance)} icon="💳" tone="green" />
|
|
||||||
<StatCard label="برداشتهای در انتظار" value={toFa(summary.pending)} icon="⏳" tone={summary.pending > 0 ? 'amber' : 'green'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{customers.error && (
|
|
||||||
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{customers.error}</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<Input
|
|
||||||
placeholder="جستجوی کاربر بر اساس نام، نام کاربری یا موبایل…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="mb-4 max-w-sm"
|
|
||||||
/>
|
|
||||||
{customers.loading ? (
|
|
||||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
|
||||||
) : (
|
|
||||||
<DataTable columns={columns} rows={filtered} rowKey={(c) => c.id} onRowClick={(c) => setSelected(c)} emptyTitle="کاربری یافت نشد" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomerDetail({
|
|
||||||
customer,
|
|
||||||
withdrawals,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
customer: ApiCustomer
|
|
||||||
withdrawals: ApiWithdrawal[]
|
|
||||||
onBack: () => void
|
|
||||||
}) {
|
|
||||||
const { syncCustomerActivity } = useVoucherSync()
|
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
|
||||||
|
|
||||||
const activity = useApiResource(
|
|
||||||
() =>
|
|
||||||
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
|
||||||
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(customer.id),
|
|
||||||
).then((res) => res.payments_and_refunds ?? []),
|
|
||||||
[customer.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
const rows = activity.data ?? []
|
|
||||||
const customerWithdrawals = withdrawals.filter((w) => w.user_id === customer.id)
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
setSyncBusy(true)
|
|
||||||
try {
|
|
||||||
const res = await syncCustomerActivity(rows)
|
|
||||||
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
|
|
||||||
} catch (err) {
|
|
||||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
|
||||||
} finally {
|
|
||||||
setSyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const payments = rows.filter((r) => r.type === 'payment').reduce((s, r) => s + r.amount, 0)
|
|
||||||
const refunds = rows.filter((r) => r.type === 'cancellation').reduce((s, r) => s + r.amount, 0)
|
|
||||||
return { payments, refunds }
|
|
||||||
}, [rows])
|
|
||||||
|
|
||||||
const columns: Array<Column<ApiPaymentOrRefund>> = [
|
|
||||||
{ key: 'date', header: 'تاریخ و زمان', render: (r) => formatDateTime(r.created_at) },
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
header: 'نوع',
|
|
||||||
render: (r) => (
|
|
||||||
<Badge tone={r.type === 'payment' ? 'blue' : 'amber'}>
|
|
||||||
{r.type === 'payment' ? 'پرداخت' : 'لغو/بازپرداخت'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'event', header: 'رویداد/کیف پول', render: (r) => (r.event_name === 'wallet' ? 'کیف پول' : r.event_name) },
|
|
||||||
{ key: 'trace', header: 'شماره پیگیری', render: (r) => <span className="font-mono text-xs text-slate-500">{toFa(r.trace_no) || '—'}</span> },
|
|
||||||
{ key: 'ref', header: 'شماره مرجع', render: (r) => <span className="font-mono text-xs text-slate-500">{toFa(r.ref_num) || '—'}</span> },
|
|
||||||
{
|
|
||||||
key: 'amount',
|
|
||||||
header: 'مبلغ',
|
|
||||||
align: 'end',
|
|
||||||
render: (r) => (
|
|
||||||
<span className={`tabular-nums font-semibold ${r.type === 'payment' ? 'text-emerald-600' : 'text-amber-600'}`}>
|
|
||||||
{formatMoney(r.amount)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button variant="secondary" icon="→" onClick={onBack}>
|
|
||||||
بازگشت
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-800">{customerFullName(customer)}</h1>
|
|
||||||
<p className="text-sm text-slate-500">{toFa(customer.mobile_number ?? '')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="primary" icon="📑" disabled={syncBusy || activity.loading || rows.length === 0} onClick={handleSync}>
|
|
||||||
{syncBusy ? 'در حال صدور…' : 'صدور سند پرداختها'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" icon="↻" onClick={activity.reload}>
|
|
||||||
بروزرسانی
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{syncMsg && (
|
|
||||||
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
|
||||||
<StatCard label="موجودی کیف پول" value={formatMoney(customer.balance || 0)} icon="💳" tone="brand" />
|
|
||||||
<StatCard label="جمع پرداختها" value={formatMoney(totals.payments)} icon="↓" tone="green" />
|
|
||||||
<StatCard label="جمع بازپرداختها" value={formatMoney(totals.refunds)} icon="↑" tone="amber" />
|
|
||||||
<StatCard label="برداشتها" value={toFa(customerWithdrawals.length)} icon="🏧" tone="rose" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activity.error && (
|
|
||||||
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{activity.error}</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<h2 className="mb-3 text-lg font-bold text-slate-800">پرداختها و بازپرداختها</h2>
|
|
||||||
{activity.loading ? (
|
|
||||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
|
||||||
) : (
|
|
||||||
<DataTable columns={columns} rows={rows} rowKey={(r) => r.id} emptyTitle="پرداختی ثبت نشده است" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,10 @@ interface MonthBucket {
|
|||||||
purchases: number
|
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 ? (
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useConnection } from '../connection/ConnectionContext'
|
|
||||||
import { useApiResource } from '../hooks/useApiResource'
|
|
||||||
import { apiGet, asList } from '../api/client'
|
|
||||||
import { ENDPOINTS } from '../api/config'
|
|
||||||
import type { ApiOwner, ApiOwnerTransaction, ApiWithdrawal, WalletTxnType } from '../api/types'
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
DataTable,
|
|
||||||
Input,
|
|
||||||
PageHeader,
|
|
||||||
StatCard,
|
|
||||||
type Column,
|
|
||||||
} from '../components/ui'
|
|
||||||
import { ConnectionRequired } from '../components/business/ConnectionRequired'
|
|
||||||
import { useVoucherSync } from '../accounting/voucherSync'
|
|
||||||
import { formatDateTime, formatMoney, toFa } from '../utils/format'
|
|
||||||
|
|
||||||
const txnTypeLabels: Record<WalletTxnType, string> = {
|
|
||||||
deposit: 'واریز',
|
|
||||||
withdraw: 'برداشت',
|
|
||||||
booking_payment: 'پرداخت رزرو',
|
|
||||||
refund: 'بازپرداخت',
|
|
||||||
}
|
|
||||||
|
|
||||||
const txnTypeTones: Record<WalletTxnType, 'green' | 'rose' | 'blue' | 'amber'> = {
|
|
||||||
deposit: 'green',
|
|
||||||
withdraw: 'rose',
|
|
||||||
booking_payment: 'blue',
|
|
||||||
refund: 'amber',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerFullName = (owner: ApiOwner): string =>
|
|
||||||
`${owner.f_name ?? ''} ${owner.l_name ?? ''}`.trim() || owner.username || 'بدون نام'
|
|
||||||
|
|
||||||
export function Owners() {
|
|
||||||
const { connected } = useConnection()
|
|
||||||
const { syncOwnerTransactions } = useVoucherSync()
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [selected, setSelected] = useState<ApiOwner | null>(null)
|
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const owners = useApiResource(
|
|
||||||
() => apiGet<unknown>(ENDPOINTS.OWNERS).then(asList<ApiOwner>),
|
|
||||||
[connected],
|
|
||||||
connected,
|
|
||||||
)
|
|
||||||
const withdrawals = useApiResource(
|
|
||||||
() =>
|
|
||||||
apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS).then(
|
|
||||||
(res) => res.withdrawals ?? [],
|
|
||||||
),
|
|
||||||
[connected],
|
|
||||||
connected,
|
|
||||||
)
|
|
||||||
|
|
||||||
const list = owners.data ?? []
|
|
||||||
const ownerWithdrawals = useMemo(
|
|
||||||
() => (withdrawals.data ?? []).filter((w) => w.user_type === 'owner'),
|
|
||||||
[withdrawals.data],
|
|
||||||
)
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
const totalBalance = list.reduce((sum, o) => sum + (o.balance || 0), 0)
|
|
||||||
const pending = ownerWithdrawals.filter((w) => w.status === 'pending').length
|
|
||||||
return { count: list.length, totalBalance, pending }
|
|
||||||
}, [list, ownerWithdrawals])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const query = search.trim().toLowerCase()
|
|
||||||
if (!query) return list
|
|
||||||
return list.filter((o) => {
|
|
||||||
const name = ownerFullName(o).toLowerCase()
|
|
||||||
return (
|
|
||||||
name.includes(query) ||
|
|
||||||
(o.username ?? '').toLowerCase().includes(query) ||
|
|
||||||
(o.mobile_number ?? '').includes(query)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [list, search])
|
|
||||||
|
|
||||||
const handleBulkSync = async () => {
|
|
||||||
setSyncBusy(true)
|
|
||||||
setSyncMsg(null)
|
|
||||||
try {
|
|
||||||
const all = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>)
|
|
||||||
const res = await syncOwnerTransactions(all)
|
|
||||||
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
|
|
||||||
} catch (err) {
|
|
||||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
|
||||||
} finally {
|
|
||||||
setSyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!connected) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="مالکان مجموعهها" subtitle="اطلاعات مالی صاحبان مجموعهها از اپلیکیشن مالکان" />
|
|
||||||
<ConnectionRequired />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected) {
|
|
||||||
return <OwnerDetail owner={selected} withdrawals={ownerWithdrawals} onBack={() => setSelected(null)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: Array<Column<ApiOwner>> = [
|
|
||||||
{ key: 'name', header: 'نام مالک', render: (o) => <span className="font-medium text-slate-800">{ownerFullName(o)}</span> },
|
|
||||||
{ key: 'username', header: 'نام کاربری', render: (o) => o.username || '—' },
|
|
||||||
{ key: 'mobile', header: 'موبایل', render: (o) => <span className="tabular-nums">{toFa(o.mobile_number ?? '') || '—'}</span> },
|
|
||||||
{ key: 'venues', header: 'مجموعهها', render: (o) => toFa(o.social_hubs?.length ?? 0) },
|
|
||||||
{ key: 'iban', header: 'شبا', render: (o) => <span className="font-mono text-xs text-slate-500">{o.credit_number || '—'}</span> },
|
|
||||||
{
|
|
||||||
key: 'balance',
|
|
||||||
header: 'موجودی کیف پول',
|
|
||||||
align: 'end',
|
|
||||||
render: (o) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(o.balance || 0)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
align: 'end',
|
|
||||||
render: (o) => (
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setSelected(o)}>
|
|
||||||
مشاهده تراکنشها
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="مالکان مجموعهها"
|
|
||||||
subtitle="اطلاعات مالی صاحبان مجموعهها از اپلیکیشن مالکان"
|
|
||||||
actions={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
|
|
||||||
{syncBusy ? 'در حال صدور…' : 'صدور سند همه تراکنشها'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
icon="↻"
|
|
||||||
onClick={() => {
|
|
||||||
owners.reload()
|
|
||||||
withdrawals.reload()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
بروزرسانی
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{syncMsg && (
|
|
||||||
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<StatCard label="تعداد مالکان" value={toFa(summary.count)} icon="👤" tone="brand" />
|
|
||||||
<StatCard label="مجموع موجودی کیف پولها" value={formatMoney(summary.totalBalance)} icon="💼" tone="green" />
|
|
||||||
<StatCard label="برداشتهای در انتظار" value={toFa(summary.pending)} icon="⏳" tone={summary.pending > 0 ? 'amber' : 'green'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{owners.error && (
|
|
||||||
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{owners.error}</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<Input
|
|
||||||
placeholder="جستجوی مالک بر اساس نام، نام کاربری یا موبایل…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="mb-4 max-w-sm"
|
|
||||||
/>
|
|
||||||
{owners.loading ? (
|
|
||||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
|
||||||
) : (
|
|
||||||
<DataTable columns={columns} rows={filtered} rowKey={(o) => o.id} onRowClick={(o) => setSelected(o)} emptyTitle="مالکی یافت نشد" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OwnerDetail({
|
|
||||||
owner,
|
|
||||||
withdrawals,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
owner: ApiOwner
|
|
||||||
withdrawals: ApiWithdrawal[]
|
|
||||||
onBack: () => void
|
|
||||||
}) {
|
|
||||||
const { syncOwnerTransactions } = useVoucherSync()
|
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
|
||||||
|
|
||||||
const transactions = useApiResource(
|
|
||||||
() => apiGet<unknown>(ENDPOINTS.OWNER_TRANSACTIONS(owner.id)).then(asList<ApiOwnerTransaction>),
|
|
||||||
[owner.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
const rows = transactions.data ?? []
|
|
||||||
const ownerWithdrawals = withdrawals.filter((w) => w.user_id === owner.id)
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
setSyncBusy(true)
|
|
||||||
try {
|
|
||||||
const res = await syncOwnerTransactions(rows)
|
|
||||||
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
|
|
||||||
} catch (err) {
|
|
||||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
|
||||||
} finally {
|
|
||||||
setSyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: Array<Column<ApiOwnerTransaction>> = [
|
|
||||||
{ key: 'date', header: 'تاریخ و زمان', render: (t) => formatDateTime(t.created_at) },
|
|
||||||
{ key: 'type', header: 'نوع', render: (t) => <Badge tone={txnTypeTones[t.type]}>{txnTypeLabels[t.type]}</Badge> },
|
|
||||||
{
|
|
||||||
key: 'amount',
|
|
||||||
header: 'مبلغ',
|
|
||||||
align: 'end',
|
|
||||||
render: (t) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(t.amount)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'وضعیت',
|
|
||||||
render: (t) => (
|
|
||||||
<Badge tone={t.status === 'completed' ? 'green' : t.status === 'pending' ? 'amber' : 'rose'}>
|
|
||||||
{t.status === 'completed' ? 'انجامشده' : t.status === 'pending' ? 'در انتظار' : 'ناموفق'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'desc', header: 'توضیحات', render: (t) => <span className="text-slate-600">{t.description || '—'}</span> },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button variant="secondary" icon="→" onClick={onBack}>
|
|
||||||
بازگشت
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-800">{ownerFullName(owner)}</h1>
|
|
||||||
<p className="text-sm text-slate-500">{toFa(owner.mobile_number ?? '')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="primary" icon="📑" disabled={syncBusy || transactions.loading || rows.length === 0} onClick={handleSync}>
|
|
||||||
{syncBusy ? 'در حال صدور…' : 'صدور سند تراکنشها'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" icon="↻" onClick={transactions.reload}>
|
|
||||||
بروزرسانی
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{syncMsg && (
|
|
||||||
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<StatCard label="موجودی کیف پول" value={formatMoney(owner.balance || 0)} icon="💼" tone="brand" />
|
|
||||||
<StatCard label="تعداد تراکنشها" value={toFa(rows.length)} icon="🔁" tone="green" />
|
|
||||||
<StatCard label="برداشتها" value={toFa(ownerWithdrawals.length)} icon="🏧" tone="amber" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{transactions.error && (
|
|
||||||
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{transactions.error}</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<h2 className="mb-3 text-lg font-bold text-slate-800">گردش حساب کیف پول</h2>
|
|
||||||
{transactions.loading ? (
|
|
||||||
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
|
||||||
) : (
|
|
||||||
<DataTable columns={columns} rows={rows} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است" />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DataTable,
|
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' })}>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<PurchasesLayout />}>
|
||||||
|
<Route index element={<Navigate to="/purchases/suppliers" replace />} />
|
||||||
|
<Route path="suppliers" element={<PurchaseSuppliers />} />
|
||||||
|
<Route
|
||||||
|
path="invoices"
|
||||||
|
element={
|
||||||
<TradeModule
|
<TradeModule
|
||||||
config={{
|
config={{
|
||||||
kind: 'purchase',
|
kind: 'purchase',
|
||||||
title: 'تأمینکنندگان و خرید',
|
title: 'فاکتورهای خرید',
|
||||||
subtitle: 'مدیریت تأمینکنندگان و ثبت فاکتورهای خرید',
|
subtitle: 'ثبت فاکتور خرید از تأمینکنندگان (مالکان)',
|
||||||
partyLabel: 'تأمینکننده',
|
partyLabel: 'تأمینکننده',
|
||||||
partyTabLabel: 'تأمینکنندگان',
|
partyTabLabel: 'تأمینکنندگان',
|
||||||
|
hidePartyTab: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/purchases/suppliers" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { TradeModule } from '../components/business/TradeModule'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { SalesLayout } from './sales/SalesLayout'
|
||||||
|
import { SalesProcess } from './sales/SalesProcess'
|
||||||
|
import { SalesProforma, SalesInvoice, SalesReturn } from './sales/SalesDocuments'
|
||||||
|
import { PartyAdjustmentsPage } from './sales/PartyAdjustmentsPage'
|
||||||
|
import { SalesTypesPage } from './sales/SalesTypesPage'
|
||||||
|
import { SalesReview } from './sales/SalesReview'
|
||||||
|
import { SalesCustomers } from './sales/SalesCustomers'
|
||||||
|
|
||||||
export function Sales() {
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-') })
|
|
||||||
setDraft(null)
|
const saved: TreasuryTxn = {
|
||||||
|
|
||||||
|
...draft,
|
||||||
|
|
||||||
|
id: draft.id || createId('tr-'),
|
||||||
|
|
||||||
|
source: draft.source ?? '',
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
upsertTreasury(saved)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(saved.id))
|
||||||
|
|
||||||
|
const newVoucher = buildTreasuryVoucher(saved, data)
|
||||||
|
|
||||||
|
if (existingVoucher) removeVoucher(existingVoucher.id)
|
||||||
|
|
||||||
|
if (newVoucher) upsertVoucher(newVoucher)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (saved.partyId) {
|
||||||
|
|
||||||
|
const dataAfterSave = {
|
||||||
|
|
||||||
|
...data,
|
||||||
|
|
||||||
|
treasury: [...data.treasury.filter((t) => t.id !== saved.id), saved],
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
||||||
|
|
||||||
|
upsertInvoice(inv)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setDraft(null)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const columns: Array<Column<TreasuryTxn>> = [
|
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" />
|
<Card className="border-brand-200 bg-brand-50/40 p-4">
|
||||||
<StatCard
|
|
||||||
label="مانده خالص"
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
value={formatMoney(summary.net)}
|
|
||||||
icon="💰"
|
<div>
|
||||||
tone={summary.net >= 0 ? 'brand' : 'rose'}
|
|
||||||
/>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Card className="p-4">
|
{syncStats && (
|
||||||
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است" />
|
|
||||||
|
<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="روش">
|
|
||||||
<Select value={draft.method} onChange={(e) => setDraft({ ...draft, method: e.target.value as TreasuryMethod })}>
|
|
||||||
{Object.entries(methodLabels).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="طرف حساب">
|
|
||||||
<Select
|
|
||||||
value={draft.partyId ?? ''}
|
|
||||||
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
|
|
||||||
>
|
|
||||||
<option value="">— بدون طرف حساب —</option>
|
|
||||||
{data.parties.map((party) => (
|
|
||||||
<option key={party.id} value={party.id}>
|
|
||||||
{party.name} ({party.kind === 'customer' ? 'مشتری' : 'تأمینکننده'})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="مبلغ (تومان)">
|
|
||||||
<Input type="number" value={draft.amount} onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })} />
|
|
||||||
</Field>
|
|
||||||
<Field label="شماره مرجع / چک">
|
|
||||||
<Input value={draft.reference} onChange={(e) => setDraft({ ...draft, reference: e.target.value })} />
|
|
||||||
</Field>
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<Field label="توضیحات">
|
|
||||||
<Textarea rows={2} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
|
<pre className="whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm text-slate-700">
|
||||||
|
|
||||||
|
{draft.description}
|
||||||
|
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<dt className="text-slate-400">طرف حساب</dt>
|
||||||
|
|
||||||
|
<dd className="font-medium">{partyName(draft.partyId)}</dd>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<dt className="text-slate-400">مبلغ</dt>
|
||||||
|
|
||||||
|
<dd className="font-semibold tabular-nums">{formatMoney(draft.amount)}</dd>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<dt className="text-slate-400">تاریخ</dt>
|
||||||
|
|
||||||
|
<dd>{formatDate(draft.date)}</dd>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<dt className="text-slate-400">مرجع</dt>
|
||||||
|
|
||||||
|
<dd className="font-mono">{draft.reference || '—'}</dd>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
|
||||||
|
<>
|
||||||
|
|
||||||
|
<Field label="تاریخ">
|
||||||
|
|
||||||
|
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<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>
|
|
||||||
|
</>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</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)
|
|
||||||
setToDelete(null)
|
if (toDelete) {
|
||||||
}}
|
|
||||||
/>
|
const voucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(toDelete.id))
|
||||||
</div>
|
|
||||||
)
|
if (voucher) removeVoucher(voucher.id)
|
||||||
|
|
||||||
|
removeTreasury(toDelete.id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (toDelete.partyId) {
|
||||||
|
|
||||||
|
const dataAfterDelete = {
|
||||||
|
|
||||||
|
...data,
|
||||||
|
|
||||||
|
treasury: data.treasury.filter((t) => t.id !== toDelete.id),
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const inv of reconcileInvoicePaymentStatus(toDelete.partyId, dataAfterDelete)) {
|
||||||
|
|
||||||
|
upsertInvoice(inv)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
setToDelete(null)
|
||||||
|
|
||||||
|
}}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DataTable,
|
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 })} />
|
||||||
|
|||||||
14
src/pages/purchases/PurchaseSuppliers.tsx
Normal file
14
src/pages/purchases/PurchaseSuppliers.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { FunZonePartySection } from '../../parties/FunZonePartySection'
|
||||||
|
|
||||||
|
export function PurchaseSuppliers() {
|
||||||
|
return (
|
||||||
|
<FunZonePartySection
|
||||||
|
config={{
|
||||||
|
kind: 'supplier',
|
||||||
|
title: 'تأمینکنندگان',
|
||||||
|
subtitle: 'مالکان مجموعهها از اپلیکیشن مالکان — منبع داده فانزون، مشابه «تأمینکنندگان» در سپیدار',
|
||||||
|
externalSource: 'funzone_owner',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/pages/purchases/PurchasesLayout.tsx
Normal file
50
src/pages/purchases/PurchasesLayout.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
|
import { PageHeader } from '../../components/ui'
|
||||||
|
|
||||||
|
const purchaseNavItems = [
|
||||||
|
{ to: '/purchases/suppliers', label: 'تأمینکنندگان', icon: '🏢', end: false },
|
||||||
|
{ to: '/purchases/invoices', label: 'فاکتورهای خرید', icon: '🛒', end: false },
|
||||||
|
{ to: '/treasury', label: 'پرداخت به تأمینکنندگان', icon: '💰', end: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PurchasesLayout() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="تأمینکنندگان و خرید"
|
||||||
|
subtitle="مدیریت تأمینکنندگان (مالکان) و ثبت فاکتورهای خرید"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row">
|
||||||
|
<nav className="lg:w-56 shrink-0">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-2 shadow-sm">
|
||||||
|
<p className="px-3 py-2 text-xs font-semibold text-slate-400">زیرمنو خرید</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{purchaseNavItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand-50 text-brand-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
238
src/pages/sales/PartyAdjustmentsPage.tsx
Normal file
238
src/pages/sales/PartyAdjustmentsPage.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
JalaliDateInput,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Textarea,
|
||||||
|
type Column,
|
||||||
|
} from '../../components/ui'
|
||||||
|
import type { AdjustmentKind, PartyAdjustment } from '../../types'
|
||||||
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
|
import { formatDate, formatMoney, todayISO, toFa } from '../../utils/format'
|
||||||
|
import { customers, partyReceivable, buildAdjustmentVoucher, reconcileInvoicePaymentStatus, voucherSourceForAdjustment } from './salesUtils'
|
||||||
|
|
||||||
|
const kindLabels: Record<AdjustmentKind, string> = {
|
||||||
|
debit: 'بدهکار',
|
||||||
|
credit: 'بستانکار',
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindTones: Record<AdjustmentKind, 'rose' | 'green'> = {
|
||||||
|
debit: 'rose',
|
||||||
|
credit: 'green',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PartyAdjustmentsPage() {
|
||||||
|
const { data, upsertPartyAdjustment, removePartyAdjustment, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||||
|
const [draft, setDraft] = useState<PartyAdjustment | null>(null)
|
||||||
|
const [toDelete, setToDelete] = useState<PartyAdjustment | null>(null)
|
||||||
|
|
||||||
|
const customerList = useMemo(
|
||||||
|
() => customers(data.parties).filter((p) => p.id),
|
||||||
|
[data.parties],
|
||||||
|
)
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...data.partyAdjustments].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
|
||||||
|
[data.partyAdjustments],
|
||||||
|
)
|
||||||
|
|
||||||
|
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||||
|
|
||||||
|
const createDraft = (): PartyAdjustment => ({
|
||||||
|
id: '',
|
||||||
|
number: nextNumber(data.partyAdjustments),
|
||||||
|
partyId: customerList[0]?.id ?? '',
|
||||||
|
kind: 'debit',
|
||||||
|
date: todayISO(),
|
||||||
|
amount: 0,
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!draft || !draft.partyId || draft.amount <= 0) return
|
||||||
|
const saved: PartyAdjustment = { ...draft, id: draft.id || createId('adj-') }
|
||||||
|
|
||||||
|
upsertPartyAdjustment(saved)
|
||||||
|
|
||||||
|
const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForAdjustment(saved.id))
|
||||||
|
const newVoucher = buildAdjustmentVoucher(saved, data)
|
||||||
|
if (existingVoucher) removeVoucher(existingVoucher.id)
|
||||||
|
if (newVoucher) upsertVoucher(newVoucher)
|
||||||
|
|
||||||
|
const dataAfterSave = {
|
||||||
|
...data,
|
||||||
|
partyAdjustments: [...data.partyAdjustments.filter((a) => a.id !== saved.id), saved],
|
||||||
|
}
|
||||||
|
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
||||||
|
upsertInvoice(inv)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Array<Column<PartyAdjustment>> = [
|
||||||
|
{ key: 'number', header: 'شماره', render: (a) => <span className="font-mono">{toFa(a.number)}</span> },
|
||||||
|
{ key: 'date', header: 'تاریخ', render: (a) => formatDate(a.date) },
|
||||||
|
{ key: 'party', header: 'مشتری', render: (a) => partyName(a.partyId) },
|
||||||
|
{
|
||||||
|
key: 'kind',
|
||||||
|
header: 'نوع',
|
||||||
|
render: (a) => <Badge tone={kindTones[a.kind]}>{kindLabels[a.kind]}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
header: 'مبلغ',
|
||||||
|
align: 'end',
|
||||||
|
render: (a) => (
|
||||||
|
<span className={`tabular-nums font-semibold ${a.kind === 'debit' ? 'text-rose-600' : 'text-emerald-600'}`}>
|
||||||
|
{a.kind === 'debit' ? '+' : '−'} {formatMoney(a.amount)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'desc', header: 'شرح', render: (a) => <span className="text-slate-500">{a.description || '—'}</span> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'end',
|
||||||
|
render: (a) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...a })}>
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(a)}>
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-slate-800">اعلامیه بدهکار/بستانکار</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
اصلاح مانده حساب مشتری بدون صدور فاکتور — بدهکار افزایش مطالبات، بستانکار کاهش مطالبات
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button icon="+" onClick={() => setDraft(createDraft())} disabled={customerList.length === 0}>
|
||||||
|
اعلامیه جدید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={sorted}
|
||||||
|
rowKey={(a) => a.id}
|
||||||
|
emptyTitle="اعلامیهای ثبت نشده است"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={draft !== null}
|
||||||
|
title={draft?.id ? `ویرایش اعلامیه #${toFa(draft.number)}` : 'اعلامیه جدید'}
|
||||||
|
onClose={() => setDraft(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{draft && (
|
||||||
|
<p className="me-auto text-sm text-slate-500">
|
||||||
|
مانده فعلی مشتری:{' '}
|
||||||
|
<span className="tabular-nums font-semibold text-slate-700">
|
||||||
|
{formatMoney(partyReceivable(draft.partyId, data))}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>ذخیره</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{draft && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="شماره">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={draft.number}
|
||||||
|
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="تاریخ">
|
||||||
|
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="مشتری">
|
||||||
|
<Select value={draft.partyId} onChange={(e) => setDraft({ ...draft, partyId: e.target.value })}>
|
||||||
|
{customerList.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="نوع اعلامیه">
|
||||||
|
<Select
|
||||||
|
value={draft.kind}
|
||||||
|
onChange={(e) => setDraft({ ...draft, kind: e.target.value as AdjustmentKind })}
|
||||||
|
>
|
||||||
|
{Object.entries(kindLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="مبلغ (تومان)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={draft.amount}
|
||||||
|
onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Field label="شرح">
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toDelete !== null}
|
||||||
|
title="حذف اعلامیه"
|
||||||
|
message={`اعلامیه شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
|
||||||
|
onCancel={() => setToDelete(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (toDelete) {
|
||||||
|
const voucher = data.vouchers.find((v) => v.source === voucherSourceForAdjustment(toDelete.id))
|
||||||
|
if (voucher) removeVoucher(voucher.id)
|
||||||
|
removePartyAdjustment(toDelete.id)
|
||||||
|
|
||||||
|
const dataAfterDelete = {
|
||||||
|
...data,
|
||||||
|
partyAdjustments: data.partyAdjustments.filter((a) => a.id !== toDelete.id),
|
||||||
|
}
|
||||||
|
for (const inv of reconcileInvoicePaymentStatus(toDelete.partyId, dataAfterDelete)) {
|
||||||
|
upsertInvoice(inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setToDelete(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
src/pages/sales/PendingWithdrawalsPanel.tsx
Normal file
116
src/pages/sales/PendingWithdrawalsPanel.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { ApiWithdrawal } from '../../api/types'
|
||||||
|
import type { Invoice, Party } from '../../types'
|
||||||
|
import { Badge, Button, Card, DataTable, StatCard, type Column } from '../../components/ui'
|
||||||
|
import { formatDateTime, formatMoney, toFa } from '../../utils/format'
|
||||||
|
import { invoiceForWithdrawal } from './usePendingCustomerWithdrawals'
|
||||||
|
|
||||||
|
interface PendingWithdrawalsPanelProps {
|
||||||
|
withdrawals: ApiWithdrawal[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
returnInvoices: Invoice[]
|
||||||
|
customerList: Party[]
|
||||||
|
onReload: () => void
|
||||||
|
onCreateReturn: (withdrawal: ApiWithdrawal) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingWithdrawalsPanel({
|
||||||
|
withdrawals,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
returnInvoices,
|
||||||
|
customerList,
|
||||||
|
onReload,
|
||||||
|
onCreateReturn,
|
||||||
|
}: PendingWithdrawalsPanelProps) {
|
||||||
|
const openWithdrawals = useMemo(
|
||||||
|
() => withdrawals.filter((w) => !invoiceForWithdrawal(returnInvoices, w.id)),
|
||||||
|
[withdrawals, returnInvoices],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns: Array<Column<ApiWithdrawal>> = [
|
||||||
|
{ key: 'customer', header: 'مشتری', render: (w) => <span className="font-medium">{w.user_name}</span> },
|
||||||
|
{ key: 'username', header: 'نام کاربری', render: (w) => w.username || '—' },
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
header: 'مبلغ',
|
||||||
|
align: 'end',
|
||||||
|
render: (w) => <span className="tabular-nums font-semibold">{formatMoney(w.amount)}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'iban', header: 'شبا', render: (w) => <span className="font-mono text-xs">{w.iban || '—'}</span> },
|
||||||
|
{
|
||||||
|
key: 'date',
|
||||||
|
header: 'تاریخ درخواست',
|
||||||
|
render: (w) => formatDateTime(w.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'وضعیت',
|
||||||
|
render: () => <Badge tone="amber">در انتظار</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'end',
|
||||||
|
render: (w) => {
|
||||||
|
const party = customerList.find((p) => p.externalId === w.user_id)
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!party}
|
||||||
|
onClick={() => onCreateReturn(w)}
|
||||||
|
title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
|
||||||
|
>
|
||||||
|
ثبت فاکتور برگشتی
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-amber-200 bg-amber-50/30 p-4">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
درخواستهای برداشت کیف پول مشتریان از بخش حسابداری ادمین — برای هر مورد فاکتور برگشتی صادر کنید
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
|
||||||
|
بروزرسانی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<StatCard
|
||||||
|
label="برداشت در انتظار"
|
||||||
|
value={toFa(openWithdrawals.length)}
|
||||||
|
icon="⏳"
|
||||||
|
tone={openWithdrawals.length > 0 ? 'amber' : 'green'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="جمع مبالغ"
|
||||||
|
value={formatMoney(openWithdrawals.reduce((s, w) => s + w.amount, 0))}
|
||||||
|
icon="💳"
|
||||||
|
tone="brand"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-slate-400">در حال بارگذاری برداشتها…</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={openWithdrawals}
|
||||||
|
rowKey={(w) => w.id}
|
||||||
|
emptyTitle="برداشت در انتظاری ثبت نشده است"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/pages/sales/SalesCustomers.tsx
Normal file
14
src/pages/sales/SalesCustomers.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { FunZonePartySection } from '../../parties/FunZonePartySection'
|
||||||
|
|
||||||
|
export function SalesCustomers() {
|
||||||
|
return (
|
||||||
|
<FunZonePartySection
|
||||||
|
config={{
|
||||||
|
kind: 'customer',
|
||||||
|
title: 'مشتریان',
|
||||||
|
subtitle: 'لیست کاربران اپلیکیشن مشتریان — منبع داده فانزون، مشابه «مشتریان» در سپیدار',
|
||||||
|
externalSource: 'funzone_customer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
595
src/pages/sales/SalesDocumentSection.tsx
Normal file
595
src/pages/sales/SalesDocumentSection.tsx
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
import { useMemo, useState, useEffect } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
JalaliDateInput,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Textarea,
|
||||||
|
type Column,
|
||||||
|
} from '../../components/ui'
|
||||||
|
import type { Invoice, InvoiceLine, InvoiceStatus, SalesDocumentType } from '../../types'
|
||||||
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
|
import { formatDate, formatMoney, todayISO, toFa } from '../../utils/format'
|
||||||
|
import { invoiceTotals, lineTotals } from '../../utils/accounting'
|
||||||
|
import {
|
||||||
|
byDocumentType,
|
||||||
|
customers,
|
||||||
|
documentTypeLabels,
|
||||||
|
isWithdrawalReturn,
|
||||||
|
reconcileInvoicePaymentStatus,
|
||||||
|
statusLabels,
|
||||||
|
} from './salesUtils'
|
||||||
|
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
|
||||||
|
import {
|
||||||
|
usePendingCustomerWithdrawals,
|
||||||
|
withdrawalNoteTag,
|
||||||
|
} from './usePendingCustomerWithdrawals'
|
||||||
|
import { PendingWithdrawalsPanel } from './PendingWithdrawalsPanel'
|
||||||
|
import type { ApiWithdrawal } from '../../api/types'
|
||||||
|
|
||||||
|
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
|
||||||
|
draft: 'amber',
|
||||||
|
confirmed: 'blue',
|
||||||
|
paid: 'green',
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLine = (): InvoiceLine => ({
|
||||||
|
id: createId('il-'),
|
||||||
|
productId: '',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: 0,
|
||||||
|
discount: 0,
|
||||||
|
taxRate: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface SalesDocumentSectionProps {
|
||||||
|
documentType: SalesDocumentType
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
/** Allow converting from proforma when creating sales invoice */
|
||||||
|
allowProformaConversion?: boolean
|
||||||
|
/** Allow linking to original invoice for returns */
|
||||||
|
allowRelatedInvoice?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesDocumentSection({
|
||||||
|
documentType,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
allowProformaConversion = false,
|
||||||
|
allowRelatedInvoice = false,
|
||||||
|
}: SalesDocumentSectionProps) {
|
||||||
|
const { data, upsertInvoice, removeInvoice, upsertProduct, upsertVoucher, removeVoucher } = useStore()
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [draft, setDraft] = useState<Invoice | null>(null)
|
||||||
|
const [toDelete, setToDelete] = useState<Invoice | null>(null)
|
||||||
|
const [previousDraft, setPreviousDraft] = useState<Invoice | null>(null)
|
||||||
|
|
||||||
|
const customerList = useMemo(
|
||||||
|
() => customers(data.parties).filter((p) => p.id),
|
||||||
|
[data.parties],
|
||||||
|
)
|
||||||
|
const documents = useMemo(() => byDocumentType(data.invoices, documentType), [data.invoices, documentType])
|
||||||
|
const proformas = useMemo(() => byDocumentType(data.invoices, 'proforma'), [data.invoices])
|
||||||
|
const saleInvoices = useMemo(() => byDocumentType(data.invoices, 'invoice'), [data.invoices])
|
||||||
|
const activeSalesTypes = useMemo(() => data.salesTypes.filter((t) => t.active), [data.salesTypes])
|
||||||
|
const isReturnSection = documentType === 'return'
|
||||||
|
const {
|
||||||
|
withdrawals: pendingWithdrawals,
|
||||||
|
loading: pendingLoading,
|
||||||
|
error: pendingError,
|
||||||
|
reload: reloadPending,
|
||||||
|
} = usePendingCustomerWithdrawals(isReturnSection)
|
||||||
|
|
||||||
|
const returnDocuments = useMemo(
|
||||||
|
() => (isReturnSection ? documents : []),
|
||||||
|
[documents, isReturnSection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||||
|
const salesTypeName = (id?: string) =>
|
||||||
|
id ? data.salesTypes.find((t) => t.id === id)?.name ?? '—' : '—'
|
||||||
|
|
||||||
|
const createDraft = (fromProforma?: Invoice): Invoice => ({
|
||||||
|
id: '',
|
||||||
|
kind: 'sale',
|
||||||
|
documentType,
|
||||||
|
number: nextNumber(documents),
|
||||||
|
partyId: fromProforma?.partyId ?? customerList[0]?.id ?? '',
|
||||||
|
salesTypeId: fromProforma?.salesTypeId,
|
||||||
|
relatedInvoiceId: fromProforma?.id,
|
||||||
|
date: todayISO(),
|
||||||
|
status: 'draft',
|
||||||
|
note: fromProforma ? `تبدیل از پیشفاکتور #${fromProforma.number}` : '',
|
||||||
|
lines: fromProforma
|
||||||
|
? fromProforma.lines.map((l) => ({ ...l, id: createId('il-') }))
|
||||||
|
: [newLine()],
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDraft = (invoice?: Invoice) => {
|
||||||
|
if (invoice) {
|
||||||
|
setPreviousDraft({ ...invoice, lines: invoice.lines.map((l) => ({ ...l })) })
|
||||||
|
setDraft({ ...invoice, lines: invoice.lines.map((l) => ({ ...l })) })
|
||||||
|
} else {
|
||||||
|
setPreviousDraft(null)
|
||||||
|
setDraft(createDraft())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLine = (lineId: string, patch: Partial<InvoiceLine>) => {
|
||||||
|
if (!draft) return
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
lines: draft.lines.map((line) => (line.id === lineId ? { ...line, ...patch } : line)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectProduct = (lineId: string, productId: string) => {
|
||||||
|
const product = data.products.find((p) => p.id === productId)
|
||||||
|
updateLine(lineId, {
|
||||||
|
productId,
|
||||||
|
unitPrice: product?.salePrice ?? 0,
|
||||||
|
taxRate: data.settings.taxRate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromProforma = (proformaId: string) => {
|
||||||
|
const proforma = proformas.find((p) => p.id === proformaId)
|
||||||
|
if (proforma) {
|
||||||
|
setPreviousDraft(null)
|
||||||
|
setDraft(createDraft(proforma))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fromProformaId = (location.state as { fromProformaId?: string } | null)?.fromProformaId
|
||||||
|
if (fromProformaId && documentType === 'invoice') {
|
||||||
|
loadFromProforma(fromProformaId)
|
||||||
|
navigate(location.pathname, { replace: true, state: null })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadFromInvoice = (invoiceId: string) => {
|
||||||
|
const source = saleInvoices.find((i) => i.id === invoiceId)
|
||||||
|
if (!source) return
|
||||||
|
setPreviousDraft(null)
|
||||||
|
setDraft({
|
||||||
|
...createDraft(),
|
||||||
|
partyId: source.partyId,
|
||||||
|
salesTypeId: source.salesTypeId,
|
||||||
|
relatedInvoiceId: source.id,
|
||||||
|
note: `برگشت از فاکتور #${source.number}`,
|
||||||
|
lines: source.lines.map((l) => ({ ...l, id: createId('il-') })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromWithdrawal = (withdrawal: ApiWithdrawal) => {
|
||||||
|
const party = customerList.find((p) => p.externalId === withdrawal.user_id)
|
||||||
|
if (!party) return
|
||||||
|
setPreviousDraft(null)
|
||||||
|
setDraft({
|
||||||
|
...createDraft(),
|
||||||
|
partyId: party.id,
|
||||||
|
date: withdrawal.created_at.slice(0, 10),
|
||||||
|
status: 'draft',
|
||||||
|
note: `${withdrawalNoteTag(withdrawal.id)}\nبرداشت کیف پول مشتری — ${withdrawal.user_name}${withdrawal.iban ? `\nشبا: ${withdrawal.iban}` : ''}`,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: createId('il-'),
|
||||||
|
productId: '',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: withdrawal.amount,
|
||||||
|
discount: 0,
|
||||||
|
taxRate: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!draft || !draft.partyId) return
|
||||||
|
const fromWithdrawal = isWithdrawalReturn(draft)
|
||||||
|
const cleanLines = draft.lines.filter((line) => {
|
||||||
|
if (fromWithdrawal && line.unitPrice > 0) return true
|
||||||
|
return Boolean(line.productId) && line.quantity > 0
|
||||||
|
})
|
||||||
|
if (cleanLines.length === 0) return
|
||||||
|
|
||||||
|
const saved: Invoice = {
|
||||||
|
...draft,
|
||||||
|
documentType,
|
||||||
|
kind: 'sale',
|
||||||
|
lines: cleanLines,
|
||||||
|
id: draft.id || createId('inv-'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const effects = invoiceSideEffects(saved, previousDraft, data)
|
||||||
|
for (const product of effects.products) {
|
||||||
|
const original = data.products.find((p) => p.id === product.id)
|
||||||
|
if (original && original.stock !== product.stock) upsertProduct(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertInvoice(saved)
|
||||||
|
|
||||||
|
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||||
|
if (effects.voucher) upsertVoucher(effects.voucher)
|
||||||
|
|
||||||
|
if (saved.partyId && saved.status !== 'draft') {
|
||||||
|
const dataAfterSave = {
|
||||||
|
...data,
|
||||||
|
invoices: [...data.invoices.filter((i) => i.id !== saved.id), saved],
|
||||||
|
}
|
||||||
|
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
||||||
|
upsertInvoice(inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowProformaConversion && saved.relatedInvoiceId && documentType === 'invoice') {
|
||||||
|
const proforma = proformas.find((p) => p.id === saved.relatedInvoiceId)
|
||||||
|
if (proforma && proforma.status === 'draft') {
|
||||||
|
upsertInvoice({ ...proforma, status: 'confirmed', note: `${proforma.note}\nتبدیل به فاکتور #${saved.number}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft(null)
|
||||||
|
setPreviousDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = draft ? invoiceTotals(draft) : null
|
||||||
|
|
||||||
|
const columns: Array<Column<Invoice>> = [
|
||||||
|
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
||||||
|
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
||||||
|
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'نوع فروش',
|
||||||
|
render: (i) => <span className="text-slate-500">{salesTypeName(i.salesTypeId)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'net',
|
||||||
|
header: 'مبلغ',
|
||||||
|
align: 'end',
|
||||||
|
render: (i) => <span className="tabular-nums">{formatMoney(invoiceTotals(i).net)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'وضعیت',
|
||||||
|
render: (i) => <Badge tone={statusTones[i.status]}>{statusLabels[i.status]}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'end',
|
||||||
|
render: (i) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{documentType === 'proforma' && i.status === 'draft' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigate('/sales/invoice', { state: { fromProformaId: i.id } })}
|
||||||
|
>
|
||||||
|
تبدیل
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => openDraft(i)}>
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(i)}>
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isReturnSection && (
|
||||||
|
<PendingWithdrawalsPanel
|
||||||
|
withdrawals={pendingWithdrawals}
|
||||||
|
loading={pendingLoading}
|
||||||
|
error={pendingError}
|
||||||
|
returnInvoices={returnDocuments}
|
||||||
|
customerList={customerList}
|
||||||
|
onReload={() => void reloadPending()}
|
||||||
|
onCreateReturn={loadFromWithdrawal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-slate-800">{title}</h2>
|
||||||
|
<p className="text-sm text-slate-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{customerList.length === 0 && (
|
||||||
|
<Link to="/sales/customers" className="text-sm text-brand-600 hover:underline">
|
||||||
|
ابتدا مشتری تعریف کنید
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Button icon="+" onClick={() => openDraft()} disabled={customerList.length === 0}>
|
||||||
|
{documentTypeLabels[documentType]} جدید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customerList.length === 0 ? (
|
||||||
|
<p className="py-10 text-center text-sm text-slate-400">
|
||||||
|
ابتدا در بخش{' '}
|
||||||
|
<Link to="/sales/customers" className="text-brand-600 hover:underline">
|
||||||
|
مشتریان
|
||||||
|
</Link>{' '}
|
||||||
|
حداقل یک مشتری تعریف کنید.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={documents}
|
||||||
|
rowKey={(i) => i.id}
|
||||||
|
emptyTitle={`${documentTypeLabels[documentType]}ی ثبت نشده است`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={draft !== null}
|
||||||
|
title={
|
||||||
|
draft?.id
|
||||||
|
? `ویرایش ${documentTypeLabels[documentType]} #${toFa(draft.number)}`
|
||||||
|
: `${documentTypeLabels[documentType]} جدید`
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
setDraft(null)
|
||||||
|
setPreviousDraft(null)
|
||||||
|
}}
|
||||||
|
size="xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{totals && (
|
||||||
|
<div className="me-auto flex flex-wrap items-center gap-4 text-sm text-slate-500">
|
||||||
|
<span>
|
||||||
|
تخفیف: <span className="tabular-nums text-slate-700">{formatMoney(totals.discount)}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
مالیات: <span className="tabular-nums text-slate-700">{formatMoney(totals.tax)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-slate-800">
|
||||||
|
قابل پرداخت:{' '}
|
||||||
|
<span className="tabular-nums text-brand-700">{formatMoney(totals.net)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(null)
|
||||||
|
setPreviousDraft(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>ذخیره</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{draft && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{allowProformaConversion && !draft.id && proformas.length > 0 && (
|
||||||
|
<Field label="تبدیل از پیشفاکتور">
|
||||||
|
<Select value="" onChange={(e) => e.target.value && loadFromProforma(e.target.value)}>
|
||||||
|
<option value="">— انتخاب پیشفاکتور —</option>
|
||||||
|
{proformas
|
||||||
|
.filter((p) => p.status === 'draft')
|
||||||
|
.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
#{toFa(p.number)} · {partyName(p.partyId)} · {formatMoney(invoiceTotals(p).net)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allowRelatedInvoice && !draft.id && saleInvoices.length > 0 && (
|
||||||
|
<Field label="برگشت از فاکتور">
|
||||||
|
<Select value="" onChange={(e) => e.target.value && loadFromInvoice(e.target.value)}>
|
||||||
|
<option value="">— انتخاب فاکتور فروش —</option>
|
||||||
|
{saleInvoices
|
||||||
|
.filter((i) => i.status !== 'draft')
|
||||||
|
.map((i) => (
|
||||||
|
<option key={i.id} value={i.id}>
|
||||||
|
#{toFa(i.number)} · {partyName(i.partyId)} · {formatMoney(invoiceTotals(i).net)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field label="شماره">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={draft.number}
|
||||||
|
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="تاریخ">
|
||||||
|
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="مشتری">
|
||||||
|
<Select value={draft.partyId} onChange={(e) => setDraft({ ...draft, partyId: e.target.value })}>
|
||||||
|
{customerList.map((party) => (
|
||||||
|
<option key={party.id} value={party.id}>
|
||||||
|
{party.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="وضعیت">
|
||||||
|
<Select
|
||||||
|
value={draft.status}
|
||||||
|
onChange={(e) => setDraft({ ...draft, status: e.target.value as InvoiceStatus })}
|
||||||
|
>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
{activeSalesTypes.length > 0 && (
|
||||||
|
<Field label="نوع فروش">
|
||||||
|
<Select
|
||||||
|
value={draft.salesTypeId ?? ''}
|
||||||
|
onChange={(e) => setDraft({ ...draft, salesTypeId: e.target.value || undefined })}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{activeSalesTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-start font-medium">کالا</th>
|
||||||
|
<th className="px-3 py-2 text-end font-medium">تعداد</th>
|
||||||
|
<th className="px-3 py-2 text-end font-medium">قیمت واحد</th>
|
||||||
|
<th className="px-3 py-2 text-end font-medium">تخفیف</th>
|
||||||
|
<th className="px-3 py-2 text-end font-medium">مالیات٪</th>
|
||||||
|
<th className="px-3 py-2 text-end font-medium">جمع</th>
|
||||||
|
<th className="px-3 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{draft.lines.map((line) => (
|
||||||
|
<tr key={line.id}>
|
||||||
|
<td className="min-w-[180px] px-2 py-1.5">
|
||||||
|
<Select value={line.productId} onChange={(e) => onSelectProduct(line.id, e.target.value)}>
|
||||||
|
<option value="">انتخاب کالا…</option>
|
||||||
|
{data.products.map((product) => (
|
||||||
|
<option key={product.id} value={product.id}>
|
||||||
|
{product.name} (موجودی: {toFa(product.stock)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</td>
|
||||||
|
<td className="w-24 px-2 py-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="text-end"
|
||||||
|
value={line.quantity}
|
||||||
|
onChange={(e) => updateLine(line.id, { quantity: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="w-32 px-2 py-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="text-end"
|
||||||
|
value={line.unitPrice}
|
||||||
|
onChange={(e) => updateLine(line.id, { unitPrice: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="w-28 px-2 py-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="text-end"
|
||||||
|
value={line.discount}
|
||||||
|
onChange={(e) => updateLine(line.id, { discount: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="w-20 px-2 py-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="text-end"
|
||||||
|
value={line.taxRate}
|
||||||
|
onChange={(e) => updateLine(line.id, { taxRate: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-end tabular-nums text-slate-700">
|
||||||
|
{formatMoney(lineTotals(line).net)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({ ...draft, lines: draft.lines.filter((l) => l.id !== line.id) })
|
||||||
|
}
|
||||||
|
className="text-slate-400 transition hover:text-rose-600"
|
||||||
|
aria-label="حذف ردیف"
|
||||||
|
disabled={draft.lines.length <= 1}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="+"
|
||||||
|
onClick={() => setDraft({ ...draft, lines: [...draft.lines, newLine()] })}
|
||||||
|
>
|
||||||
|
افزودن ردیف
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Field label="توضیحات">
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
value={draft.note}
|
||||||
|
onChange={(e) => setDraft({ ...draft, note: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{documentType !== 'proforma' && draft.status !== 'draft' && (
|
||||||
|
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
|
{isWithdrawalReturn(draft)
|
||||||
|
? 'برداشت کیف پول — موجودی انبار تغییر نمیکند؛ سند حسابداری برداشت ثبت میشود.'
|
||||||
|
: 'با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toDelete !== null}
|
||||||
|
title={`حذف ${documentTypeLabels[documentType]}`}
|
||||||
|
message={`${documentTypeLabels[documentType]} شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
|
||||||
|
onCancel={() => setToDelete(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (toDelete) {
|
||||||
|
const effects = invoiceDeleteEffects(toDelete, data)
|
||||||
|
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||||
|
for (const product of effects.products) {
|
||||||
|
const original = data.products.find((p) => p.id === product.id)
|
||||||
|
if (original && original.stock !== product.stock) upsertProduct(product)
|
||||||
|
}
|
||||||
|
removeInvoice(toDelete.id)
|
||||||
|
}
|
||||||
|
setToDelete(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/pages/sales/SalesDocuments.tsx
Normal file
33
src/pages/sales/SalesDocuments.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { SalesDocumentSection } from './SalesDocumentSection'
|
||||||
|
|
||||||
|
export function SalesProforma() {
|
||||||
|
return (
|
||||||
|
<SalesDocumentSection
|
||||||
|
documentType="proforma"
|
||||||
|
title="پیشفاکتور جدید"
|
||||||
|
subtitle="صدور پیشنهاد قیمت — بدون تأثیر بر موجودی و حسابداری تا تبدیل به فاکتور"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesInvoice() {
|
||||||
|
return (
|
||||||
|
<SalesDocumentSection
|
||||||
|
documentType="invoice"
|
||||||
|
title="فاکتور فروش جدید"
|
||||||
|
subtitle="ثبت فروش قطعی — کاهش موجودی انبار و صدور سند حسابداری"
|
||||||
|
allowProformaConversion
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesReturn() {
|
||||||
|
return (
|
||||||
|
<SalesDocumentSection
|
||||||
|
documentType="return"
|
||||||
|
title="فاکتور برگشتی جدید"
|
||||||
|
subtitle="برگشت کالا یا ثبت فاکتور برگشتی برای برداشتهای در انتظار مشتریان (از پنل مدیریت)"
|
||||||
|
allowRelatedInvoice
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/pages/sales/SalesLayout.tsx
Normal file
45
src/pages/sales/SalesLayout.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
|
import { PageHeader } from '../../components/ui'
|
||||||
|
import { salesNavItems } from './salesNavigation'
|
||||||
|
|
||||||
|
export function SalesLayout() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="مشتریان و فروش"
|
||||||
|
subtitle="مدیریت فرایند فروش، فاکتورها، اعلامیهها و مرور فروش"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row">
|
||||||
|
<nav className="lg:w-56 shrink-0">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-2 shadow-sm">
|
||||||
|
<p className="px-3 py-2 text-xs font-semibold text-slate-400">زیرمنو فروش</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{salesNavItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand-50 text-brand-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/pages/sales/SalesProcess.tsx
Normal file
110
src/pages/sales/SalesProcess.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import { Card, StatCard } from '../../components/ui'
|
||||||
|
import { formatMoney, toFa } from '../../utils/format'
|
||||||
|
import { salesNavItems } from './salesNavigation'
|
||||||
|
import { salesSummary } from './salesUtils'
|
||||||
|
|
||||||
|
const processSteps = [
|
||||||
|
{ step: 1, label: 'پیشفاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
|
||||||
|
{ step: 2, label: 'فاکتور فروش', desc: 'ثبت فروش قطعی و کاهش موجودی', to: '/sales/invoice', icon: '🧾' },
|
||||||
|
{ step: 3, label: 'دریافت', desc: 'تسویه از مشتری در ماژول دریافت و پرداخت', to: '/treasury', icon: '💰' },
|
||||||
|
{ step: 4, label: 'فاکتور برگشتی', desc: 'برداشت در انتظار مشتریان و برگشت کالا', to: '/sales/return', icon: '↩️' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SalesProcess() {
|
||||||
|
const { data } = useStore()
|
||||||
|
const summary = useMemo(() => salesSummary(data), [data])
|
||||||
|
|
||||||
|
const quickLinks = salesNavItems.filter((item) => item.to !== '/sales')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
|
||||||
|
<p className="mb-6 text-sm text-slate-500">
|
||||||
|
جریان استاندارد فروش مشابه سپیدار: از پیشفاکتور تا فاکتور، دریافت و برگشت.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{processSteps.map((step, idx) => (
|
||||||
|
<div key={step.step} className="relative">
|
||||||
|
<Link
|
||||||
|
to={step.to}
|
||||||
|
className="flex flex-col rounded-xl border border-slate-200 bg-slate-50/50 p-4 transition hover:border-brand-200 hover:bg-brand-50/30"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-brand-100 text-xs font-bold text-brand-700">
|
||||||
|
{toFa(step.step)}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg">{step.icon}</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold text-slate-800">{step.label}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{step.desc}</p>
|
||||||
|
</Link>
|
||||||
|
{idx < processSteps.length - 1 && (
|
||||||
|
<span className="absolute -left-2 top-1/2 hidden -translate-y-1/2 text-slate-300 lg:block">←</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<StatCard label="پیشفاکتور" value={toFa(summary.proformaCount)} hint={`${toFa(summary.openProformas)} باز`} />
|
||||||
|
<StatCard label="فاکتور فروش" value={toFa(summary.invoiceCount)} hint={formatMoney(summary.totalSales)} />
|
||||||
|
<StatCard label="فاکتور برگشتی" value={toFa(summary.returnCount)} hint={formatMoney(summary.totalReturns)} />
|
||||||
|
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} hint={`${toFa(summary.unpaidCount)} تسویهنشده`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card className="p-5">
|
||||||
|
<h3 className="mb-3 font-bold text-slate-800">اتصال به سایر ماژولها</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-slate-600">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span>📦</span>
|
||||||
|
<span>
|
||||||
|
<strong>انبارداری:</strong> فاکتور فروش قطعی موجودی را کاهش و فاکتور برگشتی آن را افزایش میدهد.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span>📑</span>
|
||||||
|
<span>
|
||||||
|
<strong>اسناد حسابداری:</strong> با تأیید فاکتور، سند درآمد/مطالبات بهصورت خودکار صادر میشود.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span>💰</span>
|
||||||
|
<span>
|
||||||
|
<strong>دریافت و پرداخت:</strong> دریافت از مشتری مانده حساب را کاهش میدهد.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span>⚖️</span>
|
||||||
|
<span>
|
||||||
|
<strong>اعلامیه بدهکار/بستانکار:</strong> برای اصلاح مانده مشتری بدون فاکتور.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-5">
|
||||||
|
<h3 className="mb-3 font-bold text-slate-800">دسترسی سریع</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{quickLinks.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2.5 text-sm font-medium text-slate-700 transition hover:border-brand-200 hover:bg-brand-50/40"
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
src/pages/sales/SalesReview.tsx
Normal file
136
src/pages/sales/SalesReview.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
Select,
|
||||||
|
StatCard,
|
||||||
|
type Column,
|
||||||
|
} from '../../components/ui'
|
||||||
|
import type { Invoice, SalesDocumentType } from '../../types'
|
||||||
|
import { formatDate, formatMoney, toFa } from '../../utils/format'
|
||||||
|
import { invoiceTotals } from '../../utils/accounting'
|
||||||
|
import {
|
||||||
|
customers,
|
||||||
|
documentTypeLabels,
|
||||||
|
salesDocuments,
|
||||||
|
salesSummary,
|
||||||
|
statusLabels,
|
||||||
|
} from './salesUtils'
|
||||||
|
|
||||||
|
export function SalesReview() {
|
||||||
|
const { data } = useStore()
|
||||||
|
const [filterType, setFilterType] = useState<SalesDocumentType | 'all'>('all')
|
||||||
|
const [filterCustomer, setFilterCustomer] = useState<string>('all')
|
||||||
|
|
||||||
|
const customerList = useMemo(
|
||||||
|
() => customers(data.parties).filter((p) => p.id),
|
||||||
|
[data.parties],
|
||||||
|
)
|
||||||
|
const summary = useMemo(() => salesSummary(data), [data])
|
||||||
|
|
||||||
|
const filteredDocs = useMemo(() => {
|
||||||
|
let docs = salesDocuments(data.invoices)
|
||||||
|
if (filterType !== 'all') {
|
||||||
|
docs = docs.filter((i) => (i.documentType ?? 'invoice') === filterType)
|
||||||
|
}
|
||||||
|
if (filterCustomer !== 'all') {
|
||||||
|
docs = docs.filter((i) => i.partyId === filterCustomer)
|
||||||
|
}
|
||||||
|
return docs.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number)
|
||||||
|
}, [data.invoices, filterType, filterCustomer])
|
||||||
|
|
||||||
|
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||||
|
const salesTypeName = (id?: string) =>
|
||||||
|
id ? data.salesTypes.find((t) => t.id === id)?.name ?? '—' : '—'
|
||||||
|
|
||||||
|
const docColumns: Array<Column<Invoice>> = [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'نوع سند',
|
||||||
|
render: (i) => <Badge tone="slate">{documentTypeLabels[i.documentType ?? 'invoice']}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
||||||
|
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
||||||
|
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
||||||
|
{ key: 'salesType', header: 'نوع فروش', render: (i) => salesTypeName(i.salesTypeId) },
|
||||||
|
{
|
||||||
|
key: 'net',
|
||||||
|
header: 'مبلغ',
|
||||||
|
align: 'end',
|
||||||
|
render: (i) => <span className="tabular-nums">{formatMoney(invoiceTotals(i).net)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'وضعیت',
|
||||||
|
render: (i) => (
|
||||||
|
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : 'amber'}>
|
||||||
|
{statusLabels[i.status]}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<StatCard label="کل فروش" value={formatMoney(summary.totalSales)} />
|
||||||
|
<StatCard label="برگشت" value={formatMoney(summary.totalReturns)} />
|
||||||
|
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} />
|
||||||
|
<StatCard label="مشتریان" value={toFa(summary.customerCount)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="mb-4 flex flex-wrap items-end gap-4">
|
||||||
|
<Field label="نوع سند">
|
||||||
|
<Select value={filterType} onChange={(e) => setFilterType(e.target.value as SalesDocumentType | 'all')}>
|
||||||
|
<option value="all">همه</option>
|
||||||
|
{Object.entries(documentTypeLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="مشتری">
|
||||||
|
<Select value={filterCustomer} onChange={(e) => setFilterCustomer(e.target.value)}>
|
||||||
|
<option value="all">همه مشتریان</option>
|
||||||
|
{customerList.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<div className="ms-auto flex gap-2">
|
||||||
|
<Link to="/sales/customers">
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
مشتریان
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/sales/invoice">
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
فاکتور جدید
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/treasury">
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
دریافت
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={docColumns}
|
||||||
|
rows={filteredDocs}
|
||||||
|
rowKey={(i) => i.id}
|
||||||
|
emptyTitle="سندی یافت نشد"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
src/pages/sales/SalesTypesPage.tsx
Normal file
153
src/pages/sales/SalesTypesPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Textarea,
|
||||||
|
type Column,
|
||||||
|
} from '../../components/ui'
|
||||||
|
import type { SalesType } from '../../types'
|
||||||
|
import { createId } from '../../utils/id'
|
||||||
|
import { toFa } from '../../utils/format'
|
||||||
|
|
||||||
|
export function SalesTypesPage() {
|
||||||
|
const { data, upsertSalesType, removeSalesType } = useStore()
|
||||||
|
const [draft, setDraft] = useState<SalesType | null>(null)
|
||||||
|
const [toDelete, setToDelete] = useState<SalesType | null>(null)
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...data.salesTypes].sort((a, b) => a.code.localeCompare(b.code)),
|
||||||
|
[data.salesTypes],
|
||||||
|
)
|
||||||
|
|
||||||
|
const createDraft = (): SalesType => ({
|
||||||
|
id: '',
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!draft || !draft.code.trim() || !draft.name.trim()) return
|
||||||
|
upsertSalesType({ ...draft, id: draft.id || createId('st-') })
|
||||||
|
setDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageCount = (id: string) =>
|
||||||
|
data.invoices.filter((i) => i.kind === 'sale' && i.salesTypeId === id).length
|
||||||
|
|
||||||
|
const columns: Array<Column<SalesType>> = [
|
||||||
|
{ key: 'code', header: 'کد', render: (t) => <span className="font-mono">{t.code}</span> },
|
||||||
|
{ key: 'name', header: 'نام', render: (t) => <span className="font-medium text-slate-800">{t.name}</span> },
|
||||||
|
{ key: 'desc', header: 'توضیحات', render: (t) => <span className="text-slate-500">{t.description || '—'}</span> },
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
header: 'وضعیت',
|
||||||
|
render: (t) => <Badge tone={t.active ? 'green' : 'slate'}>{t.active ? 'فعال' : 'غیرفعال'}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'usage',
|
||||||
|
header: 'استفاده',
|
||||||
|
align: 'center',
|
||||||
|
render: (t) => toFa(usageCount(t.id)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'end',
|
||||||
|
render: (t) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...t })}>
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-slate-800">نوع فروش جدید</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
دستهبندی فروش برای فاکتورها — مشابه «نوع فروش» در سپیدار
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button icon="+" onClick={() => setDraft(createDraft())}>
|
||||||
|
نوع فروش جدید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="نوع فروشی تعریف نشده است" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={draft !== null}
|
||||||
|
title={draft?.id ? 'ویرایش نوع فروش' : 'نوع فروش جدید'}
|
||||||
|
onClose={() => setDraft(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>ذخیره</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{draft && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="کد">
|
||||||
|
<Input value={draft.code} onChange={(e) => setDraft({ ...draft, code: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="نام">
|
||||||
|
<Input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Field label="توضیحات">
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="وضعیت">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.active}
|
||||||
|
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
|
||||||
|
className="rounded border-slate-300"
|
||||||
|
/>
|
||||||
|
فعال
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toDelete !== null}
|
||||||
|
title="حذف نوع فروش"
|
||||||
|
message={`«${toDelete?.name}» حذف شود؟`}
|
||||||
|
onCancel={() => setToDelete(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (toDelete) removeSalesType(toDelete.id)
|
||||||
|
setToDelete(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/pages/sales/invoiceEffects.ts
Normal file
49
src/pages/sales/invoiceEffects.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { AppData, Invoice, Product } from '../../types'
|
||||||
|
import {
|
||||||
|
applyStockChanges,
|
||||||
|
buildInvoiceVoucher,
|
||||||
|
findVoucherForInvoice,
|
||||||
|
invoicesToSettle,
|
||||||
|
partyAccountBalance,
|
||||||
|
partyPayable,
|
||||||
|
partyReceivable,
|
||||||
|
stockDeltas,
|
||||||
|
voucherSourceForInvoice,
|
||||||
|
} from './salesUtils'
|
||||||
|
|
||||||
|
/** Applies stock + voucher side-effects when an invoice is saved (Sepidar-style). */
|
||||||
|
export function invoiceSideEffects(
|
||||||
|
saved: Invoice,
|
||||||
|
previous: Invoice | null | undefined,
|
||||||
|
data: AppData,
|
||||||
|
): {
|
||||||
|
products: Product[]
|
||||||
|
voucher: ReturnType<typeof buildInvoiceVoucher>
|
||||||
|
removeVoucherId?: string
|
||||||
|
} {
|
||||||
|
const products = applyStockChanges(data.products, saved, previous ?? null)
|
||||||
|
const existingId = findVoucherForInvoice(data.vouchers, saved)?.id
|
||||||
|
const voucher = buildInvoiceVoucher(saved, data)
|
||||||
|
return { products, voucher, removeVoucherId: existingId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverts stock + removes voucher when an invoice is deleted. */
|
||||||
|
export function invoiceDeleteEffects(invoice: Invoice, data: AppData): {
|
||||||
|
products: Product[]
|
||||||
|
removeVoucherId?: string
|
||||||
|
} {
|
||||||
|
const products = applyStockChanges(data.products, { ...invoice, status: 'draft' }, invoice)
|
||||||
|
const removeVoucherId = findVoucherForInvoice(data.vouchers, invoice)?.id
|
||||||
|
return { products, removeVoucherId }
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
applyStockChanges,
|
||||||
|
buildInvoiceVoucher,
|
||||||
|
invoicesToSettle,
|
||||||
|
partyAccountBalance,
|
||||||
|
partyPayable,
|
||||||
|
partyReceivable,
|
||||||
|
stockDeltas,
|
||||||
|
voucherSourceForInvoice,
|
||||||
|
}
|
||||||
18
src/pages/sales/salesNavigation.ts
Normal file
18
src/pages/sales/salesNavigation.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface SalesNavItem {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
end?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sub-navigation for the مشتریان و فروش module (Sepidar-style). */
|
||||||
|
export const salesNavItems: SalesNavItem[] = [
|
||||||
|
{ to: '/sales', label: 'فرایند فروش', icon: '🔀', end: true },
|
||||||
|
{ to: '/sales/proforma', label: 'پیشفاکتور جدید', icon: '📋' },
|
||||||
|
{ to: '/sales/invoice', label: 'فاکتور فروش جدید', icon: '🧾' },
|
||||||
|
{ to: '/sales/return', label: 'فاکتور برگشتی جدید', icon: '↩️' },
|
||||||
|
{ to: '/sales/adjustments', label: 'اعلامیه بدهکار/بستانکار', icon: '⚖️' },
|
||||||
|
{ to: '/sales/sales-types', label: 'نوع فروش جدید', icon: '🏷️' },
|
||||||
|
{ to: '/sales/customers', label: 'مشتریان', icon: '👤' },
|
||||||
|
{ to: '/sales/review', label: 'مرور فروش', icon: '🔍' },
|
||||||
|
]
|
||||||
284
src/pages/sales/salesUtils.test.ts
Normal file
284
src/pages/sales/salesUtils.test.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for Sepidar-style party/invoice/treasury connections.
|
||||||
|
* Run: npx tsx src/pages/sales/salesUtils.test.ts
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import type { AppData, Invoice, Party, Product } from '../../types'
|
||||||
|
import {
|
||||||
|
applyStockChanges,
|
||||||
|
buildInvoiceVoucher,
|
||||||
|
buildPurchaseVoucher,
|
||||||
|
buildSalesVoucher,
|
||||||
|
invoicesToSettle,
|
||||||
|
isWithdrawalReturn,
|
||||||
|
partyAccountBalance,
|
||||||
|
partyPayable,
|
||||||
|
partyReceivable,
|
||||||
|
reconcileInvoicePaymentStatus,
|
||||||
|
stockDeltas,
|
||||||
|
} from './salesUtils'
|
||||||
|
|
||||||
|
const customer: Party = {
|
||||||
|
id: 'c1',
|
||||||
|
kind: 'customer',
|
||||||
|
name: 'Customer A',
|
||||||
|
phone: '',
|
||||||
|
economicCode: '',
|
||||||
|
address: '',
|
||||||
|
openingBalance: 0,
|
||||||
|
externalId: 'ext-c1',
|
||||||
|
externalSource: 'funzone_customer',
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplier: Party = {
|
||||||
|
id: 's1',
|
||||||
|
kind: 'supplier',
|
||||||
|
name: 'Owner A',
|
||||||
|
phone: '',
|
||||||
|
economicCode: '',
|
||||||
|
address: '',
|
||||||
|
openingBalance: 0,
|
||||||
|
externalId: 'ext-s1',
|
||||||
|
externalSource: 'funzone_owner',
|
||||||
|
}
|
||||||
|
|
||||||
|
const product: Product = {
|
||||||
|
id: 'p1',
|
||||||
|
code: 'P1',
|
||||||
|
name: 'Item',
|
||||||
|
unit: 'عدد',
|
||||||
|
salePrice: 100,
|
||||||
|
purchasePrice: 80,
|
||||||
|
stock: 10,
|
||||||
|
reorderLevel: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = [
|
||||||
|
{ id: 'a1002', code: '1002', name: 'Bank', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||||
|
{ id: 'a1004', code: '1004', name: 'Receivable', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||||
|
{ id: 'a1005', code: '1005', name: 'Inventory', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||||
|
{ id: 'a2001', code: '2001', name: 'Payable', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||||
|
{ id: 'a4001', code: '4001', name: 'Sales', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const baseData = (): AppData => ({
|
||||||
|
accounts,
|
||||||
|
vouchers: [],
|
||||||
|
parties: [customer, supplier],
|
||||||
|
products: [product],
|
||||||
|
invoices: [],
|
||||||
|
salesTypes: [],
|
||||||
|
partyAdjustments: [],
|
||||||
|
treasury: [],
|
||||||
|
employees: [],
|
||||||
|
payslips: [],
|
||||||
|
settings: {
|
||||||
|
name: 'Test',
|
||||||
|
economicCode: '',
|
||||||
|
fiscalYear: '1404',
|
||||||
|
baseCurrency: 'تومان',
|
||||||
|
taxRate: 0,
|
||||||
|
address: '',
|
||||||
|
phone: '',
|
||||||
|
voucherAccounts: {
|
||||||
|
bankAccountId: 'a1002',
|
||||||
|
ownerPayableAccountId: 'a2001',
|
||||||
|
customerPayableAccountId: 'a2001',
|
||||||
|
ownerShareAccountId: 'a4001',
|
||||||
|
salesIncomeAccountId: 'a4001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saleInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
||||||
|
id: 'inv-sale',
|
||||||
|
kind: 'sale',
|
||||||
|
documentType: 'invoice',
|
||||||
|
number: 1,
|
||||||
|
partyId: 'c1',
|
||||||
|
date: '2026-01-01',
|
||||||
|
status,
|
||||||
|
note: '',
|
||||||
|
lines: [{ id: 'l1', productId: 'p1', quantity: 2, unitPrice: 100, discount: 0, taxRate: 0 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
||||||
|
id: 'inv-purchase',
|
||||||
|
kind: 'purchase',
|
||||||
|
documentType: 'invoice',
|
||||||
|
number: 1,
|
||||||
|
partyId: 's1',
|
||||||
|
date: '2026-01-01',
|
||||||
|
status,
|
||||||
|
note: '',
|
||||||
|
lines: [{ id: 'l2', productId: 'p1', quantity: 5, unitPrice: 80, discount: 0, taxRate: 0 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sales: confirmed invoice reduces stock
|
||||||
|
{
|
||||||
|
const inv = saleInvoice('confirmed')
|
||||||
|
const deltas = stockDeltas(inv)
|
||||||
|
assert.equal(deltas.get('p1'), -2)
|
||||||
|
const updated = applyStockChanges([product], inv)
|
||||||
|
assert.equal(updated[0].stock, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purchase: confirmed invoice increases stock
|
||||||
|
{
|
||||||
|
const inv = purchaseInvoice('confirmed')
|
||||||
|
const deltas = stockDeltas(inv)
|
||||||
|
assert.equal(deltas.get('p1'), 5)
|
||||||
|
const updated = applyStockChanges([product], inv)
|
||||||
|
assert.equal(updated[0].stock, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer receivable + treasury receipt + auto settle
|
||||||
|
{
|
||||||
|
const data = baseData()
|
||||||
|
data.invoices = [saleInvoice('confirmed')]
|
||||||
|
assert.equal(partyReceivable('c1', data), 200)
|
||||||
|
data.treasury.push({
|
||||||
|
id: 't1',
|
||||||
|
number: 1,
|
||||||
|
kind: 'receipt',
|
||||||
|
method: 'bank',
|
||||||
|
date: '2026-01-02',
|
||||||
|
partyId: 'c1',
|
||||||
|
amount: 200,
|
||||||
|
reference: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
assert.equal(partyReceivable('c1', data), 0)
|
||||||
|
const settled = invoicesToSettle('c1', data)
|
||||||
|
assert.equal(settled.length, 1)
|
||||||
|
assert.equal(settled[0].status, 'paid')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supplier payable + payment
|
||||||
|
{
|
||||||
|
const data = baseData()
|
||||||
|
data.invoices = [purchaseInvoice('confirmed')]
|
||||||
|
assert.equal(partyPayable('s1', data), 400)
|
||||||
|
data.treasury.push({
|
||||||
|
id: 't2',
|
||||||
|
number: 1,
|
||||||
|
kind: 'payment',
|
||||||
|
method: 'bank',
|
||||||
|
date: '2026-01-02',
|
||||||
|
partyId: 's1',
|
||||||
|
amount: 400,
|
||||||
|
reference: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
assert.equal(partyAccountBalance('s1', data), 0)
|
||||||
|
const settled = invoicesToSettle('s1', data)
|
||||||
|
assert.equal(settled[0].status, 'paid')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voucher generation
|
||||||
|
{
|
||||||
|
const data = baseData()
|
||||||
|
const salesV = buildSalesVoucher(saleInvoice('confirmed'), data)
|
||||||
|
assert.ok(salesV)
|
||||||
|
assert.equal(salesV!.lines.reduce((s, l) => s + l.debit, 0), 200)
|
||||||
|
|
||||||
|
const purchaseV = buildPurchaseVoucher(purchaseInvoice('confirmed'), data)
|
||||||
|
assert.ok(purchaseV)
|
||||||
|
assert.equal(purchaseV!.lines.reduce((s, l) => s + l.debit, 0), 400)
|
||||||
|
|
||||||
|
assert.ok(buildInvoiceVoucher(saleInvoice('confirmed'), data))
|
||||||
|
assert.ok(buildInvoiceVoucher(purchaseInvoice('confirmed'), data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Withdrawal return: no stock impact
|
||||||
|
{
|
||||||
|
const withdrawalReturn: Invoice = {
|
||||||
|
id: 'inv-wd',
|
||||||
|
kind: 'sale',
|
||||||
|
documentType: 'return',
|
||||||
|
number: 1,
|
||||||
|
partyId: 'c1',
|
||||||
|
date: '2026-01-03',
|
||||||
|
status: 'confirmed',
|
||||||
|
note: 'withdrawal:wd-1\nبرداشت کیف پول',
|
||||||
|
lines: [{ id: 'l3', productId: '', quantity: 1, unitPrice: 500, discount: 0, taxRate: 0 }],
|
||||||
|
}
|
||||||
|
assert.ok(isWithdrawalReturn(withdrawalReturn))
|
||||||
|
assert.equal(stockDeltas(withdrawalReturn).size, 0)
|
||||||
|
const updated = applyStockChanges([product], withdrawalReturn)
|
||||||
|
assert.equal(updated[0].stock, 10)
|
||||||
|
const data = baseData()
|
||||||
|
const voucher = buildSalesVoucher(withdrawalReturn, data)
|
||||||
|
assert.ok(voucher)
|
||||||
|
assert.equal(voucher!.source, 'withdrawal:wd-1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIFO settlement: partial payment covers first invoice only
|
||||||
|
{
|
||||||
|
const data = baseData()
|
||||||
|
data.invoices = [
|
||||||
|
{ ...saleInvoice('confirmed'), id: 'inv-a', number: 1 },
|
||||||
|
{ ...saleInvoice('confirmed'), id: 'inv-b', number: 2 },
|
||||||
|
]
|
||||||
|
data.treasury.push({
|
||||||
|
id: 't3',
|
||||||
|
number: 1,
|
||||||
|
kind: 'receipt',
|
||||||
|
method: 'bank',
|
||||||
|
date: '2026-01-02',
|
||||||
|
partyId: 'c1',
|
||||||
|
amount: 200,
|
||||||
|
reference: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
assert.equal(partyReceivable('c1', data), 200)
|
||||||
|
const settled = invoicesToSettle('c1', data)
|
||||||
|
assert.equal(settled.length, 1)
|
||||||
|
assert.equal(settled[0].id, 'inv-a')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return + receipt: FIFO marks both invoices paid when fully covered
|
||||||
|
{
|
||||||
|
const data = baseData()
|
||||||
|
data.invoices = [
|
||||||
|
{ ...saleInvoice('confirmed'), id: 'inv-a', number: 1 },
|
||||||
|
{ ...saleInvoice('confirmed'), id: 'inv-b', number: 2 },
|
||||||
|
{
|
||||||
|
id: 'inv-ret',
|
||||||
|
kind: 'sale',
|
||||||
|
documentType: 'return',
|
||||||
|
number: 3,
|
||||||
|
partyId: 'c1',
|
||||||
|
date: '2026-01-02',
|
||||||
|
status: 'confirmed',
|
||||||
|
note: '',
|
||||||
|
lines: [{ id: 'lr', productId: 'p1', quantity: 1, unitPrice: 50, discount: 0, taxRate: 0 }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
data.treasury.push({
|
||||||
|
id: 't4',
|
||||||
|
number: 1,
|
||||||
|
kind: 'receipt',
|
||||||
|
method: 'bank',
|
||||||
|
date: '2026-01-03',
|
||||||
|
partyId: 'c1',
|
||||||
|
amount: 350,
|
||||||
|
reference: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
assert.equal(partyReceivable('c1', data), 0)
|
||||||
|
const settled = invoicesToSettle('c1', data)
|
||||||
|
assert.equal(settled.length, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treasury delete reverts paid status
|
||||||
|
{
|
||||||
|
const data = baseData()
|
||||||
|
data.invoices = [{ ...saleInvoice('confirmed'), status: 'paid' }]
|
||||||
|
data.treasury = []
|
||||||
|
const reverted = reconcileInvoicePaymentStatus('c1', data)
|
||||||
|
assert.equal(reverted.length, 1)
|
||||||
|
assert.equal(reverted[0].status, 'confirmed')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('All salesUtils integration tests passed.')
|
||||||
559
src/pages/sales/salesUtils.ts
Normal file
559
src/pages/sales/salesUtils.ts
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
AppData,
|
||||||
|
Invoice,
|
||||||
|
InvoiceStatus,
|
||||||
|
Party,
|
||||||
|
PartyAdjustment,
|
||||||
|
Product,
|
||||||
|
SalesDocumentType,
|
||||||
|
TreasuryTxn,
|
||||||
|
Voucher,
|
||||||
|
VoucherLine,
|
||||||
|
} from '../../types'
|
||||||
|
import { invoiceTotals } from '../../utils/accounting'
|
||||||
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
|
|
||||||
|
/** Resolves a chart account by code, with optional settings fallback. */
|
||||||
|
export function accountByCode(
|
||||||
|
accounts: Account[],
|
||||||
|
code: string,
|
||||||
|
fallbackId?: string,
|
||||||
|
): string | null {
|
||||||
|
return accounts.find((a) => a.code === code)?.id ?? fallbackId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const documentTypeLabels: Record<SalesDocumentType, string> = {
|
||||||
|
proforma: 'پیشفاکتور',
|
||||||
|
invoice: 'فاکتور فروش',
|
||||||
|
return: 'فاکتور برگشتی',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statusLabels: Record<InvoiceStatus, string> = {
|
||||||
|
draft: 'پیشنویس',
|
||||||
|
confirmed: 'قطعی',
|
||||||
|
paid: 'تسویهشده',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sales documents only (kind=sale). */
|
||||||
|
export function salesDocuments(invoices: Invoice[]): Invoice[] {
|
||||||
|
return invoices.filter((i) => i.kind === 'sale')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function byDocumentType(invoices: Invoice[], type: SalesDocumentType): Invoice[] {
|
||||||
|
return salesDocuments(invoices).filter((i) => (i.documentType ?? 'invoice') === type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customers(parties: Party[]): Party[] {
|
||||||
|
return parties.filter((p) => p.kind === 'customer' && p.externalSource === 'funzone_customer')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suppliers(parties: Party[]): Party[] {
|
||||||
|
return parties.filter((p) => p.kind === 'supplier' && p.externalSource === 'funzone_owner')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer receivable balance: opening + sales - receipts - returns + debits - credits. */
|
||||||
|
export function partyReceivable(
|
||||||
|
partyId: string,
|
||||||
|
data: Pick<AppData, 'parties' | 'invoices' | 'partyAdjustments' | 'treasury'>,
|
||||||
|
): number {
|
||||||
|
const party = data.parties.find((p) => p.id === partyId)
|
||||||
|
if (party?.kind === 'supplier') return 0
|
||||||
|
let balance = party?.openingBalance ?? 0
|
||||||
|
|
||||||
|
for (const inv of salesDocuments(data.invoices)) {
|
||||||
|
if (inv.partyId !== partyId || inv.status === 'draft') continue
|
||||||
|
const net = invoiceTotals(inv).net
|
||||||
|
const docType = inv.documentType ?? 'invoice'
|
||||||
|
if (docType === 'proforma') continue
|
||||||
|
balance += docType === 'return' ? -net : net
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const adj of data.partyAdjustments) {
|
||||||
|
if (adj.partyId !== partyId) continue
|
||||||
|
balance += adj.kind === 'debit' ? adj.amount : -adj.amount
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const txn of data.treasury) {
|
||||||
|
if (txn.partyId !== partyId) continue
|
||||||
|
if (txn.kind === 'receipt') balance -= txn.amount
|
||||||
|
else balance += txn.amount
|
||||||
|
}
|
||||||
|
|
||||||
|
return balance
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supplier payable balance: opening + purchases - payments. */
|
||||||
|
export function partyPayable(
|
||||||
|
partyId: string,
|
||||||
|
data: Pick<AppData, 'parties' | 'invoices' | 'treasury'>,
|
||||||
|
): number {
|
||||||
|
const party = data.parties.find((p) => p.id === partyId)
|
||||||
|
if (party?.kind !== 'supplier') return 0
|
||||||
|
let balance = party.openingBalance ?? 0
|
||||||
|
|
||||||
|
for (const inv of data.invoices) {
|
||||||
|
if (inv.kind !== 'purchase' || inv.partyId !== partyId || inv.status === 'draft') continue
|
||||||
|
balance += invoiceTotals(inv).net
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const txn of data.treasury) {
|
||||||
|
if (txn.partyId !== partyId) continue
|
||||||
|
if (txn.kind === 'payment') balance -= txn.amount
|
||||||
|
else balance += txn.amount
|
||||||
|
}
|
||||||
|
|
||||||
|
return balance
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sepidar-style running account balance for a party (receivable or payable). */
|
||||||
|
export function partyAccountBalance(
|
||||||
|
partyId: string,
|
||||||
|
data: Pick<AppData, 'parties' | 'invoices' | 'partyAdjustments' | 'treasury'>,
|
||||||
|
): number {
|
||||||
|
const party = data.parties.find((p) => p.id === partyId)
|
||||||
|
if (party?.kind === 'supplier') return partyPayable(partyId, data)
|
||||||
|
return partyReceivable(partyId, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function purchaseDocuments(invoices: Invoice[]): Invoice[] {
|
||||||
|
return invoices.filter((i) => i.kind === 'purchase')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether stock/inventory should be affected for this document. */
|
||||||
|
export function isWithdrawalReturn(invoice: Invoice): boolean {
|
||||||
|
return (
|
||||||
|
invoice.kind === 'sale' &&
|
||||||
|
(invoice.documentType ?? 'invoice') === 'return' &&
|
||||||
|
invoice.note.includes('withdrawal:')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withdrawalIdFromNote(note: string): string | null {
|
||||||
|
const match = note.match(/withdrawal:([^\s\n]+)/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether stock/inventory should be affected for this document. */
|
||||||
|
export function affectsStock(invoice: Invoice): boolean {
|
||||||
|
if (invoice.status === 'draft') return false
|
||||||
|
if (isWithdrawalReturn(invoice)) return false
|
||||||
|
if (invoice.kind === 'purchase') return true
|
||||||
|
const docType = invoice.documentType ?? 'invoice'
|
||||||
|
return docType !== 'proforma'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Computes stock delta per product (negative = outbound, positive = inbound). */
|
||||||
|
export function stockDeltas(invoice: Invoice): Map<string, number> {
|
||||||
|
const deltas = new Map<string, number>()
|
||||||
|
if (!affectsStock(invoice)) return deltas
|
||||||
|
|
||||||
|
if (invoice.kind === 'purchase') {
|
||||||
|
for (const line of invoice.lines) {
|
||||||
|
if (!line.productId) continue
|
||||||
|
deltas.set(line.productId, (deltas.get(line.productId) ?? 0) + line.quantity)
|
||||||
|
}
|
||||||
|
return deltas
|
||||||
|
}
|
||||||
|
|
||||||
|
const docType = invoice.documentType ?? 'invoice'
|
||||||
|
const sign = docType === 'return' ? 1 : -1
|
||||||
|
for (const line of invoice.lines) {
|
||||||
|
if (!line.productId) continue
|
||||||
|
deltas.set(line.productId, (deltas.get(line.productId) ?? 0) + sign * line.quantity)
|
||||||
|
}
|
||||||
|
return deltas
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies stock changes when a sales document is saved; reverts previous if provided. */
|
||||||
|
export function applyStockChanges(
|
||||||
|
products: Product[],
|
||||||
|
invoice: Invoice,
|
||||||
|
previous?: Invoice | null,
|
||||||
|
): Product[] {
|
||||||
|
const updated = new Map(products.map((p) => [p.id, { ...p }]))
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
for (const [productId, delta] of stockDeltas(previous)) {
|
||||||
|
const product = updated.get(productId)
|
||||||
|
if (product) product.stock = Math.max(0, product.stock - delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [productId, delta] of stockDeltas(invoice)) {
|
||||||
|
const product = updated.get(productId)
|
||||||
|
if (product) product.stock = Math.max(0, product.stock + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(updated.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds an accounting voucher for a confirmed sales invoice or return. */
|
||||||
|
export function buildSalesVoucher(
|
||||||
|
invoice: Invoice,
|
||||||
|
data: Pick<AppData, 'accounts' | 'settings' | 'vouchers' | 'parties' | 'products'>,
|
||||||
|
): Voucher | null {
|
||||||
|
if (invoice.kind !== 'sale' || invoice.status === 'draft') return null
|
||||||
|
const docType = invoice.documentType ?? 'invoice'
|
||||||
|
if (docType === 'proforma') return null
|
||||||
|
|
||||||
|
const { salesIncomeAccountId } = data.settings.voucherAccounts
|
||||||
|
const receivableAccountId = accountByCode(data.accounts, '1004')
|
||||||
|
|
||||||
|
const net = invoiceTotals(invoice).net
|
||||||
|
if (net <= 0) return null
|
||||||
|
|
||||||
|
const party = data.parties.find((p) => p.id === invoice.partyId)
|
||||||
|
const label = `${documentTypeLabels[docType]} #${invoice.number} - ${party?.name ?? ''}`
|
||||||
|
|
||||||
|
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||||
|
id: createId('vl-'),
|
||||||
|
accountId,
|
||||||
|
description: desc,
|
||||||
|
debit,
|
||||||
|
credit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isWithdrawalReturn(invoice)) {
|
||||||
|
const { customerPayableAccountId, bankAccountId: bankSettingId } = data.settings.voucherAccounts
|
||||||
|
const bankId = accountByCode(data.accounts, '1002', bankSettingId)
|
||||||
|
const payableId = customerPayableAccountId || receivableAccountId
|
||||||
|
if (!bankId || !payableId) return null
|
||||||
|
|
||||||
|
const withdrawalId = withdrawalIdFromNote(invoice.note)
|
||||||
|
return {
|
||||||
|
id: createId('v-'),
|
||||||
|
number: nextNumber(data.vouchers),
|
||||||
|
date: invoice.date,
|
||||||
|
description: `برداشت کیف پول - ${label}`,
|
||||||
|
status: 'posted',
|
||||||
|
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice.id),
|
||||||
|
lines: [
|
||||||
|
makeLine(payableId, net, 0, `برداشت کیف پول - ${label}`),
|
||||||
|
makeLine(bankId, 0, net, `پرداخت از بانک - ${label}`),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!salesIncomeAccountId || !receivableAccountId) return null
|
||||||
|
|
||||||
|
const isReturn = docType === 'return'
|
||||||
|
const lines: VoucherLine[] = isReturn
|
||||||
|
? [
|
||||||
|
makeLine(salesIncomeAccountId, net, 0, `برگشت فروش - ${label}`),
|
||||||
|
makeLine(receivableAccountId, 0, net, `کاهش مطالبات - ${label}`),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
makeLine(receivableAccountId, net, 0, `مطالبات مشتری - ${label}`),
|
||||||
|
makeLine(salesIncomeAccountId, 0, net, `درآمد فروش - ${label}`),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createId('v-'),
|
||||||
|
number: nextNumber(data.vouchers),
|
||||||
|
date: invoice.date,
|
||||||
|
description: label,
|
||||||
|
status: 'posted',
|
||||||
|
source: voucherSourceForInvoice(invoice.id),
|
||||||
|
lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds an accounting voucher for a confirmed purchase invoice. */
|
||||||
|
export function buildPurchaseVoucher(
|
||||||
|
invoice: Invoice,
|
||||||
|
data: Pick<AppData, 'accounts' | 'vouchers' | 'parties'>,
|
||||||
|
): Voucher | null {
|
||||||
|
if (invoice.kind !== 'purchase' || invoice.status === 'draft') return null
|
||||||
|
|
||||||
|
const inventoryAccountId = accountByCode(data.accounts, '1005')
|
||||||
|
const payableAccountId = accountByCode(data.accounts, '2001')
|
||||||
|
if (!inventoryAccountId || !payableAccountId) return null
|
||||||
|
|
||||||
|
const net = invoiceTotals(invoice).net
|
||||||
|
if (net <= 0) return null
|
||||||
|
|
||||||
|
const party = data.parties.find((p) => p.id === invoice.partyId)
|
||||||
|
const label = `فاکتور خرید #${invoice.number} - ${party?.name ?? ''}`
|
||||||
|
|
||||||
|
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||||
|
id: createId('vl-'),
|
||||||
|
accountId,
|
||||||
|
description: desc,
|
||||||
|
debit,
|
||||||
|
credit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createId('v-'),
|
||||||
|
number: nextNumber(data.vouchers),
|
||||||
|
date: invoice.date,
|
||||||
|
description: label,
|
||||||
|
status: 'posted',
|
||||||
|
source: voucherSourceForInvoice(invoice.id),
|
||||||
|
lines: [
|
||||||
|
makeLine(inventoryAccountId, net, 0, `افزایش موجودی - ${label}`),
|
||||||
|
makeLine(payableAccountId, 0, net, `بدهی به تأمینکننده - ${label}`),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the appropriate voucher for any confirmed invoice. */
|
||||||
|
export function buildInvoiceVoucher(
|
||||||
|
invoice: Invoice,
|
||||||
|
data: Pick<AppData, 'accounts' | 'settings' | 'vouchers' | 'parties' | 'products'>,
|
||||||
|
): Voucher | null {
|
||||||
|
if (invoice.kind === 'sale') return buildSalesVoucher(invoice, data)
|
||||||
|
if (invoice.kind === 'purchase') return buildPurchaseVoucher(invoice, data)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirmed sale/purchase invoices eligible for treasury settlement (excludes returns & proforma). */
|
||||||
|
function settlementCandidates(
|
||||||
|
partyId: string,
|
||||||
|
data: Pick<AppData, 'parties' | 'invoices'>,
|
||||||
|
): Invoice[] {
|
||||||
|
const party = data.parties.find((p) => p.id === partyId)
|
||||||
|
if (!party) return []
|
||||||
|
|
||||||
|
const kind = party.kind === 'supplier' ? 'purchase' : 'sale'
|
||||||
|
return data.invoices
|
||||||
|
.filter((inv) => {
|
||||||
|
if (inv.kind !== kind || inv.partyId !== partyId || inv.status !== 'confirmed') return false
|
||||||
|
if (kind === 'sale') {
|
||||||
|
const docType = inv.documentType ?? 'invoice'
|
||||||
|
return docType === 'invoice'
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date) || a.number - b.number)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks confirmed invoices as paid using FIFO allocation of treasury/adjustment/return credits. */
|
||||||
|
export function invoicesToSettle(
|
||||||
|
partyId: string,
|
||||||
|
data: Pick<AppData, 'parties' | 'invoices' | 'partyAdjustments' | 'treasury'>,
|
||||||
|
): Invoice[] {
|
||||||
|
const candidates = settlementCandidates(partyId, data)
|
||||||
|
if (candidates.length === 0) return []
|
||||||
|
|
||||||
|
const totalInvoiceNet = candidates.reduce((sum, inv) => sum + invoiceTotals(inv).net, 0)
|
||||||
|
const balance = partyAccountBalance(partyId, data)
|
||||||
|
const amountApplied = totalInvoiceNet - Math.max(0, balance)
|
||||||
|
if (amountApplied <= 0.001) return []
|
||||||
|
|
||||||
|
let remaining = amountApplied
|
||||||
|
const toSettle: Invoice[] = []
|
||||||
|
for (const inv of candidates) {
|
||||||
|
const net = invoiceTotals(inv).net
|
||||||
|
if (remaining + 0.001 >= net) {
|
||||||
|
toSettle.push({ ...inv, status: 'paid' })
|
||||||
|
remaining -= net
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toSettle
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Syncs invoice paid/confirmed status after treasury or adjustment changes. */
|
||||||
|
export function reconcileInvoicePaymentStatus(
|
||||||
|
partyId: string,
|
||||||
|
data: Pick<AppData, 'parties' | 'invoices' | 'partyAdjustments' | 'treasury'>,
|
||||||
|
): Invoice[] {
|
||||||
|
const party = data.parties.find((p) => p.id === partyId)
|
||||||
|
if (!party) return []
|
||||||
|
|
||||||
|
const shouldBePaidIds = new Set(invoicesToSettle(partyId, data).map((inv) => inv.id))
|
||||||
|
const kind = party.kind === 'supplier' ? 'purchase' : 'sale'
|
||||||
|
|
||||||
|
const updates: Invoice[] = []
|
||||||
|
for (const inv of data.invoices) {
|
||||||
|
if (inv.kind !== kind || inv.partyId !== partyId || inv.status === 'draft') continue
|
||||||
|
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
|
||||||
|
|
||||||
|
const shouldPaid = shouldBePaidIds.has(inv.id)
|
||||||
|
if (inv.status === 'paid' && !shouldPaid) updates.push({ ...inv, status: 'confirmed' })
|
||||||
|
else if (inv.status === 'confirmed' && shouldPaid) updates.push({ ...inv, status: 'paid' })
|
||||||
|
}
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findVoucherForInvoice(
|
||||||
|
vouchers: Voucher[],
|
||||||
|
invoice: Invoice,
|
||||||
|
): Voucher | undefined {
|
||||||
|
const byInvoice = vouchers.find((v) => v.source === voucherSourceForInvoice(invoice.id))
|
||||||
|
if (byInvoice) return byInvoice
|
||||||
|
|
||||||
|
const withdrawalId = withdrawalIdFromNote(invoice.note)
|
||||||
|
if (withdrawalId) {
|
||||||
|
return vouchers.find((v) => v.source === `withdrawal:${withdrawalId}`)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds an accounting voucher for a debit/credit notice (اعلامیه). */
|
||||||
|
export function buildAdjustmentVoucher(
|
||||||
|
adjustment: PartyAdjustment,
|
||||||
|
data: Pick<AppData, 'accounts' | 'settings' | 'vouchers' | 'parties'>,
|
||||||
|
): Voucher | null {
|
||||||
|
const receivableAccountId = accountByCode(data.accounts, '1004')
|
||||||
|
const { salesIncomeAccountId } = data.settings.voucherAccounts
|
||||||
|
if (!receivableAccountId || !salesIncomeAccountId || adjustment.amount <= 0) return null
|
||||||
|
|
||||||
|
const party = data.parties.find((p) => p.id === adjustment.partyId)
|
||||||
|
const kindLabel = adjustment.kind === 'debit' ? 'بدهکار' : 'بستانکار'
|
||||||
|
const label = `اعلامیه ${kindLabel} #${adjustment.number} - ${party?.name ?? ''}`
|
||||||
|
|
||||||
|
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||||
|
id: createId('vl-'),
|
||||||
|
accountId,
|
||||||
|
description: desc,
|
||||||
|
debit,
|
||||||
|
credit,
|
||||||
|
})
|
||||||
|
|
||||||
|
const amount = adjustment.amount
|
||||||
|
const lines: VoucherLine[] =
|
||||||
|
adjustment.kind === 'debit'
|
||||||
|
? [
|
||||||
|
makeLine(receivableAccountId, amount, 0, `افزایش مطالبات - ${label}`),
|
||||||
|
makeLine(salesIncomeAccountId, 0, amount, `اصلاح درآمد - ${label}`),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
makeLine(salesIncomeAccountId, amount, 0, `اصلاح درآمد - ${label}`),
|
||||||
|
makeLine(receivableAccountId, 0, amount, `کاهش مطالبات - ${label}`),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createId('v-'),
|
||||||
|
number: nextNumber(data.vouchers),
|
||||||
|
date: adjustment.date,
|
||||||
|
description: adjustment.description || label,
|
||||||
|
status: 'posted',
|
||||||
|
source: voucherSourceForAdjustment(adjustment.id),
|
||||||
|
lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds an accounting voucher for a treasury receipt or payment. */
|
||||||
|
export function buildTreasuryVoucher(
|
||||||
|
txn: TreasuryTxn,
|
||||||
|
data: Pick<AppData, 'accounts' | 'settings' | 'vouchers' | 'parties'>,
|
||||||
|
): Voucher | null {
|
||||||
|
if (txn.amount <= 0) return null
|
||||||
|
|
||||||
|
const { bankAccountId, salesIncomeAccountId, ownerPayableAccountId } = data.settings.voucherAccounts
|
||||||
|
const bankId = accountByCode(data.accounts, '1002', bankAccountId)
|
||||||
|
if (!bankId) return null
|
||||||
|
|
||||||
|
const party = txn.partyId ? data.parties.find((p) => p.id === txn.partyId) : undefined
|
||||||
|
const counterAccountId =
|
||||||
|
party?.kind === 'supplier'
|
||||||
|
? accountByCode(data.accounts, '2005', ownerPayableAccountId) ??
|
||||||
|
accountByCode(data.accounts, '2001')
|
||||||
|
: accountByCode(data.accounts, '1004')
|
||||||
|
|
||||||
|
const taxAccountId = accountByCode(data.accounts, '2002')
|
||||||
|
const ownerPayableId = accountByCode(data.accounts, '2005', ownerPayableAccountId)
|
||||||
|
const platformIncomeId = accountByCode(data.accounts, '4001', salesIncomeAccountId)
|
||||||
|
|
||||||
|
const kindLabel = txn.kind === 'receipt' ? 'دریافت' : 'پرداخت'
|
||||||
|
const label = `${kindLabel} #${txn.number}${party ? ` - ${party.name}` : ''}`
|
||||||
|
|
||||||
|
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||||
|
id: createId('vl-'),
|
||||||
|
accountId,
|
||||||
|
description: desc,
|
||||||
|
debit,
|
||||||
|
credit,
|
||||||
|
})
|
||||||
|
|
||||||
|
const amount = txn.amount
|
||||||
|
const tax = txn.taxAmount ?? 0
|
||||||
|
const profit = txn.platformProfitAmount ?? 0
|
||||||
|
const ownerNet = txn.ownerNetAmount ?? 0
|
||||||
|
const hasEventSplit =
|
||||||
|
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
|
||||||
|
|
||||||
|
if (hasEventSplit) {
|
||||||
|
return {
|
||||||
|
id: createId('v-'),
|
||||||
|
number: nextNumber(data.vouchers),
|
||||||
|
date: txn.date,
|
||||||
|
description: txn.description || label,
|
||||||
|
status: 'posted',
|
||||||
|
source: voucherSourceForTreasury(txn.id),
|
||||||
|
lines: [
|
||||||
|
makeLine(bankId, amount, 0, `واریز بابت رویداد - ${label}`),
|
||||||
|
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
|
||||||
|
makeLine(platformIncomeId!, 0, profit, `سود پلتفرم (۱۴٪) - ${label}`),
|
||||||
|
makeLine(taxAccountId!, 0, tax, `مالیات (۱۰٪) - ${label}`),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!counterAccountId) return null
|
||||||
|
|
||||||
|
const lines: VoucherLine[] =
|
||||||
|
txn.kind === 'receipt'
|
||||||
|
? [
|
||||||
|
makeLine(bankId, amount, 0, `واریز - ${label}`),
|
||||||
|
makeLine(counterAccountId, 0, amount, `تسویه مطالبات - ${label}`),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
makeLine(counterAccountId, amount, 0, `تسویه بدهی - ${label}`),
|
||||||
|
makeLine(bankId, 0, amount, `برداشت - ${label}`),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: createId('v-'),
|
||||||
|
number: nextNumber(data.vouchers),
|
||||||
|
date: txn.date,
|
||||||
|
description: txn.description || label,
|
||||||
|
status: 'posted',
|
||||||
|
source: voucherSourceForTreasury(txn.id),
|
||||||
|
lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function voucherSourceForInvoice(invoiceId: string): string {
|
||||||
|
return `invoice:${invoiceId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function voucherSourceForAdjustment(adjustmentId: string): string {
|
||||||
|
return `adjustment:${adjustmentId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function voucherSourceForTreasury(txnId: string): string {
|
||||||
|
return `treasury:${txnId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summary stats for the sales process dashboard. */
|
||||||
|
export function salesSummary(data: AppData) {
|
||||||
|
const proformas = byDocumentType(data.invoices, 'proforma')
|
||||||
|
const invoices = byDocumentType(data.invoices, 'invoice')
|
||||||
|
const returns = byDocumentType(data.invoices, 'return')
|
||||||
|
|
||||||
|
const sumNet = (list: Invoice[]) =>
|
||||||
|
list.filter((i) => i.status !== 'draft').reduce((s, i) => s + invoiceTotals(i).net, 0)
|
||||||
|
|
||||||
|
const unpaid = invoices.filter((i) => i.status === 'confirmed')
|
||||||
|
const customersList = customers(data.parties)
|
||||||
|
|
||||||
|
return {
|
||||||
|
proformaCount: proformas.length,
|
||||||
|
invoiceCount: invoices.length,
|
||||||
|
returnCount: returns.length,
|
||||||
|
adjustmentCount: data.partyAdjustments.length,
|
||||||
|
salesTypeCount: data.salesTypes.length,
|
||||||
|
customerCount: customersList.length,
|
||||||
|
totalSales: sumNet(invoices),
|
||||||
|
totalReturns: sumNet(returns),
|
||||||
|
netSales: sumNet(invoices) - sumNet(returns),
|
||||||
|
unpaidCount: unpaid.length,
|
||||||
|
unpaidAmount: sumNet(unpaid),
|
||||||
|
openProformas: proformas.filter((p) => p.status === 'draft').length,
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/pages/sales/usePendingCustomerWithdrawals.ts
Normal file
44
src/pages/sales/usePendingCustomerWithdrawals.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { apiGet } from '../../api/client'
|
||||||
|
import { ENDPOINTS } from '../../api/config'
|
||||||
|
import type { ApiWithdrawal } from '../../api/types'
|
||||||
|
|
||||||
|
/** Pending customer wallet withdrawals from admin panel accounting API. */
|
||||||
|
export function usePendingCustomerWithdrawals(enabled = true) {
|
||||||
|
const [withdrawals, setWithdrawals] = useState<ApiWithdrawal[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
|
||||||
|
const pending = (res.withdrawals ?? []).filter(
|
||||||
|
(w) => w.user_type === 'customer' && w.status === 'pending',
|
||||||
|
)
|
||||||
|
setWithdrawals(pending)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'خطا در بارگذاری برداشتها')
|
||||||
|
setWithdrawals([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) void load()
|
||||||
|
}, [enabled, load])
|
||||||
|
|
||||||
|
return { withdrawals, loading, error, reload: load }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const withdrawalNoteTag = (withdrawalId: string): string => `withdrawal:${withdrawalId}`
|
||||||
|
|
||||||
|
export function invoiceForWithdrawal(
|
||||||
|
invoices: { note: string; status: string }[],
|
||||||
|
withdrawalId: string,
|
||||||
|
): boolean {
|
||||||
|
const tag = withdrawalNoteTag(withdrawalId)
|
||||||
|
return invoices.some((inv) => inv.note.includes(tag) && inv.status !== 'draft')
|
||||||
|
}
|
||||||
151
src/pages/treasury/funzoneTreasurySync.test.ts
Normal file
151
src/pages/treasury/funzoneTreasurySync.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Run: npx tsx src/pages/treasury/funzoneTreasurySync.test.ts
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
||||||
|
import type { Party, TreasuryTxn } from '../../types'
|
||||||
|
import { splitEventPayment, ownerTxnExcludedFromTreasury } from '../../utils/funzoneFees'
|
||||||
|
import {
|
||||||
|
buildFunZoneTreasurySync,
|
||||||
|
buildTreasuryFromCustomerPayment,
|
||||||
|
buildTreasuryFromOwnerTransaction,
|
||||||
|
} from './funzoneTreasurySync'
|
||||||
|
|
||||||
|
// Fee split matches admin panel (10% tax + 14% profit)
|
||||||
|
{
|
||||||
|
const split = splitEventPayment(100_000)
|
||||||
|
assert.equal(split.tax, 10_000)
|
||||||
|
assert.equal(split.platformProfit, 14_000)
|
||||||
|
assert.equal(split.ownerNet, 76_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(ownerTxnExcludedFromTreasury('deposit'))
|
||||||
|
assert.ok(ownerTxnExcludedFromTreasury('booking_payment'))
|
||||||
|
assert.equal(ownerTxnExcludedFromTreasury('withdraw'), false)
|
||||||
|
|
||||||
|
const customer: Party = {
|
||||||
|
id: 'c1',
|
||||||
|
kind: 'customer',
|
||||||
|
name: 'Ali',
|
||||||
|
phone: '',
|
||||||
|
economicCode: '',
|
||||||
|
address: '',
|
||||||
|
openingBalance: 0,
|
||||||
|
externalId: 'cust-1',
|
||||||
|
externalSource: 'funzone_customer',
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner: Party = {
|
||||||
|
id: 'o1',
|
||||||
|
kind: 'supplier',
|
||||||
|
name: 'Owner',
|
||||||
|
phone: '',
|
||||||
|
economicCode: '',
|
||||||
|
address: '',
|
||||||
|
openingBalance: 0,
|
||||||
|
externalId: 'own-1',
|
||||||
|
externalSource: 'funzone_owner',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event payment → receipt with fee split
|
||||||
|
{
|
||||||
|
const item: ApiPaymentOrRefund = {
|
||||||
|
id: 'pay-1',
|
||||||
|
type: 'payment',
|
||||||
|
customer_id: 'cust-1',
|
||||||
|
customer_name: 'Ali',
|
||||||
|
trace_no: 'T1',
|
||||||
|
ref_num: 'R1',
|
||||||
|
amount: 100_000,
|
||||||
|
event_name: 'Football Night',
|
||||||
|
event_date: '2026-01-01',
|
||||||
|
event_time: '18:00',
|
||||||
|
created_at: '2026-01-01T10:00:00Z',
|
||||||
|
reservation_id: 'res-1',
|
||||||
|
refund_status: null,
|
||||||
|
}
|
||||||
|
const txn = buildTreasuryFromCustomerPayment(item, 'c1', 1)!
|
||||||
|
assert.equal(txn.kind, 'receipt')
|
||||||
|
assert.equal(txn.platformProfitAmount, 14_000)
|
||||||
|
assert.equal(txn.taxAmount, 10_000)
|
||||||
|
assert.equal(txn.ownerNetAmount, 76_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner deposit & booking_payment excluded from sync
|
||||||
|
{
|
||||||
|
const ownerTxns: ApiOwnerTransaction[] = [
|
||||||
|
{
|
||||||
|
id: 'ot-deposit',
|
||||||
|
owner_id: 'own-1',
|
||||||
|
type: 'deposit',
|
||||||
|
amount: 50_000,
|
||||||
|
status: 'completed',
|
||||||
|
description: 'deposit',
|
||||||
|
created_at: '2026-01-02T10:00:00Z',
|
||||||
|
updated_at: '2026-01-02T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ot-booking',
|
||||||
|
owner_id: 'own-1',
|
||||||
|
type: 'booking_payment',
|
||||||
|
amount: 100_000,
|
||||||
|
status: 'completed',
|
||||||
|
description: 'booking',
|
||||||
|
created_at: '2026-01-02T11:00:00Z',
|
||||||
|
updated_at: '2026-01-02T11:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ot-withdraw',
|
||||||
|
owner_id: 'own-1',
|
||||||
|
type: 'withdraw',
|
||||||
|
amount: 76_000,
|
||||||
|
status: 'completed',
|
||||||
|
description: 'withdraw',
|
||||||
|
created_at: '2026-01-03T10:00:00Z',
|
||||||
|
updated_at: '2026-01-03T10:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const stale: TreasuryTxn = {
|
||||||
|
id: 'tr-stale',
|
||||||
|
number: 1,
|
||||||
|
kind: 'payment',
|
||||||
|
method: 'bank',
|
||||||
|
date: '2026-01-02',
|
||||||
|
partyId: 'o1',
|
||||||
|
amount: 100_000,
|
||||||
|
reference: '',
|
||||||
|
description: 'old',
|
||||||
|
source: 'funzone:owner-txn:ot-booking',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = buildFunZoneTreasurySync(
|
||||||
|
{ parties: [customer, owner], treasury: [stale] },
|
||||||
|
[],
|
||||||
|
ownerTxns,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(result.excludedOwnerTxns, 2)
|
||||||
|
assert.equal(result.toRemove.length, 1)
|
||||||
|
assert.equal(result.toRemove[0].id, 'tr-stale')
|
||||||
|
assert.equal(result.toUpsert.length, 1)
|
||||||
|
assert.equal(result.toUpsert[0].source, 'funzone:owner-txn:ot-withdraw')
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(buildTreasuryFromOwnerTransaction(
|
||||||
|
{
|
||||||
|
id: 'x',
|
||||||
|
owner_id: 'own-1',
|
||||||
|
type: 'deposit',
|
||||||
|
amount: 1,
|
||||||
|
status: 'completed',
|
||||||
|
description: '',
|
||||||
|
created_at: '2026-01-01',
|
||||||
|
updated_at: '2026-01-01',
|
||||||
|
},
|
||||||
|
'o1',
|
||||||
|
'Owner',
|
||||||
|
1,
|
||||||
|
), null)
|
||||||
|
|
||||||
|
console.log('funzoneTreasurySync tests passed.')
|
||||||
257
src/pages/treasury/funzoneTreasurySync.ts
Normal file
257
src/pages/treasury/funzoneTreasurySync.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
||||||
|
import type { AppData, TreasuryTxn } from '../../types'
|
||||||
|
import { findExistingParty } from '../../parties/funzonePartySync'
|
||||||
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
|
import {
|
||||||
|
isEventCustomerPayment,
|
||||||
|
ownerTxnExcludedFromTreasury,
|
||||||
|
ownerTxnIncludedInTreasury,
|
||||||
|
splitEventPayment,
|
||||||
|
} from '../../utils/funzoneFees'
|
||||||
|
|
||||||
|
const ownerTypeLabels: Record<string, string> = {
|
||||||
|
withdraw: 'برداشت مالک به بانک',
|
||||||
|
refund: 'بازپرداخت/کسر از مالک',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function treasurySourceCustomerPayment(id: string): string {
|
||||||
|
return `funzone:cust-pay:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function treasurySourceOwnerTxn(id: string): string {
|
||||||
|
return `funzone:owner-txn:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunZoneTreasuryTxn(txn: TreasuryTxn): boolean {
|
||||||
|
return Boolean(txn.source?.startsWith('funzone:'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findTreasuryBySource(treasury: TreasuryTxn[], source: string): TreasuryTxn | undefined {
|
||||||
|
return treasury.find((t) => t.source === source)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDateFromApi(iso: string): string {
|
||||||
|
return iso.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLabel(eventName: string | null | undefined): string {
|
||||||
|
if (!eventName) return '—'
|
||||||
|
return eventName.toLowerCase() === 'wallet' ? 'کیف پول' : eventName
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
if (status === 'completed') return 'انجامشده'
|
||||||
|
if (status === 'pending') return 'در انتظار'
|
||||||
|
if (status === 'failed') return 'ناموفق'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer platform payment → دریافت; cancellation/refund → پرداخت. */
|
||||||
|
export function buildTreasuryFromCustomerPayment(
|
||||||
|
item: ApiPaymentOrRefund,
|
||||||
|
partyId: string,
|
||||||
|
number: number,
|
||||||
|
existing?: TreasuryTxn,
|
||||||
|
): TreasuryTxn | null {
|
||||||
|
const amount = Math.abs(item.amount)
|
||||||
|
if (amount <= 0) return null
|
||||||
|
|
||||||
|
const isPayment = item.type === 'payment'
|
||||||
|
const kind = isPayment ? 'receipt' : 'payment'
|
||||||
|
const isEvent = isPayment && isEventCustomerPayment(item.event_name)
|
||||||
|
const split = isEvent ? splitEventPayment(amount) : null
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
isPayment
|
||||||
|
? `دریافت از مشتری — ${item.customer_name}`
|
||||||
|
: `بازپرداخت به مشتری — ${item.customer_name}`,
|
||||||
|
`نوع: ${isPayment ? 'پرداخت مشتری' : 'لغو/بازپرداخت'}`,
|
||||||
|
`رویداد: ${eventLabel(item.event_name)}`,
|
||||||
|
split
|
||||||
|
? [
|
||||||
|
`مبلغ کل: ${split.gross}`,
|
||||||
|
`مالیات (۱۰٪): ${split.tax}`,
|
||||||
|
`سود پلتفرم (۱۴٪): ${split.platformProfit}`,
|
||||||
|
`سهم مالک (خالص): ${split.ownerNet}`,
|
||||||
|
].join('\n')
|
||||||
|
: null,
|
||||||
|
item.event_date ? `تاریخ رویداد: ${item.event_date}${item.event_time ? ` ${item.event_time}` : ''}` : null,
|
||||||
|
item.reservation_id ? `شناسه رزرو: ${item.reservation_id}` : null,
|
||||||
|
item.trace_no ? `شماره پیگیری: ${item.trace_no}` : null,
|
||||||
|
item.ref_num ? `مرجع بانکی: ${item.ref_num}` : null,
|
||||||
|
item.refund_status ? `وضعیت بازپرداخت: ${statusLabel(item.refund_status)}` : null,
|
||||||
|
'منبع: فانزون',
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existing?.id ?? '',
|
||||||
|
number: existing?.number ?? number,
|
||||||
|
kind,
|
||||||
|
method: 'bank',
|
||||||
|
date: isoDateFromApi(item.created_at),
|
||||||
|
partyId,
|
||||||
|
amount,
|
||||||
|
taxAmount: split?.tax ?? 0,
|
||||||
|
platformProfitAmount: split?.platformProfit ?? 0,
|
||||||
|
ownerNetAmount: split?.ownerNet ?? 0,
|
||||||
|
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
||||||
|
description: lines.join('\n'),
|
||||||
|
source: treasurySourceCustomerPayment(item.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner bank payout or refund only (excludes deposit & booking_payment). */
|
||||||
|
export function buildTreasuryFromOwnerTransaction(
|
||||||
|
txn: ApiOwnerTransaction,
|
||||||
|
partyId: string,
|
||||||
|
partyName: string,
|
||||||
|
number: number,
|
||||||
|
existing?: TreasuryTxn,
|
||||||
|
): TreasuryTxn | null {
|
||||||
|
if (!ownerTxnIncludedInTreasury(txn.type)) return null
|
||||||
|
|
||||||
|
const amount = Math.abs(txn.amount)
|
||||||
|
if (amount <= 0) return null
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`${ownerTypeLabels[txn.type] ?? txn.type} — ${partyName}`,
|
||||||
|
`وضعیت: ${statusLabel(txn.status)}`,
|
||||||
|
txn.description ? `توضیحات: ${txn.description}` : null,
|
||||||
|
'منبع: فانزون',
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existing?.id ?? '',
|
||||||
|
number: existing?.number ?? number,
|
||||||
|
kind: 'payment',
|
||||||
|
method: 'bank',
|
||||||
|
date: isoDateFromApi(txn.created_at),
|
||||||
|
partyId,
|
||||||
|
amount,
|
||||||
|
taxAmount: 0,
|
||||||
|
platformProfitAmount: 0,
|
||||||
|
ownerNetAmount: amount,
|
||||||
|
reference: txn.id.slice(0, 8),
|
||||||
|
description: lines.join('\n'),
|
||||||
|
source: treasurySourceOwnerTxn(txn.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunZoneTreasurySyncResult {
|
||||||
|
toUpsert: TreasuryTxn[]
|
||||||
|
toRemove: TreasuryTxn[]
|
||||||
|
skipped: number
|
||||||
|
unmatchedCustomers: number
|
||||||
|
unmatchedOwners: number
|
||||||
|
excludedOwnerTxns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds treasury rows from FunZone APIs; skips unchanged existing imports. */
|
||||||
|
export function buildFunZoneTreasurySync(
|
||||||
|
data: Pick<AppData, 'parties' | 'treasury'>,
|
||||||
|
customerPayments: ApiPaymentOrRefund[],
|
||||||
|
ownerTransactions: ApiOwnerTransaction[],
|
||||||
|
): FunZoneTreasurySyncResult {
|
||||||
|
const toUpsert: TreasuryTxn[] = []
|
||||||
|
let skipped = 0
|
||||||
|
let unmatchedCustomers = 0
|
||||||
|
let unmatchedOwners = 0
|
||||||
|
let excludedOwnerTxns = 0
|
||||||
|
|
||||||
|
const staleSources = new Set(
|
||||||
|
ownerTransactions
|
||||||
|
.filter((t) => ownerTxnExcludedFromTreasury(t.type))
|
||||||
|
.map((t) => treasurySourceOwnerTxn(t.id)),
|
||||||
|
)
|
||||||
|
const toRemove = data.treasury.filter((t) => t.source && staleSources.has(t.source))
|
||||||
|
|
||||||
|
let receiptNumber = nextNumber(data.treasury.filter((t) => t.kind === 'receipt'))
|
||||||
|
let paymentNumber = nextNumber(data.treasury.filter((t) => t.kind === 'payment'))
|
||||||
|
|
||||||
|
for (const item of customerPayments) {
|
||||||
|
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
|
||||||
|
if (!party?.id) {
|
||||||
|
unmatchedCustomers += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = treasurySourceCustomerPayment(item.id)
|
||||||
|
const existing = findTreasuryBySource(data.treasury, source)
|
||||||
|
const built = buildTreasuryFromCustomerPayment(
|
||||||
|
item,
|
||||||
|
party.id,
|
||||||
|
existing?.number ?? receiptNumber,
|
||||||
|
existing,
|
||||||
|
)
|
||||||
|
if (!built) continue
|
||||||
|
|
||||||
|
if (existing && treasuryUnchanged(existing, built)) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
toUpsert.push(built)
|
||||||
|
if (!existing && built.kind === 'receipt') receiptNumber += 1
|
||||||
|
if (!existing && built.kind === 'payment') paymentNumber += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const txn of ownerTransactions) {
|
||||||
|
if (txn.status === 'failed') continue
|
||||||
|
|
||||||
|
if (ownerTxnExcludedFromTreasury(txn.type)) {
|
||||||
|
excludedOwnerTxns += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ownerTxnIncludedInTreasury(txn.type)) continue
|
||||||
|
|
||||||
|
const party = findExistingParty(data.parties, txn.owner_id, 'funzone_owner')
|
||||||
|
if (!party?.id) {
|
||||||
|
unmatchedOwners += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = treasurySourceOwnerTxn(txn.id)
|
||||||
|
const existing = findTreasuryBySource(data.treasury, source)
|
||||||
|
const built = buildTreasuryFromOwnerTransaction(
|
||||||
|
txn,
|
||||||
|
party.id,
|
||||||
|
party.name,
|
||||||
|
existing?.number ?? paymentNumber,
|
||||||
|
existing,
|
||||||
|
)
|
||||||
|
if (!built) continue
|
||||||
|
|
||||||
|
if (existing && treasuryUnchanged(existing, built)) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
toUpsert.push(built)
|
||||||
|
if (!existing) paymentNumber += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns }
|
||||||
|
}
|
||||||
|
|
||||||
|
function treasuryUnchanged(a: TreasuryTxn, b: TreasuryTxn): boolean {
|
||||||
|
return (
|
||||||
|
a.kind === b.kind &&
|
||||||
|
a.method === b.method &&
|
||||||
|
a.date === b.date &&
|
||||||
|
a.partyId === b.partyId &&
|
||||||
|
Math.abs(a.amount - b.amount) < 0.01 &&
|
||||||
|
Math.abs((a.taxAmount ?? 0) - (b.taxAmount ?? 0)) < 0.01 &&
|
||||||
|
Math.abs((a.platformProfitAmount ?? 0) - (b.platformProfitAmount ?? 0)) < 0.01 &&
|
||||||
|
Math.abs((a.ownerNetAmount ?? 0) - (b.ownerNetAmount ?? 0)) < 0.01 &&
|
||||||
|
a.reference === b.reference &&
|
||||||
|
a.description === b.description
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignTreasuryIds(items: TreasuryTxn[]): TreasuryTxn[] {
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: item.id || createId('tr-'),
|
||||||
|
}))
|
||||||
|
}
|
||||||
140
src/pages/treasury/useFunZoneTreasurySync.ts
Normal file
140
src/pages/treasury/useFunZoneTreasurySync.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { apiGet, asList } from '../../api/client'
|
||||||
|
import { ENDPOINTS } from '../../api/config'
|
||||||
|
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
||||||
|
import type { AppData, TreasuryTxn } from '../../types'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import { buildTreasuryVoucher, reconcileInvoicePaymentStatus, voucherSourceForTreasury } from '../sales/salesUtils'
|
||||||
|
import {
|
||||||
|
assignTreasuryIds,
|
||||||
|
buildFunZoneTreasurySync,
|
||||||
|
} from './funzoneTreasurySync'
|
||||||
|
|
||||||
|
export interface FunZoneTreasurySyncStats {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
removed: number
|
||||||
|
skipped: number
|
||||||
|
unmatchedCustomers: number
|
||||||
|
unmatchedOwners: number
|
||||||
|
excludedOwnerTxns: number
|
||||||
|
customerPaymentCount: number
|
||||||
|
ownerTxnCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFunZoneTreasurySync() {
|
||||||
|
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [stats, setStats] = useState<FunZoneTreasurySyncStats | null>(null)
|
||||||
|
const autoSynced = useRef(false)
|
||||||
|
|
||||||
|
const persistTreasuryBatch = useCallback(
|
||||||
|
(baseData: AppData, items: TreasuryTxn[]): FunZoneTreasurySyncStats => {
|
||||||
|
const saved = assignTreasuryIds(items)
|
||||||
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
|
let working = { ...baseData }
|
||||||
|
|
||||||
|
for (const txn of saved) {
|
||||||
|
const isNew = !baseData.treasury.some((t) => t.id === txn.id)
|
||||||
|
if (isNew) created += 1
|
||||||
|
else updated += 1
|
||||||
|
|
||||||
|
upsertTreasury(txn)
|
||||||
|
|
||||||
|
const existingVoucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(txn.id))
|
||||||
|
const voucher = buildTreasuryVoucher(txn, working)
|
||||||
|
if (existingVoucher) removeVoucher(existingVoucher.id)
|
||||||
|
if (voucher) upsertVoucher(voucher)
|
||||||
|
|
||||||
|
working = {
|
||||||
|
...working,
|
||||||
|
treasury: [...working.treasury.filter((t) => t.id !== txn.id && t.source !== txn.source), txn],
|
||||||
|
vouchers: [
|
||||||
|
...working.vouchers.filter((v) => v.id !== existingVoucher?.id),
|
||||||
|
...(voucher ? [voucher] : []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (txn.partyId) {
|
||||||
|
for (const inv of reconcileInvoicePaymentStatus(txn.partyId, working)) {
|
||||||
|
upsertInvoice(inv)
|
||||||
|
working = {
|
||||||
|
...working,
|
||||||
|
invoices: working.invoices.map((i) => (i.id === inv.id ? inv : i)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
removed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
unmatchedCustomers: 0,
|
||||||
|
unmatchedOwners: 0,
|
||||||
|
excludedOwnerTxns: 0,
|
||||||
|
customerPaymentCount: 0,
|
||||||
|
ownerTxnCount: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sync = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [customerPayments, ownerTransactions] = await Promise.all([
|
||||||
|
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
|
||||||
|
(res) => res.payments_and_refunds ?? [],
|
||||||
|
),
|
||||||
|
apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
|
||||||
|
])
|
||||||
|
|
||||||
|
const { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns } =
|
||||||
|
buildFunZoneTreasurySync(data, customerPayments, ownerTransactions)
|
||||||
|
|
||||||
|
let working = { ...data }
|
||||||
|
for (const stale of toRemove) {
|
||||||
|
const voucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(stale.id))
|
||||||
|
if (voucher) removeVoucher(voucher.id)
|
||||||
|
removeTreasury(stale.id)
|
||||||
|
working = {
|
||||||
|
...working,
|
||||||
|
treasury: working.treasury.filter((t) => t.id !== stale.id),
|
||||||
|
vouchers: working.vouchers.filter((v) => v.id !== voucher?.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchStats = persistTreasuryBatch(working, toUpsert)
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
...batchStats,
|
||||||
|
removed: toRemove.length,
|
||||||
|
skipped,
|
||||||
|
unmatchedCustomers,
|
||||||
|
unmatchedOwners,
|
||||||
|
excludedOwnerTxns,
|
||||||
|
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
|
||||||
|
ownerTxnCount: ownerTransactions.filter((t) => t.status !== 'failed' && t.type === 'withdraw').length,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'خطا در همگامسازی خزانه')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [data, persistTreasuryBatch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoSynced.current || data.parties.length === 0) return
|
||||||
|
const hasFunZoneParties = data.parties.some((p) => p.externalSource?.startsWith('funzone_'))
|
||||||
|
if (!hasFunZoneParties) return
|
||||||
|
autoSynced.current = true
|
||||||
|
void sync()
|
||||||
|
}, [data.parties, sync])
|
||||||
|
|
||||||
|
return { sync, loading, error, stats }
|
||||||
|
}
|
||||||
538
src/parties/FunZonePartySection.tsx
Normal file
538
src/parties/FunZonePartySection.tsx
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { apiGet, asList } from '../api/client'
|
||||||
|
import { ENDPOINTS } from '../api/config'
|
||||||
|
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
|
||||||
|
import { useVoucherSync } from '../accounting/voucherSync'
|
||||||
|
import { useStore } from '../store/AppStore'
|
||||||
|
import type { Party } from '../types'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
StatCard,
|
||||||
|
type Column,
|
||||||
|
} from '../components/ui'
|
||||||
|
import { formatDate, formatDateTime, formatMoney, toFa } from '../utils/format'
|
||||||
|
import {
|
||||||
|
type FunZonePartyConfig,
|
||||||
|
getPartyFullName,
|
||||||
|
useFunZoneParties,
|
||||||
|
usePartyWithdrawals,
|
||||||
|
} from './useFunZoneParties'
|
||||||
|
import { partyAccountBalance, salesDocuments } from '../pages/sales/salesUtils'
|
||||||
|
|
||||||
|
const txnTypeLabels: Record<string, string> = {
|
||||||
|
deposit: 'واریز',
|
||||||
|
withdraw: 'برداشت',
|
||||||
|
booking_payment: 'پرداخت رزرو',
|
||||||
|
refund: 'بازپرداخت',
|
||||||
|
payment: 'پرداخت',
|
||||||
|
cancellation: 'لغو/بازپرداخت',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARTY_ACTIONS_WIDTH = '16rem'
|
||||||
|
|
||||||
|
export function FunZonePartySection({ config }: { config: FunZonePartyConfig }) {
|
||||||
|
const { data, upsertPartyAsync } = useStore()
|
||||||
|
const { parties, loading, syncing, error, reload, syncToAccounting } = useFunZoneParties(config)
|
||||||
|
const withdrawals = usePartyWithdrawals(config.kind)
|
||||||
|
const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync()
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selected, setSelected] = useState<Party | null>(null)
|
||||||
|
const [editAccounting, setEditAccounting] = useState<Party | null>(null)
|
||||||
|
const [syncBusy, setSyncBusy] = useState(false)
|
||||||
|
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase()
|
||||||
|
if (!query) return parties
|
||||||
|
return parties.filter((p) => {
|
||||||
|
const name = getPartyFullName(p).toLowerCase()
|
||||||
|
return (
|
||||||
|
name.includes(query) ||
|
||||||
|
(p.username ?? '').toLowerCase().includes(query) ||
|
||||||
|
(p.phone ?? '').includes(query) ||
|
||||||
|
(p.nationalCode ?? '').includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [parties, search])
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const totalWallet = parties.reduce((s, p) => s + (p.walletBalance ?? 0), 0)
|
||||||
|
return { count: parties.length, totalWallet }
|
||||||
|
}, [parties])
|
||||||
|
|
||||||
|
const handleBulkSync = async () => {
|
||||||
|
setSyncBusy(true)
|
||||||
|
setSyncMsg(null)
|
||||||
|
try {
|
||||||
|
if (config.kind === 'supplier') {
|
||||||
|
const all = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>)
|
||||||
|
const res = await syncOwnerTransactions(all)
|
||||||
|
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
||||||
|
} else {
|
||||||
|
const all = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
||||||
|
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
|
||||||
|
).then((res) => res.payments_and_refunds ?? [])
|
||||||
|
const res = await syncCustomerActivity(all)
|
||||||
|
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
||||||
|
} finally {
|
||||||
|
setSyncBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveAccounting = async () => {
|
||||||
|
if (!editAccounting?.id) return
|
||||||
|
await upsertPartyAsync(editAccounting)
|
||||||
|
setEditAccounting(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
return (
|
||||||
|
<PartyDetail
|
||||||
|
party={selected}
|
||||||
|
config={config}
|
||||||
|
withdrawals={withdrawals}
|
||||||
|
onBack={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Array<Column<Party>> = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: config.kind === 'supplier' ? 'نام تأمینکننده' : 'نام مشتری',
|
||||||
|
truncate: true,
|
||||||
|
className: 'w-[14rem]',
|
||||||
|
headerClassName: 'w-[14rem]',
|
||||||
|
render: (p) => {
|
||||||
|
const name = getPartyFullName(p)
|
||||||
|
return (
|
||||||
|
<span className="block truncate font-medium text-slate-800" title={name}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'username',
|
||||||
|
header: 'نام کاربری',
|
||||||
|
truncate: true,
|
||||||
|
className: 'w-[8rem]',
|
||||||
|
headerClassName: 'w-[8rem]',
|
||||||
|
render: (p) => (
|
||||||
|
<span className="block truncate text-slate-600" title={p.username || undefined}>
|
||||||
|
{p.username || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mobile',
|
||||||
|
header: 'موبایل',
|
||||||
|
className: 'w-[9.5rem] overflow-hidden whitespace-nowrap',
|
||||||
|
headerClassName: 'w-[9.5rem]',
|
||||||
|
render: (p) => (
|
||||||
|
<span className="block truncate tabular-nums" dir="ltr" title={p.phone || undefined}>
|
||||||
|
{toFa(p.phone) || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'national',
|
||||||
|
header: 'کد ملی',
|
||||||
|
className: 'w-[9rem] overflow-hidden whitespace-nowrap',
|
||||||
|
headerClassName: 'w-[9rem]',
|
||||||
|
render: (p) => (
|
||||||
|
<span className="block truncate font-mono text-sm text-slate-600">{toFa(p.nationalCode ?? '') || '—'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'economic',
|
||||||
|
header: 'کد اقتصادی',
|
||||||
|
className: 'w-[8rem] overflow-hidden whitespace-nowrap',
|
||||||
|
headerClassName: 'w-[8rem]',
|
||||||
|
render: (p) => (
|
||||||
|
<span className="block truncate font-mono text-sm text-slate-600">{toFa(p.economicCode) || '—'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(config.kind === 'supplier'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'venues',
|
||||||
|
header: 'مجموعهها',
|
||||||
|
className: 'w-[6rem] whitespace-nowrap text-center',
|
||||||
|
headerClassName: 'w-[6rem]',
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (p: Party) => toFa(p.venues?.length ?? 0),
|
||||||
|
} as Column<Party>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: 'iban',
|
||||||
|
header: 'شبا',
|
||||||
|
truncate: true,
|
||||||
|
className: 'w-[12rem]',
|
||||||
|
headerClassName: 'w-[12rem]',
|
||||||
|
render: (p) => {
|
||||||
|
const iban = p.iban?.trim()
|
||||||
|
if (!iban) return <span className="text-slate-400">—</span>
|
||||||
|
return (
|
||||||
|
<span className="block truncate font-mono text-sm text-slate-600" dir="ltr" title={iban}>
|
||||||
|
{iban}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wallet',
|
||||||
|
header: 'موجودی کیف پول',
|
||||||
|
align: 'end',
|
||||||
|
className: 'w-[10.5rem] overflow-hidden whitespace-nowrap tabular-nums',
|
||||||
|
headerClassName: 'w-[10.5rem]',
|
||||||
|
render: (p) => (
|
||||||
|
<span className="block truncate" title={formatMoney(p.walletBalance ?? 0)}>
|
||||||
|
{formatMoney(p.walletBalance ?? 0)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'balance',
|
||||||
|
header: 'مانده حساب',
|
||||||
|
align: 'end',
|
||||||
|
className: 'w-[10.5rem] overflow-hidden whitespace-nowrap tabular-nums',
|
||||||
|
headerClassName: 'w-[10.5rem]',
|
||||||
|
render: (p) => {
|
||||||
|
const value = p.id ? formatMoney(partyAccountBalance(p.id, data)) : '—'
|
||||||
|
return (
|
||||||
|
<span className="block truncate font-semibold text-slate-700" title={value}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
header: 'وضعیت',
|
||||||
|
sticky: { left: PARTY_ACTIONS_WIDTH },
|
||||||
|
className: 'w-[7rem] whitespace-nowrap',
|
||||||
|
headerClassName: 'w-[7rem]',
|
||||||
|
render: (p) => <Badge tone={p.isActive ? 'green' : 'slate'}>{p.isActive ? 'فعال' : 'غیرفعال'}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'end',
|
||||||
|
sticky: { left: '0' },
|
||||||
|
className: 'w-[16rem] whitespace-nowrap',
|
||||||
|
headerClassName: 'w-[16rem]',
|
||||||
|
render: (p) => (
|
||||||
|
<div className="flex justify-end gap-2" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
|
||||||
|
{p.id && (
|
||||||
|
<Button variant="ghost" onClick={() => setEditAccounting({ ...p })}>
|
||||||
|
حسابداری
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={() => setSelected(p)}>
|
||||||
|
{config.kind === 'supplier' ? 'تراکنشها' : 'پرداختها'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const tableMinWidth = config.kind === 'supplier' ? '1720px' : '1660px'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="p-5 sm:p-6">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-slate-800">{config.title}</h2>
|
||||||
|
<p className="text-sm text-slate-500">{config.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
|
||||||
|
{syncBusy ? 'در حال صدور…' : 'صدور سند همه تراکنشها'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" icon="↻" disabled={loading || syncing} onClick={() => { void reload(); void syncToAccounting() }}>
|
||||||
|
{syncing ? 'همگامسازی…' : 'بروزرسانی'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{syncMsg && <p className="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{syncMsg}</p>}
|
||||||
|
{error && <p className="mb-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<StatCard label="تعداد" value={toFa(summary.count)} icon="👤" tone="brand" />
|
||||||
|
<StatCard label="مجموع کیف پول" value={formatMoney(summary.totalWallet)} icon="💼" tone="green" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="جستجو بر اساس نام، موبایل، کد ملی…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="mb-4 max-w-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری از فانزون…</p>
|
||||||
|
) : (
|
||||||
|
<div className="-mx-2 overflow-hidden rounded-xl border border-slate-200 sm:-mx-3">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={filtered}
|
||||||
|
rowKey={(p) => p.externalId ?? p.id}
|
||||||
|
onRowClick={(p) => setSelected(p)}
|
||||||
|
size="comfortable"
|
||||||
|
minWidth={tableMinWidth}
|
||||||
|
emptyTitle={config.kind === 'supplier' ? 'تأمینکنندهای یافت نشد' : 'مشتریای یافت نشد'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={editAccounting !== null}
|
||||||
|
title="اطلاعات حسابداری"
|
||||||
|
onClose={() => setEditAccounting(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setEditAccounting(null)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleSaveAccounting()}>ذخیره</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editAccounting && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="کد اقتصادی">
|
||||||
|
<Input
|
||||||
|
value={editAccounting.economicCode}
|
||||||
|
onChange={(e) => setEditAccounting({ ...editAccounting, economicCode: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="مانده اولیه (تومان)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editAccounting.openingBalance}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditAccounting({ ...editAccounting, openingBalance: Number(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="sm:col-span-2 text-xs text-slate-500">
|
||||||
|
سایر فیلدها از اپلیکیشن فانزون خوانده میشوند و در اینجا قابل ویرایش نیستند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartyDetail({
|
||||||
|
party,
|
||||||
|
config,
|
||||||
|
withdrawals,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
party: Party
|
||||||
|
config: FunZonePartyConfig
|
||||||
|
withdrawals: ReturnType<typeof usePartyWithdrawals>
|
||||||
|
onBack: () => void
|
||||||
|
}) {
|
||||||
|
const { data } = useStore()
|
||||||
|
const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync()
|
||||||
|
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
||||||
|
const [syncBusy, setSyncBusy] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [ownerTxns, setOwnerTxns] = useState<ApiOwnerTransaction[]>([])
|
||||||
|
const [customerActivity, setCustomerActivity] = useState<ApiPaymentOrRefund[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const externalId = party.externalId ?? ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!externalId) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
if (config.kind === 'supplier') {
|
||||||
|
const rows = await apiGet<unknown>(ENDPOINTS.OWNER_TRANSACTIONS(externalId)).then(
|
||||||
|
asList<ApiOwnerTransaction>,
|
||||||
|
)
|
||||||
|
setOwnerTxns(rows)
|
||||||
|
} else {
|
||||||
|
const res = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
||||||
|
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(externalId),
|
||||||
|
)
|
||||||
|
setCustomerActivity(res.payments_and_refunds ?? [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'خطا در بارگذاری')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
}, [config.kind, externalId])
|
||||||
|
|
||||||
|
const partyWithdrawals = withdrawals.filter((w) => w.user_id === externalId)
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncBusy(true)
|
||||||
|
try {
|
||||||
|
if (config.kind === 'supplier') {
|
||||||
|
const res = await syncOwnerTransactions(ownerTxns)
|
||||||
|
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
||||||
|
} else {
|
||||||
|
const res = await syncCustomerActivity(customerActivity)
|
||||||
|
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSyncMsg(err instanceof Error ? err.message : 'خطا')
|
||||||
|
} finally {
|
||||||
|
setSyncBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceCount =
|
||||||
|
party.id ? salesDocuments(data.invoices).filter((i) => i.partyId === party.id).length : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="secondary" icon="→" onClick={onBack}>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">{getPartyFullName(party)}</h1>
|
||||||
|
<p className="text-sm text-slate-500">{toFa(party.phone)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" icon="📑" disabled={syncBusy || loading} onClick={() => void handleSync()}>
|
||||||
|
{syncBusy ? 'در حال صدور…' : 'صدور سند'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{syncMsg && <Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<StatCard label="موجودی کیف پول" value={formatMoney(party.walletBalance ?? 0)} icon="💼" tone="brand" />
|
||||||
|
<StatCard
|
||||||
|
label="مانده حساب"
|
||||||
|
value={party.id ? formatMoney(partyAccountBalance(party.id, data)) : '—'}
|
||||||
|
icon="📊"
|
||||||
|
tone="green"
|
||||||
|
/>
|
||||||
|
<StatCard label="فاکتورها" value={toFa(invoiceCount)} icon="🧾" tone="brand" />
|
||||||
|
<StatCard label="برداشتها" value={toFa(partyWithdrawals.length)} icon="🏧" tone="amber" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-5">
|
||||||
|
<h3 className="mb-4 font-bold text-slate-800">مشخصات {config.kind === 'supplier' ? 'تأمینکننده' : 'مشتری'}</h3>
|
||||||
|
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<InfoItem label="نام کاربری" value={party.username} />
|
||||||
|
<InfoItem label="ایمیل" value={party.email} />
|
||||||
|
<InfoItem label="کد ملی" value={party.nationalCode ? toFa(party.nationalCode) : undefined} />
|
||||||
|
<InfoItem label="کد اقتصادی" value={party.economicCode ? toFa(party.economicCode) : undefined} />
|
||||||
|
<InfoItem label="شبا" value={party.iban} />
|
||||||
|
<InfoItem label="آدرس" value={party.address} />
|
||||||
|
<InfoItem label="تاریخ تولد" value={party.birthday ? formatDate(party.birthday) : undefined} />
|
||||||
|
{config.kind === 'supplier' && (
|
||||||
|
<InfoItem label="مجموعهها" value={party.venues?.join('، ') || '—'} />
|
||||||
|
)}
|
||||||
|
<InfoItem label="وضعیت" value={party.isActive ? 'فعال' : 'غیرفعال'} />
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="mb-3 text-lg font-bold text-slate-800">
|
||||||
|
{config.kind === 'supplier' ? 'گردش حساب کیف پول' : 'پرداختها و بازپرداختها'}
|
||||||
|
</h2>
|
||||||
|
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری…</p>
|
||||||
|
) : config.kind === 'supplier' ? (
|
||||||
|
<DataTable
|
||||||
|
columns={ownerTxnColumns}
|
||||||
|
rows={ownerTxns}
|
||||||
|
rowKey={(t) => t.id}
|
||||||
|
emptyTitle="تراکنشی ثبت نشده است"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={customerActivityColumns}
|
||||||
|
rows={customerActivity}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyTitle="پرداختی ثبت نشده است"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value }: { label: string; value?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-slate-400">{label}</dt>
|
||||||
|
<dd className="font-medium text-slate-700">{value || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerTxnColumns: Array<Column<ApiOwnerTransaction>> = [
|
||||||
|
{ key: 'date', header: 'تاریخ', render: (t) => formatDateTime(t.created_at) },
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'نوع',
|
||||||
|
render: (t) => <Badge tone="blue">{txnTypeLabels[t.type] ?? t.type}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
header: 'مبلغ',
|
||||||
|
align: 'end',
|
||||||
|
render: (t) => <span className="tabular-nums font-semibold">{formatMoney(t.amount)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'وضعیت',
|
||||||
|
render: (t) => (
|
||||||
|
<Badge tone={t.status === 'completed' ? 'green' : t.status === 'pending' ? 'amber' : 'rose'}>
|
||||||
|
{t.status === 'completed' ? 'انجامشده' : t.status === 'pending' ? 'در انتظار' : 'ناموفق'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'desc', header: 'توضیحات', render: (t) => t.description || '—' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const customerActivityColumns: Array<Column<ApiPaymentOrRefund>> = [
|
||||||
|
{ key: 'date', header: 'تاریخ', render: (r) => formatDateTime(r.created_at) },
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: 'نوع',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge tone={r.type === 'payment' ? 'blue' : 'amber'}>{txnTypeLabels[r.type] ?? r.type}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'event', header: 'رویداد', render: (r) => (r.event_name === 'wallet' ? 'کیف پول' : r.event_name) },
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
header: 'مبلغ',
|
||||||
|
align: 'end',
|
||||||
|
render: (r) => <span className="tabular-nums font-semibold">{formatMoney(r.amount)}</span>,
|
||||||
|
},
|
||||||
|
]
|
||||||
51
src/parties/GlobalPartySync.tsx
Normal file
51
src/parties/GlobalPartySync.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { acct, asList, apiGet } from '../api/client'
|
||||||
|
import { ENDPOINTS, ACCOUNTING_ENDPOINTS } from '../api/config'
|
||||||
|
import type { ApiCustomer, ApiOwner } from '../api/types'
|
||||||
|
import type { Party } from '../types'
|
||||||
|
import { useStore } from '../store/AppStore'
|
||||||
|
import { createId } from '../utils/id'
|
||||||
|
import { findExistingParty, mergeFunZoneParties, partyNeedsSync } from './funzonePartySync'
|
||||||
|
|
||||||
|
/** Syncs FunZone owners/customers into accounting parties on app load. */
|
||||||
|
export function GlobalPartySync() {
|
||||||
|
const { ready, upsertPartyAsync, reload } = useStore()
|
||||||
|
const synced = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || synced.current) return
|
||||||
|
synced.current = true
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const [owners, customersRaw, currentParties] = await Promise.all([
|
||||||
|
apiGet<unknown>(ENDPOINTS.OWNERS).then(asList<ApiOwner>),
|
||||||
|
apiGet<unknown>(ENDPOINTS.CUSTOMERS),
|
||||||
|
acct.get(ACCOUNTING_ENDPOINTS.PARTIES).then(asList<Party>),
|
||||||
|
])
|
||||||
|
|
||||||
|
const customers: ApiCustomer[] = Array.isArray(customersRaw)
|
||||||
|
? (customersRaw as ApiCustomer[])
|
||||||
|
: ((customersRaw as { results?: ApiCustomer[] }).results ?? [])
|
||||||
|
|
||||||
|
const batches = [
|
||||||
|
...mergeFunZoneParties(owners, currentParties, 'supplier'),
|
||||||
|
...mergeFunZoneParties(customers, currentParties, 'customer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const incoming of batches) {
|
||||||
|
const existing = findExistingParty(currentParties, incoming.externalId!, incoming.externalSource)
|
||||||
|
if (existing && !partyNeedsSync(existing, incoming)) continue
|
||||||
|
await upsertPartyAsync({ ...incoming, id: existing?.id ?? createId('party-') })
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
} catch {
|
||||||
|
synced.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run()
|
||||||
|
}, [ready, upsertPartyAsync, reload])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
106
src/parties/funzonePartySync.ts
Normal file
106
src/parties/funzonePartySync.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { ApiCustomer, ApiOwner } from '../api/types'
|
||||||
|
import { customerFullName, ownerFullName } from '../api/types'
|
||||||
|
import type { Party, PartyKind } from '../types'
|
||||||
|
|
||||||
|
export function partyDisplayName(party: Party): string {
|
||||||
|
if (party.firstName || party.lastName) {
|
||||||
|
return `${party.firstName ?? ''} ${party.lastName ?? ''}`.trim()
|
||||||
|
}
|
||||||
|
return party.name
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ownerToParty(owner: ApiOwner, existing?: Party): Party {
|
||||||
|
return {
|
||||||
|
id: existing?.id ?? '',
|
||||||
|
kind: 'supplier',
|
||||||
|
name: ownerFullName(owner),
|
||||||
|
phone: owner.mobile_number ?? '',
|
||||||
|
economicCode: existing?.economicCode ?? '',
|
||||||
|
address: owner.address ?? '',
|
||||||
|
openingBalance: existing?.openingBalance ?? 0,
|
||||||
|
externalId: owner.id,
|
||||||
|
externalSource: 'funzone_owner',
|
||||||
|
firstName: owner.f_name ?? '',
|
||||||
|
lastName: owner.l_name ?? '',
|
||||||
|
username: owner.username ?? '',
|
||||||
|
email: owner.email ?? '',
|
||||||
|
nationalCode: owner.national_code ?? '',
|
||||||
|
iban: owner.credit_number ?? '',
|
||||||
|
walletBalance: owner.balance ?? 0,
|
||||||
|
isActive: owner.is_active,
|
||||||
|
isVerified: owner.is_verified ?? false,
|
||||||
|
birthday: owner.birthday ?? null,
|
||||||
|
venues: owner.social_hubs ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customerToParty(customer: ApiCustomer, existing?: Party): Party {
|
||||||
|
const nationalCode =
|
||||||
|
customer.national_code != null ? String(customer.national_code) : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existing?.id ?? '',
|
||||||
|
kind: 'customer',
|
||||||
|
name: customerFullName(customer),
|
||||||
|
phone: customer.mobile_number ?? '',
|
||||||
|
economicCode: existing?.economicCode ?? '',
|
||||||
|
address: customer.address ?? '',
|
||||||
|
openingBalance: existing?.openingBalance ?? 0,
|
||||||
|
externalId: customer.id,
|
||||||
|
externalSource: 'funzone_customer',
|
||||||
|
firstName: customer.f_name ?? '',
|
||||||
|
lastName: customer.l_name ?? '',
|
||||||
|
username: customer.username ?? '',
|
||||||
|
email: customer.email ?? '',
|
||||||
|
nationalCode,
|
||||||
|
iban: customer.iban ?? '',
|
||||||
|
walletBalance: customer.balance ?? 0,
|
||||||
|
isActive: customer.is_active,
|
||||||
|
isVerified: customer.is_verified ?? false,
|
||||||
|
birthday: customer.birthday ?? null,
|
||||||
|
venues: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findExistingParty(
|
||||||
|
parties: Party[],
|
||||||
|
externalId: string,
|
||||||
|
externalSource: Party['externalSource'],
|
||||||
|
): Party | undefined {
|
||||||
|
return parties.find((p) => p.externalId === externalId && p.externalSource === externalSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFunZoneParties(
|
||||||
|
apiRecords: Array<ApiOwner | ApiCustomer>,
|
||||||
|
existingParties: Party[],
|
||||||
|
kind: PartyKind,
|
||||||
|
): Party[] {
|
||||||
|
return apiRecords.map((record) => {
|
||||||
|
const externalSource = kind === 'supplier' ? 'funzone_owner' : 'funzone_customer'
|
||||||
|
const existing = findExistingParty(existingParties, record.id, externalSource)
|
||||||
|
return kind === 'supplier'
|
||||||
|
? ownerToParty(record as ApiOwner, existing)
|
||||||
|
: customerToParty(record as ApiCustomer, existing)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when party data from FunZone differs from the stored accounting record. */
|
||||||
|
export function partyNeedsSync(stored: Party, incoming: Party): boolean {
|
||||||
|
const keys: Array<keyof Party> = [
|
||||||
|
'name',
|
||||||
|
'phone',
|
||||||
|
'address',
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'username',
|
||||||
|
'email',
|
||||||
|
'nationalCode',
|
||||||
|
'iban',
|
||||||
|
'walletBalance',
|
||||||
|
'isActive',
|
||||||
|
'isVerified',
|
||||||
|
'birthday',
|
||||||
|
]
|
||||||
|
if (JSON.stringify(stored.venues ?? []) !== JSON.stringify(incoming.venues ?? [])) return true
|
||||||
|
return keys.some((key) => stored[key] !== incoming[key])
|
||||||
|
}
|
||||||
107
src/parties/useFunZoneParties.ts
Normal file
107
src/parties/useFunZoneParties.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { apiGet, asList } from '../api/client'
|
||||||
|
import { ENDPOINTS } from '../api/config'
|
||||||
|
import type { ApiCustomer, ApiOwner } from '../api/types'
|
||||||
|
import { useStore } from '../store/AppStore'
|
||||||
|
import type { PartyKind } from '../types'
|
||||||
|
import { createId } from '../utils/id'
|
||||||
|
import {
|
||||||
|
findExistingParty,
|
||||||
|
mergeFunZoneParties,
|
||||||
|
partyDisplayName,
|
||||||
|
partyNeedsSync,
|
||||||
|
} from './funzonePartySync'
|
||||||
|
import { partyAccountBalance, partyReceivable, salesDocuments } from '../pages/sales/salesUtils'
|
||||||
|
|
||||||
|
export interface FunZonePartyConfig {
|
||||||
|
kind: PartyKind
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
externalSource: 'funzone_customer' | 'funzone_owner'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApiRecords(kind: PartyKind): Promise<Array<ApiOwner | ApiCustomer>> {
|
||||||
|
if (kind === 'supplier') {
|
||||||
|
return apiGet<unknown>(ENDPOINTS.OWNERS).then(asList<ApiOwner>)
|
||||||
|
}
|
||||||
|
const data = await apiGet<unknown>(ENDPOINTS.CUSTOMERS)
|
||||||
|
if (Array.isArray(data)) return data as ApiCustomer[]
|
||||||
|
const record = data as { results?: ApiCustomer[] }
|
||||||
|
return record.results ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFunZoneParties(config: FunZonePartyConfig) {
|
||||||
|
const { data, upsertPartyAsync, ready } = useStore()
|
||||||
|
const [apiRecords, setApiRecords] = useState<Array<ApiOwner | ApiCustomer>>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
setApiRecords(await fetchApiRecords(config.kind))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'خطا در بارگذاری از فانزون')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [config.kind])
|
||||||
|
|
||||||
|
const syncToAccounting = useCallback(async () => {
|
||||||
|
if (apiRecords.length === 0) return
|
||||||
|
setSyncing(true)
|
||||||
|
try {
|
||||||
|
const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)
|
||||||
|
for (const incoming of merged) {
|
||||||
|
const existing = findExistingParty(data.parties, incoming.externalId!, incoming.externalSource)
|
||||||
|
if (existing && !partyNeedsSync(existing, incoming)) continue
|
||||||
|
await upsertPartyAsync({ ...incoming, id: existing?.id ?? createId('party-') })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSyncing(false)
|
||||||
|
}
|
||||||
|
}, [apiRecords, config.kind, data.parties, upsertPartyAsync])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) void load()
|
||||||
|
}, [ready, load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready && apiRecords.length > 0) void syncToAccounting()
|
||||||
|
}, [ready, apiRecords, syncToAccounting])
|
||||||
|
|
||||||
|
const parties = useMemo(() => {
|
||||||
|
const merged = mergeFunZoneParties(apiRecords, data.parties, config.kind)
|
||||||
|
return merged.map((incoming) => {
|
||||||
|
const stored = findExistingParty(data.parties, incoming.externalId!, incoming.externalSource)
|
||||||
|
return stored
|
||||||
|
? { ...incoming, id: stored.id, openingBalance: stored.openingBalance, economicCode: stored.economicCode }
|
||||||
|
: incoming
|
||||||
|
})
|
||||||
|
}, [apiRecords, data.parties, config.kind])
|
||||||
|
|
||||||
|
return { parties, loading, syncing, error, reload: load, syncToAccounting }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePartyWithdrawals(kind: PartyKind) {
|
||||||
|
const [withdrawals, setWithdrawals] = useState<
|
||||||
|
import('../api/types').ApiWithdrawal[]
|
||||||
|
>([])
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet<{ withdrawals: import('../api/types').ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
|
||||||
|
.then((res) => {
|
||||||
|
const userType = kind === 'supplier' ? 'owner' : 'customer'
|
||||||
|
setWithdrawals((res.withdrawals ?? []).filter((w) => w.user_type === userType))
|
||||||
|
})
|
||||||
|
.catch(() => setWithdrawals([]))
|
||||||
|
}, [kind])
|
||||||
|
return withdrawals
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPartyFullName(party: import('../types').Party): string {
|
||||||
|
return partyDisplayName(party) || party.name
|
||||||
|
}
|
||||||
|
|
||||||
|
export { partyAccountBalance, partyReceivable, salesDocuments }
|
||||||
@@ -15,8 +15,10 @@ import type {
|
|||||||
Employee,
|
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),
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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
36
src/utils/funzoneFees.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/** FunZone event payment fee rates (aligned with admin panel & owner export). */
|
||||||
|
export const FUNZONE_EVENT_TAX_RATE = 0.1
|
||||||
|
export const FUNZONE_PLATFORM_PROFIT_RATE = 0.14
|
||||||
|
|
||||||
|
export interface EventPaymentSplit {
|
||||||
|
gross: number
|
||||||
|
tax: number
|
||||||
|
platformProfit: number
|
||||||
|
ownerNet: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMoney(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits gross event ticket payment: tax 10% + platform profit 14% + owner net 76%. */
|
||||||
|
export function splitEventPayment(gross: number): EventPaymentSplit {
|
||||||
|
const tax = roundMoney(gross * FUNZONE_EVENT_TAX_RATE)
|
||||||
|
const platformProfit = roundMoney(gross * FUNZONE_PLATFORM_PROFIT_RATE)
|
||||||
|
const ownerNet = roundMoney(gross - tax - platformProfit)
|
||||||
|
return { gross, tax, platformProfit, ownerNet }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventCustomerPayment(eventName: string | null | undefined): boolean {
|
||||||
|
return Boolean(eventName && eventName.toLowerCase() !== 'wallet')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner wallet credits already reflected in customer receipts — exclude from treasury. */
|
||||||
|
export function ownerTxnExcludedFromTreasury(type: string): boolean {
|
||||||
|
return type === 'deposit' || type === 'booking_payment'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actual payouts / chargebacks shown in پرداخت. */
|
||||||
|
export function ownerTxnIncludedInTreasury(type: string): boolean {
|
||||||
|
return type === 'withdraw' || type === 'refund'
|
||||||
|
}
|
||||||
44
src/utils/solarHijriCalendar.ts
Normal file
44
src/utils/solarHijriCalendar.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Solar Hijri (Persian) calendar helpers — aligned with FunZone frontends.
|
||||||
|
* Stores dates as Gregorian ISO (yyyy-mm-dd); displays and picks in Jalali.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toGregorian, toJalaali } from 'jalaali-js'
|
||||||
|
|
||||||
|
/** Parses yyyy-mm-dd (or ISO datetime) as local calendar date (no UTC shift). */
|
||||||
|
export function parseISODateLocal(iso: string): Date {
|
||||||
|
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
|
||||||
|
if (!y || !m || !d) return new Date(NaN)
|
||||||
|
return new Date(y, m - 1, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a Date as local Gregorian yyyy-mm-dd for storage/API. */
|
||||||
|
export function dateToISO(date: Date): string {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's date as yyyy-mm-dd in local timezone. */
|
||||||
|
export function todayISO(): string {
|
||||||
|
return dateToISO(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gregorian ISO → Jalali { year, month, day }. */
|
||||||
|
export function gregorianISOToJalali(iso: string): { year: number; month: number; day: number } {
|
||||||
|
const date = parseISODateLocal(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
const now = new Date()
|
||||||
|
const j = toJalaali(now.getFullYear(), now.getMonth() + 1, now.getDate())
|
||||||
|
return { year: j.jy, month: j.jm, day: j.jd }
|
||||||
|
}
|
||||||
|
const j = toJalaali(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
||||||
|
return { year: j.jy, month: j.jm, day: j.jd }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jalali y/m/d → Gregorian ISO yyyy-mm-dd. */
|
||||||
|
export function jalaliToGregorianISO(year: number, month: number, day: number): string {
|
||||||
|
const g = toGregorian(year, month, day)
|
||||||
|
return dateToISO(new Date(g.gy, g.gm - 1, g.gd))
|
||||||
|
}
|
||||||
@@ -18,5 +18,6 @@
|
|||||||
"forceConsistentCasingInFileNames": true
|
"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
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/accounting/vouchersync.ts","./src/api/client.ts","./src/api/config.ts","./src/api/types.ts","./src/components/business/connectionrequired.tsx","./src/components/business/trademodule.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/navigation.ts","./src/components/ui/confirmdialog.tsx","./src/components/ui/datatable.tsx","./src/components/ui/modal.tsx","./src/components/ui/index.ts","./src/components/ui/primitives.tsx","./src/connection/connectioncontext.tsx","./src/hooks/useapiresource.ts","./src/pages/accounts.tsx","./src/pages/customers.tsx","./src/pages/dashboard.tsx","./src/pages/inventory.tsx","./src/pages/login.tsx","./src/pages/owners.tsx","./src/pages/payroll.tsx","./src/pages/purchases.tsx","./src/pages/reports.tsx","./src/pages/sales.tsx","./src/pages/settings.tsx","./src/pages/treasury.tsx","./src/pages/vouchers.tsx","./src/store/appstore.tsx","./src/types/index.ts","./src/utils/accounting.ts","./src/utils/format.ts","./src/utils/id.ts"],"version":"5.6.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/accounting/vouchersync.ts","./src/api/client.ts","./src/api/config.ts","./src/api/types.ts","./src/components/business/connectionrequired.tsx","./src/components/business/trademodule.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/navigation.ts","./src/components/ui/confirmdialog.tsx","./src/components/ui/datatable.tsx","./src/components/ui/jalalidateinput.tsx","./src/components/ui/modal.tsx","./src/components/ui/index.ts","./src/components/ui/primitives.tsx","./src/connection/connectioncontext.tsx","./src/hooks/useapiresource.ts","./src/pages/accounts.tsx","./src/pages/dashboard.tsx","./src/pages/inventory.tsx","./src/pages/login.tsx","./src/pages/payroll.tsx","./src/pages/purchases.tsx","./src/pages/reports.tsx","./src/pages/sales.tsx","./src/pages/settings.tsx","./src/pages/treasury.tsx","./src/pages/vouchers.tsx","./src/pages/purchases/purchasesuppliers.tsx","./src/pages/purchases/purchaseslayout.tsx","./src/pages/sales/partyadjustmentspage.tsx","./src/pages/sales/pendingwithdrawalspanel.tsx","./src/pages/sales/salescustomers.tsx","./src/pages/sales/salesdocumentsection.tsx","./src/pages/sales/salesdocuments.tsx","./src/pages/sales/saleslayout.tsx","./src/pages/sales/salesprocess.tsx","./src/pages/sales/salesreview.tsx","./src/pages/sales/salestypespage.tsx","./src/pages/sales/invoiceeffects.ts","./src/pages/sales/salesnavigation.ts","./src/pages/sales/salesutils.ts","./src/pages/sales/usependingcustomerwithdrawals.ts","./src/pages/treasury/funzonetreasurysync.ts","./src/pages/treasury/usefunzonetreasurysync.ts","./src/parties/funzonepartysection.tsx","./src/parties/globalpartysync.tsx","./src/parties/funzonepartysync.ts","./src/parties/usefunzoneparties.ts","./src/store/appstore.tsx","./src/types/index.ts","./src/utils/accounting.ts","./src/utils/format.ts","./src/utils/funzonefees.ts","./src/utils/id.ts","./src/utils/solarhijricalendar.ts"],"version":"5.6.3"}
|
||||||
Reference in New Issue
Block a user