Add FunZone sync for sales and purchases, voucher filters, and event-name support.
Manual sync buttons pull customer payments and owner payouts into treasury, invoices, and accounting vouchers; voucher page gains category, date, status, and search filters; supplier vouchers dedupe against treasury-backed entries. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import type { Invoice, Party, Voucher, VoucherAccountMap, VoucherLine } from '../types'
|
import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types'
|
||||||
|
import { treasurySourceOwnerTxn } from '../pages/treasury/funzoneTreasurySync'
|
||||||
|
import { voucherSourceForTreasury } from '../pages/sales/salesUtils'
|
||||||
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 { invoiceTotals } from '../utils/accounting'
|
||||||
|
import { formatEventName } from '../utils/eventName'
|
||||||
import { isWithdrawalReturn } from '../pages/sales/salesUtils'
|
import { isWithdrawalReturn } from '../pages/sales/salesUtils'
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
@@ -39,25 +42,39 @@ function makeVoucher(
|
|||||||
description: string,
|
description: string,
|
||||||
lines: VoucherLine[],
|
lines: VoucherLine[],
|
||||||
source: string,
|
source: string,
|
||||||
|
regarding?: string,
|
||||||
): Voucher {
|
): Voucher {
|
||||||
return {
|
return {
|
||||||
id: createId('v-'),
|
id: createId('v-'),
|
||||||
number,
|
number,
|
||||||
date: isoDate.slice(0, 10),
|
date: isoDate.slice(0, 10),
|
||||||
description,
|
description,
|
||||||
|
regarding,
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source,
|
source,
|
||||||
lines,
|
lines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ownerTxnAlreadyBookedViaTreasury(
|
||||||
|
treasury: TreasuryTxn[],
|
||||||
|
vouchers: Voucher[],
|
||||||
|
txnId: string,
|
||||||
|
): boolean {
|
||||||
|
const treasuryTxn = treasury.find((t) => t.source === treasurySourceOwnerTxn(txnId))
|
||||||
|
if (!treasuryTxn) return false
|
||||||
|
return vouchers.some((v) => v.source === voucherSourceForTreasury(treasuryTxn.id))
|
||||||
|
}
|
||||||
|
|
||||||
function buildOwnerVouchers(
|
function buildOwnerVouchers(
|
||||||
records: ApiOwnerTransaction[],
|
records: ApiOwnerTransaction[],
|
||||||
map: VoucherAccountMap,
|
map: VoucherAccountMap,
|
||||||
existingSources: Set<string>,
|
existingSources: Set<string>,
|
||||||
startNumber: number,
|
startNumber: number,
|
||||||
|
treasury: TreasuryTxn[],
|
||||||
|
vouchers: Voucher[],
|
||||||
): { vouchers: Voucher[]; skipped: number } {
|
): { vouchers: Voucher[]; skipped: number } {
|
||||||
const vouchers: Voucher[] = []
|
const vouchersOut: Voucher[] = []
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
let number = startNumber
|
let number = startNumber
|
||||||
|
|
||||||
@@ -67,8 +84,8 @@ function buildOwnerVouchers(
|
|||||||
const amount = Math.abs(txn.amount)
|
const amount = Math.abs(txn.amount)
|
||||||
if (amount <= 0) continue
|
if (amount <= 0) continue
|
||||||
|
|
||||||
const source = `funzone:owner-txn:${txn.id}`
|
const source = treasurySourceOwnerTxn(txn.id)
|
||||||
if (existingSources.has(source)) {
|
if (existingSources.has(source) || ownerTxnAlreadyBookedViaTreasury(treasury, vouchers, txn.id)) {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -94,11 +111,11 @@ function buildOwnerVouchers(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
vouchers.push(makeVoucher(number, txn.created_at, description, lines, source))
|
vouchersOut.push(makeVoucher(number, txn.created_at, description, lines, source))
|
||||||
number += 1
|
number += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return { vouchers, skipped }
|
return { vouchers: vouchersOut, skipped }
|
||||||
}
|
}
|
||||||
|
|
||||||
function walletWithdrawAlreadyBooked(
|
function walletWithdrawAlreadyBooked(
|
||||||
@@ -141,6 +158,7 @@ function buildCustomerVouchers(
|
|||||||
|
|
||||||
const isWallet = (item.event_name || '').toLowerCase() === 'wallet'
|
const isWallet = (item.event_name || '').toLowerCase() === 'wallet'
|
||||||
const label = item.customer_name || 'مشتری'
|
const label = item.customer_name || 'مشتری'
|
||||||
|
const regarding = isWallet ? 'wallet' : item.event_name || undefined
|
||||||
let lines: VoucherLine[]
|
let lines: VoucherLine[]
|
||||||
let description: string
|
let description: string
|
||||||
|
|
||||||
@@ -154,7 +172,7 @@ function buildCustomerVouchers(
|
|||||||
} else {
|
} else {
|
||||||
lines = [
|
lines = [
|
||||||
makeLine(map.bankAccountId, amount, 0, 'دریافت بابت رزرو'),
|
makeLine(map.bankAccountId, amount, 0, 'دریافت بابت رزرو'),
|
||||||
makeLine(map.salesIncomeAccountId, 0, amount, `درآمد - ${item.event_name}`),
|
makeLine(map.salesIncomeAccountId, 0, amount, `درآمد - ${formatEventName(item.event_name)}`),
|
||||||
]
|
]
|
||||||
description = `فروش/رزرو - ${label}`
|
description = `فروش/رزرو - ${label}`
|
||||||
}
|
}
|
||||||
@@ -170,13 +188,13 @@ function buildCustomerVouchers(
|
|||||||
description = `برداشت کیف پول - ${label}`
|
description = `برداشت کیف پول - ${label}`
|
||||||
} else {
|
} else {
|
||||||
lines = [
|
lines = [
|
||||||
makeLine(map.salesIncomeAccountId, amount, 0, `کاهش درآمد - ${item.event_name}`),
|
makeLine(map.salesIncomeAccountId, amount, 0, `کاهش درآمد - ${formatEventName(item.event_name)}`),
|
||||||
makeLine(map.bankAccountId, 0, amount, 'بازپرداخت'),
|
makeLine(map.bankAccountId, 0, amount, 'بازپرداخت'),
|
||||||
]
|
]
|
||||||
description = `بازپرداخت رزرو - ${label}`
|
description = `بازپرداخت رزرو - ${label}`
|
||||||
}
|
}
|
||||||
|
|
||||||
vouchers.push(makeVoucher(number, item.created_at, description, lines, source))
|
vouchers.push(makeVoucher(number, item.created_at, description, lines, source, regarding))
|
||||||
number += 1
|
number += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,8 +233,10 @@ export function useVoucherSync() {
|
|||||||
|
|
||||||
const syncOwnerTransactions = useCallback(
|
const syncOwnerTransactions = useCallback(
|
||||||
(records: ApiOwnerTransaction[]): Promise<SyncResult> =>
|
(records: ApiOwnerTransaction[]): Promise<SyncResult> =>
|
||||||
run((map, sources, start) => buildOwnerVouchers(records, map, sources, start)),
|
run((map, sources, start) =>
|
||||||
[run],
|
buildOwnerVouchers(records, map, sources, start, data.treasury, data.vouchers),
|
||||||
|
),
|
||||||
|
[run, data.treasury, data.vouchers],
|
||||||
)
|
)
|
||||||
|
|
||||||
const syncCustomerActivity = useCallback(
|
const syncCustomerActivity = useCallback(
|
||||||
|
|||||||
55
src/components/business/InvoiceLineGoodsCell.tsx
Normal file
55
src/components/business/InvoiceLineGoodsCell.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { InvoiceLine, Product } from '../../types'
|
||||||
|
import { Input, Select } from '../ui'
|
||||||
|
import { formatEventName, normalizeEventName } from '../../utils/eventName'
|
||||||
|
import { toFa } from '../../utils/format'
|
||||||
|
|
||||||
|
interface InvoiceLineGoodsCellProps {
|
||||||
|
line: InvoiceLine
|
||||||
|
products: Product[]
|
||||||
|
/** Sale documents (invoice / return / proforma) — show event name field. */
|
||||||
|
showEventName?: boolean
|
||||||
|
/** Wallet withdrawal return — event name only, no product picker. */
|
||||||
|
withdrawalReturn?: boolean
|
||||||
|
onChange: (patch: Partial<InvoiceLine>) => void
|
||||||
|
onSelectProduct: (productId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoiceLineGoodsCell({
|
||||||
|
line,
|
||||||
|
products,
|
||||||
|
showEventName = false,
|
||||||
|
withdrawalReturn = false,
|
||||||
|
onChange,
|
||||||
|
onSelectProduct,
|
||||||
|
}: InvoiceLineGoodsCellProps) {
|
||||||
|
if (withdrawalReturn) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={line.eventName ? formatEventName(line.eventName) : ''}
|
||||||
|
onChange={(e) => onChange({ eventName: normalizeEventName(e.target.value) || 'wallet' })}
|
||||||
|
placeholder="رویداد / بابت"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{showEventName && (
|
||||||
|
<Input
|
||||||
|
value={line.eventName ? formatEventName(line.eventName) : ''}
|
||||||
|
onChange={(e) => onChange({ eventName: normalizeEventName(e.target.value) || undefined })}
|
||||||
|
placeholder="رویداد (بابت پرداخت)"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Select value={line.productId} onChange={(e) => onSelectProduct(e.target.value)}>
|
||||||
|
<option value="">انتخاب کالا…</option>
|
||||||
|
{products.map((product) => (
|
||||||
|
<option key={product.id} value={product.id}>
|
||||||
|
{product.name} (موجودی: {toFa(product.stock)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,8 +19,10 @@ import type { Invoice, InvoiceKind, InvoiceLine, InvoiceStatus, Party } from '..
|
|||||||
import { createId, nextNumber } from '../../utils/id'
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
import { formatDate, formatMoney, todayISO, 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 { customers, reconcileInvoicePaymentStatus, suppliers, invoiceRegarding } from '../../pages/sales/salesUtils'
|
||||||
import { invoiceSideEffects, invoiceDeleteEffects } from '../../pages/sales/invoiceEffects'
|
import { invoiceSideEffects, invoiceDeleteEffects } from '../../pages/sales/invoiceEffects'
|
||||||
|
import { InvoiceLineGoodsCell } from './InvoiceLineGoodsCell'
|
||||||
|
import { formatEventName } from '../../utils/eventName'
|
||||||
|
|
||||||
interface TradeConfig {
|
interface TradeConfig {
|
||||||
kind: InvoiceKind
|
kind: InvoiceKind
|
||||||
@@ -154,7 +156,12 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
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) => {
|
||||||
|
if (config.kind === 'sale') {
|
||||||
|
return Boolean(line.productId || line.eventName) && line.quantity > 0
|
||||||
|
}
|
||||||
|
return Boolean(line.productId) && line.quantity > 0
|
||||||
|
})
|
||||||
if (cleanLines.length === 0) return
|
if (cleanLines.length === 0) return
|
||||||
|
|
||||||
const saved: Invoice = {
|
const saved: Invoice = {
|
||||||
@@ -195,6 +202,18 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
|||||||
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
||||||
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
||||||
{ key: 'party', header: config.partyLabel, render: (i) => partyName(i.partyId) },
|
{ key: 'party', header: config.partyLabel, render: (i) => partyName(i.partyId) },
|
||||||
|
...(config.kind === 'sale'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'regarding',
|
||||||
|
header: 'بابت',
|
||||||
|
render: (i: Invoice) => {
|
||||||
|
const regarding = invoiceRegarding(i)
|
||||||
|
return regarding ? formatEventName(regarding) : '—'
|
||||||
|
},
|
||||||
|
} as Column<Invoice>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: 'count',
|
key: 'count',
|
||||||
header: 'اقلام',
|
header: 'اقلام',
|
||||||
@@ -315,7 +334,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-slate-50 text-slate-500">
|
<thead className="bg-slate-50 text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-start font-medium">کالا</th>
|
<th className="px-3 py-2 text-start font-medium">{config.kind === 'sale' ? 'کالا / رویداد' : 'کالا'}</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 text-end font-medium">تخفیف</th>
|
||||||
@@ -328,14 +347,13 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
|
|||||||
{draft.lines.map((line) => (
|
{draft.lines.map((line) => (
|
||||||
<tr key={line.id}>
|
<tr key={line.id}>
|
||||||
<td className="px-2 py-1.5 min-w-[180px]">
|
<td className="px-2 py-1.5 min-w-[180px]">
|
||||||
<Select value={line.productId} onChange={(e) => onSelectProduct(line.id, e.target.value)}>
|
<InvoiceLineGoodsCell
|
||||||
<option value="">انتخاب کالا…</option>
|
line={line}
|
||||||
{data.products.map((product) => (
|
products={data.products}
|
||||||
<option key={product.id} value={product.id}>
|
showEventName={config.kind === 'sale'}
|
||||||
{product.name}
|
onChange={(patch) => updateLine(line.id, patch)}
|
||||||
</option>
|
onSelectProduct={(productId) => onSelectProduct(line.id, productId)}
|
||||||
))}
|
/>
|
||||||
</Select>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 w-24">
|
<td className="px-2 py-1.5 w-24">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -12,12 +12,26 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Select,
|
Select,
|
||||||
|
StatCard,
|
||||||
type Column,
|
type Column,
|
||||||
} 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, todayISO, toFa } from '../utils/format'
|
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format'
|
||||||
import { voucherBalance } from '../utils/accounting'
|
import { voucherBalance } from '../utils/accounting'
|
||||||
|
import { resolveVoucherRegarding } from '../utils/voucherRegarding'
|
||||||
|
import { normalizeEventName } from '../utils/eventName'
|
||||||
|
import {
|
||||||
|
classifyVoucher,
|
||||||
|
defaultVoucherFilters,
|
||||||
|
filterVouchers,
|
||||||
|
isAutomatedVoucher,
|
||||||
|
type VoucherCategoryFilter,
|
||||||
|
type VoucherOriginFilter,
|
||||||
|
type VoucherStatusFilter,
|
||||||
|
voucherCategoryLabel,
|
||||||
|
voucherFilterSummary,
|
||||||
|
} from '../utils/voucherFilters'
|
||||||
|
|
||||||
const newLine = (): VoucherLine => ({
|
const newLine = (): VoucherLine => ({
|
||||||
id: createId('vl-'),
|
id: createId('vl-'),
|
||||||
@@ -33,6 +47,7 @@ function createDraft(items: Voucher[]): Voucher {
|
|||||||
number: nextNumber(items),
|
number: nextNumber(items),
|
||||||
date: todayISO(),
|
date: todayISO(),
|
||||||
description: '',
|
description: '',
|
||||||
|
regarding: '',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
lines: [newLine(), newLine()],
|
lines: [newLine(), newLine()],
|
||||||
}
|
}
|
||||||
@@ -42,6 +57,12 @@ export function Vouchers() {
|
|||||||
const { data, upsertVoucher, removeVoucher } = useStore()
|
const { data, upsertVoucher, removeVoucher } = useStore()
|
||||||
const [draft, setDraft] = useState<Voucher | null>(null)
|
const [draft, setDraft] = useState<Voucher | null>(null)
|
||||||
const [toDelete, setToDelete] = useState<Voucher | null>(null)
|
const [toDelete, setToDelete] = useState<Voucher | null>(null)
|
||||||
|
const [category, setCategory] = useState<VoucherCategoryFilter>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<VoucherStatusFilter>('all')
|
||||||
|
const [origin, setOrigin] = useState<VoucherOriginFilter>('all')
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
const leafAccounts = useMemo(
|
const leafAccounts = useMemo(
|
||||||
() => data.accounts.filter((account) => !account.isGroup),
|
() => data.accounts.filter((account) => !account.isGroup),
|
||||||
@@ -49,10 +70,37 @@ export function Vouchers() {
|
|||||||
)
|
)
|
||||||
const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
|
const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
|
||||||
|
|
||||||
const sorted = useMemo(
|
const summary = useMemo(() => voucherFilterSummary(data.vouchers, data), [data])
|
||||||
() => [...data.vouchers].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
|
|
||||||
[data.vouchers],
|
const filtered = useMemo(() => {
|
||||||
)
|
const rows = filterVouchers(data.vouchers, data, {
|
||||||
|
...defaultVoucherFilters,
|
||||||
|
category,
|
||||||
|
status: statusFilter,
|
||||||
|
origin,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
search,
|
||||||
|
})
|
||||||
|
return rows.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number)
|
||||||
|
}, [data, category, statusFilter, origin, dateFrom, dateTo, search])
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
category !== 'all' ||
|
||||||
|
statusFilter !== 'all' ||
|
||||||
|
origin !== 'all' ||
|
||||||
|
Boolean(dateFrom) ||
|
||||||
|
Boolean(dateTo) ||
|
||||||
|
Boolean(search.trim())
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setCategory('all')
|
||||||
|
setStatusFilter('all')
|
||||||
|
setOrigin('all')
|
||||||
|
setDateFrom('')
|
||||||
|
setDateTo('')
|
||||||
|
setSearch('')
|
||||||
|
}
|
||||||
|
|
||||||
const balance = draft ? voucherBalance(draft) : null
|
const balance = draft ? voucherBalance(draft) : null
|
||||||
|
|
||||||
@@ -75,6 +123,17 @@ export function Vouchers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: Array<Column<Voucher>> = [
|
const columns: Array<Column<Voucher>> = [
|
||||||
|
{
|
||||||
|
key: 'category',
|
||||||
|
header: 'دسته',
|
||||||
|
headerClassName: 'w-[7rem]',
|
||||||
|
className: 'w-[7rem] whitespace-nowrap',
|
||||||
|
render: (v) => {
|
||||||
|
const cat = classifyVoucher(v, data)
|
||||||
|
const tone = cat === 'customer' ? 'blue' : cat === 'supplier' ? 'amber' : 'slate'
|
||||||
|
return <Badge tone={tone}>{voucherCategoryLabel(cat)}</Badge>
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'number',
|
key: 'number',
|
||||||
header: 'شماره',
|
header: 'شماره',
|
||||||
@@ -89,6 +148,17 @@ export function Vouchers() {
|
|||||||
className: 'w-[6.5rem] whitespace-nowrap tabular-nums',
|
className: 'w-[6.5rem] whitespace-nowrap tabular-nums',
|
||||||
render: (v) => formatDate(v.date),
|
render: (v) => formatDate(v.date),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'regarding',
|
||||||
|
header: 'بابت',
|
||||||
|
truncate: true,
|
||||||
|
className: 'min-w-0',
|
||||||
|
render: (v) => (
|
||||||
|
<span className="block truncate text-slate-700" title={resolveVoucherRegarding(v, data.treasury)}>
|
||||||
|
{resolveVoucherRegarding(v, data.treasury)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'desc',
|
key: 'desc',
|
||||||
header: 'شرح',
|
header: 'شرح',
|
||||||
@@ -115,7 +185,14 @@ export function Vouchers() {
|
|||||||
headerClassName: 'w-[6.5rem]',
|
headerClassName: 'w-[6.5rem]',
|
||||||
className: 'w-[6.5rem] whitespace-nowrap',
|
className: 'w-[6.5rem] whitespace-nowrap',
|
||||||
render: (v) =>
|
render: (v) =>
|
||||||
v.status === 'posted' ? <Badge tone="green">ثبت قطعی</Badge> : <Badge tone="amber">پیشنویس</Badge>,
|
v.status === 'posted' ? (
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<Badge tone="green">ثبت قطعی</Badge>
|
||||||
|
{isAutomatedVoucher(v) ? <Badge tone="slate">خودکار</Badge> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Badge tone="amber">پیشنویس</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -149,13 +226,72 @@ export function Vouchers() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<StatCard label="همه اسناد" value={toFa(data.vouchers.length)} />
|
||||||
|
<StatCard label="مشتریان" value={toFa(summary.customer)} tone="brand" />
|
||||||
|
<StatCard label="تأمینکنندگان" value={toFa(summary.supplier)} tone="amber" />
|
||||||
|
<StatCard label="دستی" value={toFa(summary.manual)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
|
<div className="mb-4 flex flex-wrap items-end gap-4">
|
||||||
|
<Field label="دسته">
|
||||||
|
<Select value={category} onChange={(e) => setCategory(e.target.value as VoucherCategoryFilter)}>
|
||||||
|
<option value="all">همه</option>
|
||||||
|
<option value="customer">مشتریان</option>
|
||||||
|
<option value="supplier">تأمینکنندگان</option>
|
||||||
|
<option value="manual">دستی</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="از تاریخ">
|
||||||
|
<JalaliDateInput value={dateFrom} onChange={setDateFrom} placeholder="انتخاب تاریخ" />
|
||||||
|
</Field>
|
||||||
|
<Field label="تا تاریخ">
|
||||||
|
<JalaliDateInput value={dateTo} onChange={setDateTo} placeholder="انتخاب تاریخ" />
|
||||||
|
</Field>
|
||||||
|
<Field label="وضعیت">
|
||||||
|
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as VoucherStatusFilter)}>
|
||||||
|
<option value="all">همه</option>
|
||||||
|
<option value="posted">ثبت قطعی</option>
|
||||||
|
<option value="draft">پیشنویس</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="منبع">
|
||||||
|
<Select value={origin} onChange={(e) => setOrigin(e.target.value as VoucherOriginFilter)}>
|
||||||
|
<option value="all">همه</option>
|
||||||
|
<option value="funzone">خودکار / فانزون</option>
|
||||||
|
<option value="manual">دستی</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="جستجو">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="شماره، شرح، بابت…"
|
||||||
|
className="min-w-[12rem]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<Button size="sm" variant="ghost" onClick={resetFilters}>
|
||||||
|
پاک کردن فیلترها
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 text-sm text-slate-500">
|
||||||
|
نمایش {toFa(filtered.length)} از {toFa(data.vouchers.length)} سند
|
||||||
|
</p>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={sorted}
|
rows={filtered}
|
||||||
rowKey={(v) => v.id}
|
rowKey={(v) => v.id}
|
||||||
emptyTitle="سندی ثبت نشده است"
|
emptyTitle={hasActiveFilters ? 'سندی با این فیلتر یافت نشد' : 'سندی ثبت نشده است'}
|
||||||
emptyDescription="اولین سند حسابداری خود را ایجاد کنید."
|
emptyDescription={
|
||||||
|
hasActiveFilters
|
||||||
|
? 'فیلترها را تغییر دهید یا پاک کنید.'
|
||||||
|
: 'اولین سند حسابداری خود را ایجاد کنید.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -193,7 +329,7 @@ export function Vouchers() {
|
|||||||
>
|
>
|
||||||
{draft && (
|
{draft && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Field label="شماره سند">
|
<Field label="شماره سند">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -204,6 +340,15 @@ export function Vouchers() {
|
|||||||
<Field label="تاریخ">
|
<Field label="تاریخ">
|
||||||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="بابت">
|
||||||
|
<Input
|
||||||
|
value={draft.regarding ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, regarding: normalizeEventName(e.target.value) || undefined })
|
||||||
|
}
|
||||||
|
placeholder="رویداد / موضوع سند"
|
||||||
|
/>
|
||||||
|
</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 })} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -215,6 +360,7 @@ export function Vouchers() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-start font-medium">حساب</th>
|
<th className="px-3 py-2 text-start font-medium">حساب</th>
|
||||||
<th className="px-3 py-2 text-start font-medium">شرح ردیف</th>
|
<th className="px-3 py-2 text-start font-medium">شرح ردیف</th>
|
||||||
|
<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" />
|
<th className="px-3 py-2" />
|
||||||
@@ -243,6 +389,9 @@ export function Vouchers() {
|
|||||||
placeholder="شرح"
|
placeholder="شرح"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-sm text-slate-600">
|
||||||
|
{resolveVoucherRegarding(draft, data.treasury)}
|
||||||
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
46
src/pages/purchases/FunZonePurchasesSyncBanner.tsx
Normal file
46
src/pages/purchases/FunZonePurchasesSyncBanner.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Button, Card } from '../../components/ui'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import { countFunZoneSupplierDocuments } from '../../utils/voucherFilters'
|
||||||
|
import { formatDateTime, toFa } from '../../utils/format'
|
||||||
|
import { usePurchasesFunZoneSync } from './PurchasesSyncContext'
|
||||||
|
|
||||||
|
export function FunZonePurchasesSyncBanner() {
|
||||||
|
const { loading, error, stats, lastSyncedAt, sync } = usePurchasesFunZoneSync()
|
||||||
|
const { data } = useStore()
|
||||||
|
|
||||||
|
const syncedCount = useMemo(() => countFunZoneSupplierDocuments(data), [data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-brand-200 bg-gradient-to-l from-brand-50/80 to-white p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-bold text-slate-800">همگامسازی با فانزون</h3>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
||||||
|
برای دریافت برداشتها و بازپرداختهای مالکان از فانزون و تبدیل آنها به پرداخت و سند حسابداری،
|
||||||
|
دکمه همگامسازی را بزنید.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-slate-600 ring-1 ring-slate-200">
|
||||||
|
{toFa(syncedCount)} تراکنش همگامشده
|
||||||
|
</span>
|
||||||
|
{lastSyncedAt ? (
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-slate-500 ring-1 ring-slate-200">
|
||||||
|
آخرین بروزرسانی: {formatDateTime(lastSyncedAt)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{stats ? (
|
||||||
|
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700 ring-1 ring-emerald-200">
|
||||||
|
پرداخت جدید: {toFa(stats.treasuryCreated)} · سند جدید: {toFa(stats.ownerVouchersCreated)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{error ? <p className="mt-2 text-sm text-rose-600">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" icon="↻" size="sm" onClick={() => void sync()} disabled={loading}>
|
||||||
|
{loading ? 'در حال همگامسازی…' : 'همگامسازی با فانزون'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { FunZonePartySection } from '../../parties/FunZonePartySection'
|
import { FunZonePartySection } from '../../parties/FunZonePartySection'
|
||||||
|
import { FunZonePurchasesSyncBanner } from './FunZonePurchasesSyncBanner'
|
||||||
|
|
||||||
export function PurchaseSuppliers() {
|
export function PurchaseSuppliers() {
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<FunZonePurchasesSyncBanner />
|
||||||
<FunZonePartySection
|
<FunZonePartySection
|
||||||
config={{
|
config={{
|
||||||
kind: 'supplier',
|
kind: 'supplier',
|
||||||
@@ -10,5 +13,6 @@ export function PurchaseSuppliers() {
|
|||||||
externalSource: 'funzone_owner',
|
externalSource: 'funzone_owner',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { PageHeader } from '../../components/ui'
|
import { PageHeader } from '../../components/ui'
|
||||||
|
import { PurchasesSyncProvider } from './PurchasesSyncContext'
|
||||||
|
|
||||||
const purchaseNavItems = [
|
const purchaseNavItems = [
|
||||||
{ to: '/purchases/suppliers', label: 'تأمینکنندگان', icon: '🏢', end: false },
|
{ to: '/purchases/suppliers', label: 'تأمینکنندگان', icon: '🏢', end: false },
|
||||||
@@ -9,6 +10,7 @@ const purchaseNavItems = [
|
|||||||
|
|
||||||
export function PurchasesLayout() {
|
export function PurchasesLayout() {
|
||||||
return (
|
return (
|
||||||
|
<PurchasesSyncProvider>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="تأمینکنندگان و خرید"
|
title="تأمینکنندگان و خرید"
|
||||||
@@ -46,5 +48,6 @@ export function PurchasesLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</PurchasesSyncProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/pages/purchases/PurchasesSyncContext.tsx
Normal file
86
src/pages/purchases/PurchasesSyncContext.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { apiGet, asList } from '../../api/client'
|
||||||
|
import { ENDPOINTS } from '../../api/config'
|
||||||
|
import type { ApiOwnerTransaction } from '../../api/types'
|
||||||
|
import { useVoucherSync } from '../../accounting/voucherSync'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import { useFunZoneTreasurySync } from '../treasury/useFunZoneTreasurySync'
|
||||||
|
|
||||||
|
export interface FunZonePurchasesSyncStats {
|
||||||
|
treasuryCreated: number
|
||||||
|
treasuryUpdated: number
|
||||||
|
treasurySkipped: number
|
||||||
|
treasuryRemoved: number
|
||||||
|
ownerVouchersCreated: number
|
||||||
|
ownerVouchersSkipped: number
|
||||||
|
unmatchedOwners: number
|
||||||
|
ownerWithdrawCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchasesSyncContextValue {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
stats: FunZonePurchasesSyncStats | null
|
||||||
|
lastSyncedAt: string | null
|
||||||
|
sync: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurchasesSyncContext = createContext<PurchasesSyncContextValue | null>(null)
|
||||||
|
|
||||||
|
export function usePurchasesFunZoneSync(): PurchasesSyncContextValue {
|
||||||
|
const ctx = useContext(PurchasesSyncContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('usePurchasesFunZoneSync must be used within PurchasesSyncProvider')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchasesSyncProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { reload } = useStore()
|
||||||
|
const treasury = useFunZoneTreasurySync()
|
||||||
|
const { syncOwnerTransactions } = useVoucherSync()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [stats, setStats] = useState<FunZonePurchasesSyncStats | null>(null)
|
||||||
|
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
|
||||||
|
const syncingRef = useRef(false)
|
||||||
|
|
||||||
|
const sync = useCallback(async () => {
|
||||||
|
if (syncingRef.current) return
|
||||||
|
syncingRef.current = true
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const treasuryStats = await treasury.sync()
|
||||||
|
|
||||||
|
const ownerTransactions = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(
|
||||||
|
asList<ApiOwnerTransaction>,
|
||||||
|
)
|
||||||
|
const ownerVoucherResult = await syncOwnerTransactions(ownerTransactions)
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
treasuryCreated: treasuryStats?.created ?? 0,
|
||||||
|
treasuryUpdated: treasuryStats?.updated ?? 0,
|
||||||
|
treasurySkipped: treasuryStats?.skipped ?? 0,
|
||||||
|
treasuryRemoved: treasuryStats?.removed ?? 0,
|
||||||
|
ownerVouchersCreated: ownerVoucherResult.created,
|
||||||
|
ownerVouchersSkipped: ownerVoucherResult.skipped,
|
||||||
|
unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0,
|
||||||
|
ownerWithdrawCount: treasuryStats?.ownerTxnCount ?? 0,
|
||||||
|
})
|
||||||
|
setLastSyncedAt(new Date().toISOString())
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'خطا در همگامسازی تأمینکنندگان با فانزون')
|
||||||
|
} finally {
|
||||||
|
syncingRef.current = false
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [treasury, syncOwnerTransactions, reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PurchasesSyncContext.Provider value={{ loading, error, stats, lastSyncedAt, sync }}>
|
||||||
|
{children}
|
||||||
|
</PurchasesSyncContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/pages/sales/FunZoneSalesSyncBanner.tsx
Normal file
50
src/pages/sales/FunZoneSalesSyncBanner.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Card } from '../../components/ui'
|
||||||
|
import { formatDateTime, toFa } from '../../utils/format'
|
||||||
|
import { useSalesFunZoneSync } from './SalesSyncContext'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { isFunZoneSalesInvoice } from './funzoneSalesSync'
|
||||||
|
import { salesDocuments } from './salesUtils'
|
||||||
|
|
||||||
|
export function FunZoneSalesSyncBanner() {
|
||||||
|
const { loading, error, stats, lastSyncedAt, sync } = useSalesFunZoneSync()
|
||||||
|
const { data } = useStore()
|
||||||
|
|
||||||
|
const funzoneDocCount = useMemo(
|
||||||
|
() => salesDocuments(data.invoices).filter(isFunZoneSalesInvoice).length,
|
||||||
|
[data.invoices],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-brand-200 bg-gradient-to-l from-brand-50/80 to-white p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-bold text-slate-800">همگامسازی با فانزون</h3>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
||||||
|
برای دریافت پرداختهای رویداد و لغو رزرو از فانزون و تبدیل آنها به فاکتور فروش/برگشتی و
|
||||||
|
دریافت و پرداخت، دکمه همگامسازی را بزنید.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-slate-600 ring-1 ring-slate-200">
|
||||||
|
{toFa(funzoneDocCount)} سند همگامشده در فروش
|
||||||
|
</span>
|
||||||
|
{lastSyncedAt ? (
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-slate-500 ring-1 ring-slate-200">
|
||||||
|
آخرین بروزرسانی: {formatDateTime(lastSyncedAt)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{stats ? (
|
||||||
|
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700 ring-1 ring-emerald-200">
|
||||||
|
فاکتور جدید: {toFa(stats.invoicesCreated)} · دریافت/پرداخت: {toFa(stats.treasuryCreated)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{error ? <p className="mt-2 text-sm text-rose-600">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" icon="↻" size="sm" onClick={() => void sync()} disabled={loading}>
|
||||||
|
{loading ? 'در حال همگامسازی…' : 'همگامسازی با فانزون'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react'
|
|||||||
import type { ApiWithdrawal } from '../../api/types'
|
import type { ApiWithdrawal } from '../../api/types'
|
||||||
import type { Invoice, Party } from '../../types'
|
import type { Invoice, Party } from '../../types'
|
||||||
import { Badge, Button, Card, DataTable, StatCard, type Column } from '../../components/ui'
|
import { Badge, Button, Card, DataTable, StatCard, type Column } from '../../components/ui'
|
||||||
import { formatDateTime, formatMoney, toFa } from '../../utils/format'
|
import { formatDate, formatIban, formatMoney, formatTime, toFa } from '../../utils/format'
|
||||||
import { invoiceForWithdrawal } from './usePendingCustomerWithdrawals'
|
import { invoiceForWithdrawal } from './usePendingCustomerWithdrawals'
|
||||||
|
|
||||||
interface PendingWithdrawalsPanelProps {
|
interface PendingWithdrawalsPanelProps {
|
||||||
@@ -15,6 +15,49 @@ interface PendingWithdrawalsPanelProps {
|
|||||||
onCreateReturn: (withdrawal: ApiWithdrawal) => void
|
onCreateReturn: (withdrawal: ApiWithdrawal) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CustomerCell({ withdrawal }: { withdrawal: ApiWithdrawal }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<p className="truncate font-semibold text-slate-800" title={withdrawal.user_name}>
|
||||||
|
{withdrawal.user_name}
|
||||||
|
</p>
|
||||||
|
{withdrawal.username ? (
|
||||||
|
<p className="truncate text-xs text-slate-500" dir="ltr" title={withdrawal.username}>
|
||||||
|
@{withdrawal.username}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IbanCell({ iban }: { iban: string }) {
|
||||||
|
const formatted = formatIban(iban)
|
||||||
|
if (formatted === '—') {
|
||||||
|
return <span className="text-sm text-slate-400">ثبت نشده</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-block max-w-full rounded-lg border border-slate-200/80 bg-white px-2.5 py-1.5 shadow-sm"
|
||||||
|
dir="ltr"
|
||||||
|
title={iban.replace(/\s/g, '')}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs leading-relaxed tracking-wide text-slate-700 sm:text-sm">
|
||||||
|
{formatted}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestDateCell({ iso }: { iso: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 whitespace-nowrap">
|
||||||
|
<p className="tabular-nums text-sm font-medium text-slate-800">{formatDate(iso.slice(0, 10))}</p>
|
||||||
|
<p className="tabular-nums text-xs text-slate-500">{formatTime(iso)}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PendingWithdrawalsPanel({
|
export function PendingWithdrawalsPanel({
|
||||||
withdrawals,
|
withdrawals,
|
||||||
loading,
|
loading,
|
||||||
@@ -30,35 +73,57 @@ export function PendingWithdrawalsPanel({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const columns: Array<Column<ApiWithdrawal>> = [
|
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: 'customer',
|
||||||
|
header: 'مشتری',
|
||||||
|
headerClassName: 'w-[11rem]',
|
||||||
|
className: 'w-[11rem]',
|
||||||
|
render: (w) => <CustomerCell withdrawal={w} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
header: 'مبلغ',
|
header: 'مبلغ (تومان)',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
render: (w) => <span className="tabular-nums font-semibold">{formatMoney(w.amount)}</span>,
|
headerClassName: 'w-[6.5rem]',
|
||||||
|
className: 'w-[6.5rem] whitespace-nowrap',
|
||||||
|
render: (w) => (
|
||||||
|
<span className="tabular-nums text-base font-bold text-brand-700">{formatMoney(w.amount)}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ key: 'iban', header: 'شبا', render: (w) => <span className="font-mono text-xs">{w.iban || '—'}</span> },
|
|
||||||
{
|
{
|
||||||
key: 'date',
|
key: 'iban',
|
||||||
|
header: 'شماره شبا',
|
||||||
|
headerClassName: 'min-w-[14rem]',
|
||||||
|
className: 'min-w-[14rem]',
|
||||||
|
render: (w) => <IbanCell iban={w.iban} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'requestedAt',
|
||||||
header: 'تاریخ درخواست',
|
header: 'تاریخ درخواست',
|
||||||
render: (w) => formatDateTime(w.created_at),
|
headerClassName: 'w-[6.5rem]',
|
||||||
|
className: 'w-[6.5rem]',
|
||||||
|
render: (w) => <RequestDateCell iso={w.created_at} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: 'وضعیت',
|
header: 'وضعیت',
|
||||||
|
headerClassName: 'w-[5.5rem]',
|
||||||
|
className: 'w-[5.5rem] whitespace-nowrap',
|
||||||
render: () => <Badge tone="amber">در انتظار</Badge>,
|
render: () => <Badge tone="amber">در انتظار</Badge>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
|
sticky: true,
|
||||||
|
headerClassName: 'w-[9.5rem]',
|
||||||
|
className: 'w-[9.5rem] whitespace-nowrap',
|
||||||
render: (w) => {
|
render: (w) => {
|
||||||
const party = customerList.find((p) => p.externalId === w.user_id)
|
const party = customerList.find((p) => p.externalId === w.user_id)
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
disabled={!party}
|
disabled={!party}
|
||||||
onClick={() => onCreateReturn(w)}
|
onClick={() => onCreateReturn(w)}
|
||||||
title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
|
title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
|
||||||
@@ -71,12 +136,12 @@ export function PendingWithdrawalsPanel({
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-amber-200 bg-amber-50/30 p-4">
|
<Card className="border-amber-200 bg-gradient-to-b from-amber-50/60 to-white p-4 sm:p-5">
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-amber-100 pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
|
<h3 className="text-lg font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-slate-500">
|
||||||
درخواستهای برداشت کیف پول مشتریان از بخش حسابداری ادمین — برای هر مورد فاکتور برگشتی صادر کنید
|
درخواستهای برداشت کیف پول مشتریان — شبا، مبلغ و تاریخ هر درخواست جدا نمایش داده میشود.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
|
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
|
||||||
@@ -84,7 +149,7 @@ export function PendingWithdrawalsPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="برداشت در انتظار"
|
label="برداشت در انتظار"
|
||||||
value={toFa(openWithdrawals.length)}
|
value={toFa(openWithdrawals.length)}
|
||||||
@@ -99,17 +164,26 @@ export function PendingWithdrawalsPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
|
{error && (
|
||||||
|
<p className="mb-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-6 text-center text-sm text-slate-400">در حال بارگذاری برداشتها…</p>
|
<p className="py-8 text-center text-sm text-slate-400">در حال بارگذاری برداشتها…</p>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-sm">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={openWithdrawals}
|
rows={openWithdrawals}
|
||||||
rowKey={(w) => w.id}
|
rowKey={(w) => w.id}
|
||||||
emptyTitle="برداشت در انتظاری ثبت نشده است"
|
emptyTitle="برداشت در انتظاری ثبت نشده است"
|
||||||
|
minWidth="52rem"
|
||||||
|
size="comfortable"
|
||||||
|
className="rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ import {
|
|||||||
byDocumentType,
|
byDocumentType,
|
||||||
customers,
|
customers,
|
||||||
documentTypeLabels,
|
documentTypeLabels,
|
||||||
|
invoiceRegarding,
|
||||||
isWithdrawalReturn,
|
isWithdrawalReturn,
|
||||||
reconcileInvoicePaymentStatus,
|
reconcileInvoicePaymentStatus,
|
||||||
statusLabels,
|
statusLabels,
|
||||||
} from './salesUtils'
|
} from './salesUtils'
|
||||||
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
|
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
|
||||||
|
import { InvoiceLineGoodsCell } from '../../components/business/InvoiceLineGoodsCell'
|
||||||
|
import { formatEventName } from '../../utils/eventName'
|
||||||
import {
|
import {
|
||||||
usePendingCustomerWithdrawals,
|
usePendingCustomerWithdrawals,
|
||||||
withdrawalNoteTag,
|
withdrawalNoteTag,
|
||||||
@@ -169,7 +172,7 @@ export function SalesDocumentSection({
|
|||||||
salesTypeId: source.salesTypeId,
|
salesTypeId: source.salesTypeId,
|
||||||
relatedInvoiceId: source.id,
|
relatedInvoiceId: source.id,
|
||||||
note: `برگشت از فاکتور #${source.number}`,
|
note: `برگشت از فاکتور #${source.number}`,
|
||||||
lines: source.lines.map((l) => ({ ...l, id: createId('il-') })),
|
lines: source.lines.map((l) => ({ ...l, id: createId('il-'), eventName: l.eventName })),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +194,7 @@ export function SalesDocumentSection({
|
|||||||
unitPrice: withdrawal.amount,
|
unitPrice: withdrawal.amount,
|
||||||
discount: 0,
|
discount: 0,
|
||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
|
eventName: 'wallet',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -201,7 +205,7 @@ export function SalesDocumentSection({
|
|||||||
const fromWithdrawal = isWithdrawalReturn(draft)
|
const fromWithdrawal = isWithdrawalReturn(draft)
|
||||||
const cleanLines = draft.lines.filter((line) => {
|
const cleanLines = draft.lines.filter((line) => {
|
||||||
if (fromWithdrawal && line.unitPrice > 0) return true
|
if (fromWithdrawal && line.unitPrice > 0) return true
|
||||||
return Boolean(line.productId) && line.quantity > 0
|
return Boolean(line.productId || line.eventName) && line.quantity > 0
|
||||||
})
|
})
|
||||||
if (cleanLines.length === 0) return
|
if (cleanLines.length === 0) return
|
||||||
|
|
||||||
@@ -251,6 +255,18 @@ export function SalesDocumentSection({
|
|||||||
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
||||||
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
||||||
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
||||||
|
{
|
||||||
|
key: 'regarding',
|
||||||
|
header: 'بابت',
|
||||||
|
render: (i) => {
|
||||||
|
const regarding = invoiceRegarding(i)
|
||||||
|
return regarding ? (
|
||||||
|
<span className="text-slate-700">{formatEventName(regarding)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">—</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
header: 'نوع فروش',
|
header: 'نوع فروش',
|
||||||
@@ -469,7 +485,7 @@ export function SalesDocumentSection({
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-slate-50 text-slate-500">
|
<thead className="bg-slate-50 text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-start font-medium">کالا</th>
|
<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 text-end font-medium">تخفیف</th>
|
||||||
@@ -482,14 +498,14 @@ export function SalesDocumentSection({
|
|||||||
{draft.lines.map((line) => (
|
{draft.lines.map((line) => (
|
||||||
<tr key={line.id}>
|
<tr key={line.id}>
|
||||||
<td className="min-w-[180px] px-2 py-1.5">
|
<td className="min-w-[180px] px-2 py-1.5">
|
||||||
<Select value={line.productId} onChange={(e) => onSelectProduct(line.id, e.target.value)}>
|
<InvoiceLineGoodsCell
|
||||||
<option value="">انتخاب کالا…</option>
|
line={line}
|
||||||
{data.products.map((product) => (
|
products={data.products}
|
||||||
<option key={product.id} value={product.id}>
|
showEventName
|
||||||
{product.name} (موجودی: {toFa(product.stock)})
|
withdrawalReturn={isWithdrawalReturn(draft)}
|
||||||
</option>
|
onChange={(patch) => updateLine(line.id, patch)}
|
||||||
))}
|
onSelectProduct={(productId) => onSelectProduct(line.id, productId)}
|
||||||
</Select>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="w-24 px-2 py-1.5">
|
<td className="w-24 px-2 py-1.5">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { PageHeader } from '../../components/ui'
|
import { PageHeader } from '../../components/ui'
|
||||||
import { salesNavItems } from './salesNavigation'
|
import { salesNavItems } from './salesNavigation'
|
||||||
|
import { SalesSyncProvider } from './SalesSyncContext'
|
||||||
|
|
||||||
export function SalesLayout() {
|
export function SalesLayout() {
|
||||||
return (
|
return (
|
||||||
|
<SalesSyncProvider>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مشتریان و فروش"
|
title="مشتریان و فروش"
|
||||||
@@ -41,5 +43,6 @@ export function SalesLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</SalesSyncProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useStore } from '../../store/AppStore'
|
|||||||
import { Card, StatCard } from '../../components/ui'
|
import { Card, StatCard } from '../../components/ui'
|
||||||
import { formatMoney, toFa } from '../../utils/format'
|
import { formatMoney, toFa } from '../../utils/format'
|
||||||
import { salesNavItems } from './salesNavigation'
|
import { salesNavItems } from './salesNavigation'
|
||||||
import { salesSummary } from './salesUtils'
|
import { salesDocuments, salesSummary } from './salesUtils'
|
||||||
|
import { FunZoneSalesSyncBanner } from './FunZoneSalesSyncBanner'
|
||||||
|
import { isFunZoneSalesInvoice } from './funzoneSalesSync'
|
||||||
|
|
||||||
const processSteps = [
|
const processSteps = [
|
||||||
{ step: 1, label: 'پیشفاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
|
{ step: 1, label: 'پیشفاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
|
||||||
@@ -16,11 +18,16 @@ const processSteps = [
|
|||||||
export function SalesProcess() {
|
export function SalesProcess() {
|
||||||
const { data } = useStore()
|
const { data } = useStore()
|
||||||
const summary = useMemo(() => salesSummary(data), [data])
|
const summary = useMemo(() => salesSummary(data), [data])
|
||||||
|
const funzoneSyncedCount = useMemo(
|
||||||
|
() => salesDocuments(data.invoices).filter(isFunZoneSalesInvoice).length,
|
||||||
|
[data.invoices],
|
||||||
|
)
|
||||||
|
|
||||||
const quickLinks = salesNavItems.filter((item) => item.to !== '/sales')
|
const quickLinks = salesNavItems.filter((item) => item.to !== '/sales')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<FunZoneSalesSyncBanner />
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
|
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
|
||||||
<p className="mb-6 text-sm text-slate-500">
|
<p className="mb-6 text-sm text-slate-500">
|
||||||
@@ -51,11 +58,12 @@ export function SalesProcess() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||||
<StatCard label="پیشفاکتور" value={toFa(summary.proformaCount)} hint={`${toFa(summary.openProformas)} باز`} />
|
<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.invoiceCount)} hint={formatMoney(summary.totalSales)} />
|
||||||
<StatCard label="فاکتور برگشتی" value={toFa(summary.returnCount)} hint={formatMoney(summary.totalReturns)} />
|
<StatCard label="فاکتور برگشتی" value={toFa(summary.returnCount)} hint={formatMoney(summary.totalReturns)} />
|
||||||
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} hint={`${toFa(summary.unpaidCount)} تسویهنشده`} />
|
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} hint={`${toFa(summary.unpaidCount)} تسویهنشده`} />
|
||||||
|
<StatCard label="همگام فانزون" value={toFa(funzoneSyncedCount)} hint="فاکتور از پرداخت رویداد" tone="brand" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ import { invoiceTotals } from '../../utils/accounting'
|
|||||||
import {
|
import {
|
||||||
customers,
|
customers,
|
||||||
documentTypeLabels,
|
documentTypeLabels,
|
||||||
|
invoiceRegarding,
|
||||||
salesDocuments,
|
salesDocuments,
|
||||||
salesSummary,
|
salesSummary,
|
||||||
statusLabels,
|
statusLabels,
|
||||||
} from './salesUtils'
|
} from './salesUtils'
|
||||||
|
import { formatEventName } from '../../utils/eventName'
|
||||||
|
import { FunZoneSalesSyncBanner } from './FunZoneSalesSyncBanner'
|
||||||
|
import { isFunZoneSalesInvoice } from './funzoneSalesSync'
|
||||||
|
|
||||||
export function SalesReview() {
|
export function SalesReview() {
|
||||||
const { data } = useStore()
|
const { data } = useStore()
|
||||||
@@ -57,6 +61,14 @@ export function SalesReview() {
|
|||||||
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
|
||||||
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
||||||
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
||||||
|
{
|
||||||
|
key: 'regarding',
|
||||||
|
header: 'بابت',
|
||||||
|
render: (i) => {
|
||||||
|
const regarding = invoiceRegarding(i)
|
||||||
|
return regarding ? formatEventName(regarding) : '—'
|
||||||
|
},
|
||||||
|
},
|
||||||
{ key: 'salesType', header: 'نوع فروش', render: (i) => salesTypeName(i.salesTypeId) },
|
{ key: 'salesType', header: 'نوع فروش', render: (i) => salesTypeName(i.salesTypeId) },
|
||||||
{
|
{
|
||||||
key: 'net',
|
key: 'net',
|
||||||
@@ -68,15 +80,20 @@ export function SalesReview() {
|
|||||||
key: 'status',
|
key: 'status',
|
||||||
header: 'وضعیت',
|
header: 'وضعیت',
|
||||||
render: (i) => (
|
render: (i) => (
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : 'amber'}>
|
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : 'amber'}>
|
||||||
{statusLabels[i.status]}
|
{statusLabels[i.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{isFunZoneSalesInvoice(i) ? <Badge tone="slate">فانزون</Badge> : null}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<FunZoneSalesSyncBanner />
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<StatCard label="کل فروش" value={formatMoney(summary.totalSales)} />
|
<StatCard label="کل فروش" value={formatMoney(summary.totalSales)} />
|
||||||
<StatCard label="برگشت" value={formatMoney(summary.totalReturns)} />
|
<StatCard label="برگشت" value={formatMoney(summary.totalReturns)} />
|
||||||
|
|||||||
163
src/pages/sales/SalesSyncContext.tsx
Normal file
163
src/pages/sales/SalesSyncContext.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { apiGet } from '../../api/client'
|
||||||
|
import { ENDPOINTS } from '../../api/config'
|
||||||
|
import type { ApiPaymentOrRefund } from '../../api/types'
|
||||||
|
import type { AppData, Invoice } from '../../types'
|
||||||
|
import { useStore } from '../../store/AppStore'
|
||||||
|
import { useFunZoneTreasurySync } from '../treasury/useFunZoneTreasurySync'
|
||||||
|
import { invoiceSideEffects } from './invoiceEffects'
|
||||||
|
import { reconcileInvoicePaymentStatus } from './salesUtils'
|
||||||
|
import { assignInvoiceIds, buildFunZoneSalesSync, invoiceUnchanged } from './funzoneSalesSync'
|
||||||
|
|
||||||
|
export interface FunZoneSalesSyncStats {
|
||||||
|
treasuryCreated: number
|
||||||
|
treasuryUpdated: number
|
||||||
|
treasurySkipped: number
|
||||||
|
invoicesCreated: number
|
||||||
|
invoicesUpdated: number
|
||||||
|
invoicesSkipped: number
|
||||||
|
unmatchedCustomers: number
|
||||||
|
funzonePayments: number
|
||||||
|
funzoneCancellations: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesSyncContextValue {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
stats: FunZoneSalesSyncStats | null
|
||||||
|
lastSyncedAt: string | null
|
||||||
|
sync: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SalesSyncContext = createContext<SalesSyncContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useSalesFunZoneSync(): SalesSyncContextValue {
|
||||||
|
const ctx = useContext(SalesSyncContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSalesFunZoneSync must be used within SalesSyncProvider')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesSyncProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore()
|
||||||
|
const treasury = useFunZoneTreasurySync()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [stats, setStats] = useState<FunZoneSalesSyncStats | null>(null)
|
||||||
|
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
|
||||||
|
const syncingRef = useRef(false)
|
||||||
|
const dataRef = useRef(data)
|
||||||
|
dataRef.current = data
|
||||||
|
|
||||||
|
const applyInvoiceIfChanged = useCallback(
|
||||||
|
(working: AppData, invoice: Invoice): AppData => {
|
||||||
|
const previous = working.invoices.find((i) => i.id === invoice.id)
|
||||||
|
if (previous && invoiceUnchanged(previous, invoice) && previous.status === invoice.status) {
|
||||||
|
return working
|
||||||
|
}
|
||||||
|
|
||||||
|
const effects = invoiceSideEffects(invoice, previous ?? null, working)
|
||||||
|
upsertInvoice(invoice)
|
||||||
|
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||||
|
if (effects.voucher) upsertVoucher(effects.voucher)
|
||||||
|
|
||||||
|
let next: AppData = {
|
||||||
|
...working,
|
||||||
|
invoices: [...working.invoices.filter((i) => i.id !== invoice.id), invoice],
|
||||||
|
vouchers: [
|
||||||
|
...working.vouchers.filter(
|
||||||
|
(v) => v.id !== effects.removeVoucherId && v.id !== effects.voucher?.id,
|
||||||
|
),
|
||||||
|
...(effects.voucher ? [effects.voucher] : []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.partyId && invoice.status !== 'draft') {
|
||||||
|
for (const settled of reconcileInvoicePaymentStatus(invoice.partyId, next)) {
|
||||||
|
const current = next.invoices.find((i) => i.id === settled.id)
|
||||||
|
if (current?.status === settled.status) continue
|
||||||
|
upsertInvoice(settled)
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
invoices: next.invoices.map((i) => (i.id === settled.id ? settled : i)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
[upsertInvoice, upsertVoucher, removeVoucher],
|
||||||
|
)
|
||||||
|
|
||||||
|
const persistSalesBatch = useCallback(
|
||||||
|
(baseData: AppData, items: Invoice[]) => {
|
||||||
|
const saved = assignInvoiceIds(items)
|
||||||
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
|
let working = { ...baseData }
|
||||||
|
|
||||||
|
for (const invoice of saved) {
|
||||||
|
const isNew = !baseData.invoices.some((i) => i.id === invoice.id)
|
||||||
|
if (isNew) created += 1
|
||||||
|
else updated += 1
|
||||||
|
working = applyInvoiceIfChanged(working, invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated }
|
||||||
|
},
|
||||||
|
[applyInvoiceIfChanged],
|
||||||
|
)
|
||||||
|
|
||||||
|
const syncSalesInvoices = useCallback(async () => {
|
||||||
|
const current = dataRef.current
|
||||||
|
const customerPayments = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
||||||
|
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
|
||||||
|
).then((res) => res.payments_and_refunds ?? [])
|
||||||
|
|
||||||
|
const { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount } =
|
||||||
|
buildFunZoneSalesSync(current, customerPayments)
|
||||||
|
|
||||||
|
const { created, updated } = persistSalesBatch(current, toUpsert)
|
||||||
|
|
||||||
|
return {
|
||||||
|
invoicesCreated: created,
|
||||||
|
invoicesUpdated: updated,
|
||||||
|
invoicesSkipped: skipped,
|
||||||
|
unmatchedCustomers,
|
||||||
|
funzonePayments: paymentCount,
|
||||||
|
funzoneCancellations: cancellationCount,
|
||||||
|
}
|
||||||
|
}, [persistSalesBatch])
|
||||||
|
|
||||||
|
const sync = useCallback(async () => {
|
||||||
|
if (syncingRef.current) return
|
||||||
|
syncingRef.current = true
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const treasuryStats = await treasury.sync()
|
||||||
|
const salesResult = await syncSalesInvoices()
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
treasuryCreated: treasuryStats?.created ?? 0,
|
||||||
|
treasuryUpdated: treasuryStats?.updated ?? 0,
|
||||||
|
treasurySkipped: treasuryStats?.skipped ?? 0,
|
||||||
|
...salesResult,
|
||||||
|
})
|
||||||
|
setLastSyncedAt(new Date().toISOString())
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'خطا در همگامسازی فروش با فانزون')
|
||||||
|
} finally {
|
||||||
|
syncingRef.current = false
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [treasury, syncSalesInvoices, reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SalesSyncContext.Provider value={{ loading, error, stats, lastSyncedAt, sync }}>
|
||||||
|
{children}
|
||||||
|
</SalesSyncContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
202
src/pages/sales/funzoneSalesSync.ts
Normal file
202
src/pages/sales/funzoneSalesSync.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import type { ApiPaymentOrRefund } from '../../api/types'
|
||||||
|
import type { AppData, Invoice } from '../../types'
|
||||||
|
import { findExistingParty } from '../../parties/funzonePartySync'
|
||||||
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
|
import { isEventCustomerPayment } from '../../utils/funzoneFees'
|
||||||
|
import { invoiceTotals } from '../../utils/accounting'
|
||||||
|
import { byDocumentType, isWithdrawalReturn } from './salesUtils'
|
||||||
|
|
||||||
|
export const FUNZONE_PAYMENT_NOTE_PREFIX = 'funzone:pay:'
|
||||||
|
export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:'
|
||||||
|
|
||||||
|
export function funzonePaymentNoteTag(paymentId: string): string {
|
||||||
|
return `${FUNZONE_PAYMENT_NOTE_PREFIX}${paymentId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function funzoneCancellationNoteTag(paymentId: string): string {
|
||||||
|
return `${FUNZONE_CANCEL_NOTE_PREFIX}${paymentId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunZoneTicketSalesInvoice(invoice: Invoice): boolean {
|
||||||
|
return (
|
||||||
|
invoice.kind === 'sale' &&
|
||||||
|
(invoice.documentType ?? 'invoice') === 'invoice' &&
|
||||||
|
invoice.note.includes(FUNZONE_PAYMENT_NOTE_PREFIX)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunZoneSalesInvoice(invoice: Invoice): boolean {
|
||||||
|
return isFunZoneTicketSalesInvoice(invoice) || invoice.note.includes(FUNZONE_CANCEL_NOTE_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDateFromApi(iso: string): string {
|
||||||
|
return iso.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInvoiceByNoteTag(invoices: Invoice[], tag: string): Invoice | undefined {
|
||||||
|
return invoices.find((inv) => inv.note.includes(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancellationAlreadyBooked(
|
||||||
|
invoices: Invoice[],
|
||||||
|
customerName: string,
|
||||||
|
amount: number,
|
||||||
|
): boolean {
|
||||||
|
for (const inv of invoices) {
|
||||||
|
if (!isWithdrawalReturn(inv) || inv.status === 'draft') continue
|
||||||
|
const net = invoiceTotals(inv).net
|
||||||
|
if (Math.abs(net - amount) > 0.01) continue
|
||||||
|
if (inv.note.includes(customerName)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invoiceUnchanged(existing: Invoice, built: Invoice): boolean {
|
||||||
|
if (existing.partyId !== built.partyId || existing.date !== built.date) return false
|
||||||
|
if (existing.documentType !== built.documentType) return false
|
||||||
|
if (existing.status !== built.status) return false
|
||||||
|
if (Math.abs(invoiceTotals(existing).net - invoiceTotals(built).net) > 0.01) return false
|
||||||
|
const existingEvent = existing.lines[0]?.eventName ?? ''
|
||||||
|
const builtEvent = built.lines[0]?.eventName ?? ''
|
||||||
|
return existingEvent === builtEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Event ticket payment → فاکتور فروش; booking cancellation → فاکتور برگشتی. */
|
||||||
|
export function buildSalesInvoiceFromCustomerPayment(
|
||||||
|
item: ApiPaymentOrRefund,
|
||||||
|
partyId: string,
|
||||||
|
existingInvoices: Invoice[],
|
||||||
|
number: number,
|
||||||
|
existing?: Invoice,
|
||||||
|
): Invoice | null {
|
||||||
|
const amount = Math.abs(item.amount)
|
||||||
|
if (amount <= 0) return null
|
||||||
|
|
||||||
|
if (item.type === 'payment') {
|
||||||
|
if (!isEventCustomerPayment(item.event_name)) return null
|
||||||
|
|
||||||
|
const tag = funzonePaymentNoteTag(item.id)
|
||||||
|
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: prior?.id ?? '',
|
||||||
|
kind: 'sale',
|
||||||
|
documentType: 'invoice',
|
||||||
|
number: prior?.number ?? number,
|
||||||
|
partyId,
|
||||||
|
date: isoDateFromApi(item.created_at),
|
||||||
|
status: prior?.status === 'draft' ? 'draft' : 'paid',
|
||||||
|
note: `${tag}\nفاکتور خودکار از فانزون — ${item.customer_name}`,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: prior?.lines[0]?.id ?? createId('il-'),
|
||||||
|
productId: '',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: amount,
|
||||||
|
discount: 0,
|
||||||
|
taxRate: 0,
|
||||||
|
eventName: item.event_name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWallet = (item.event_name || '').toLowerCase() === 'wallet'
|
||||||
|
if (isWallet) {
|
||||||
|
if (cancellationAlreadyBooked(existingInvoices, item.customer_name, amount)) return null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = funzoneCancellationNoteTag(item.id)
|
||||||
|
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: prior?.id ?? '',
|
||||||
|
kind: 'sale',
|
||||||
|
documentType: 'return',
|
||||||
|
number: prior?.number ?? number,
|
||||||
|
partyId,
|
||||||
|
date: isoDateFromApi(item.created_at),
|
||||||
|
status: prior?.status ?? 'confirmed',
|
||||||
|
note: `${tag}\nبرگشت خودکار از فانزون — ${item.customer_name}`,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: prior?.lines[0]?.id ?? createId('il-'),
|
||||||
|
productId: '',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: amount,
|
||||||
|
discount: 0,
|
||||||
|
taxRate: 0,
|
||||||
|
eventName: item.event_name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relatedInvoiceId: prior?.relatedInvoiceId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunZoneSalesSyncResult {
|
||||||
|
toUpsert: Invoice[]
|
||||||
|
skipped: number
|
||||||
|
unmatchedCustomers: number
|
||||||
|
paymentCount: number
|
||||||
|
cancellationCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFunZoneSalesSync(
|
||||||
|
data: Pick<AppData, 'parties' | 'invoices'>,
|
||||||
|
customerPayments: ApiPaymentOrRefund[],
|
||||||
|
): FunZoneSalesSyncResult {
|
||||||
|
const toUpsert: Invoice[] = []
|
||||||
|
let skipped = 0
|
||||||
|
let unmatchedCustomers = 0
|
||||||
|
let paymentCount = 0
|
||||||
|
let cancellationCount = 0
|
||||||
|
|
||||||
|
let invoiceNumber = nextNumber(byDocumentType(data.invoices, 'invoice'))
|
||||||
|
let returnNumber = nextNumber(byDocumentType(data.invoices, 'return'))
|
||||||
|
|
||||||
|
for (const item of customerPayments) {
|
||||||
|
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
|
||||||
|
if (!party?.id) {
|
||||||
|
unmatchedCustomers += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'payment' && isEventCustomerPayment(item.event_name)) paymentCount += 1
|
||||||
|
if (item.type === 'cancellation' && isEventCustomerPayment(item.event_name)) cancellationCount += 1
|
||||||
|
|
||||||
|
const tag =
|
||||||
|
item.type === 'payment'
|
||||||
|
? funzonePaymentNoteTag(item.id)
|
||||||
|
: funzoneCancellationNoteTag(item.id)
|
||||||
|
const existing = findInvoiceByNoteTag(data.invoices, tag)
|
||||||
|
const built = buildSalesInvoiceFromCustomerPayment(
|
||||||
|
item,
|
||||||
|
party.id,
|
||||||
|
data.invoices,
|
||||||
|
item.type === 'payment' ? invoiceNumber : returnNumber,
|
||||||
|
existing,
|
||||||
|
)
|
||||||
|
if (!built) continue
|
||||||
|
|
||||||
|
if (existing && invoiceUnchanged(existing, built)) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
toUpsert.push(built)
|
||||||
|
if (!existing) {
|
||||||
|
if (built.documentType === 'invoice') invoiceNumber += 1
|
||||||
|
else returnNumber += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignInvoiceIds(items: Invoice[]): Invoice[] {
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: item.id || createId('inv-'),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
VoucherLine,
|
VoucherLine,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { invoiceTotals } from '../../utils/accounting'
|
import { invoiceTotals } from '../../utils/accounting'
|
||||||
|
import { eventNameFromTreasuryDescription } from '../../utils/eventName'
|
||||||
import { createId, nextNumber } from '../../utils/id'
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
|
|
||||||
/** Resolves a chart account by code, with optional settings fallback. */
|
/** Resolves a chart account by code, with optional settings fallback. */
|
||||||
@@ -134,6 +135,11 @@ export function withdrawalIdFromNote(note: string): string | null {
|
|||||||
return match?.[1] ?? null
|
return match?.[1] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First event name on invoice lines (بابت). */
|
||||||
|
export function invoiceRegarding(invoice: Invoice): string | undefined {
|
||||||
|
return invoice.lines.map((l) => l.eventName).find((name) => Boolean(name?.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether stock/inventory should be affected for this document. */
|
/** Whether stock/inventory should be affected for this document. */
|
||||||
export function affectsStock(invoice: Invoice): boolean {
|
export function affectsStock(invoice: Invoice): boolean {
|
||||||
if (invoice.status === 'draft') return false
|
if (invoice.status === 'draft') return false
|
||||||
@@ -205,6 +211,7 @@ export function buildSalesVoucher(
|
|||||||
|
|
||||||
const party = data.parties.find((p) => p.id === invoice.partyId)
|
const party = data.parties.find((p) => p.id === invoice.partyId)
|
||||||
const label = `${documentTypeLabels[docType]} #${invoice.number} - ${party?.name ?? ''}`
|
const label = `${documentTypeLabels[docType]} #${invoice.number} - ${party?.name ?? ''}`
|
||||||
|
const regarding = invoiceRegarding(invoice)
|
||||||
|
|
||||||
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
|
||||||
id: createId('vl-'),
|
id: createId('vl-'),
|
||||||
@@ -226,6 +233,7 @@ export function buildSalesVoucher(
|
|||||||
number: nextNumber(data.vouchers),
|
number: nextNumber(data.vouchers),
|
||||||
date: invoice.date,
|
date: invoice.date,
|
||||||
description: `برداشت کیف پول - ${label}`,
|
description: `برداشت کیف پول - ${label}`,
|
||||||
|
regarding: regarding ?? 'wallet',
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice.id),
|
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice.id),
|
||||||
lines: [
|
lines: [
|
||||||
@@ -253,6 +261,7 @@ export function buildSalesVoucher(
|
|||||||
number: nextNumber(data.vouchers),
|
number: nextNumber(data.vouchers),
|
||||||
date: invoice.date,
|
date: invoice.date,
|
||||||
description: label,
|
description: label,
|
||||||
|
regarding,
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: voucherSourceForInvoice(invoice.id),
|
source: voucherSourceForInvoice(invoice.id),
|
||||||
lines,
|
lines,
|
||||||
@@ -373,8 +382,11 @@ export function reconcileInvoicePaymentStatus(
|
|||||||
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
|
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
|
||||||
|
|
||||||
const shouldPaid = shouldBePaidIds.has(inv.id)
|
const shouldPaid = shouldBePaidIds.has(inv.id)
|
||||||
if (inv.status === 'paid' && !shouldPaid) updates.push({ ...inv, status: 'confirmed' })
|
if (inv.status === 'paid' && !shouldPaid) {
|
||||||
else if (inv.status === 'confirmed' && shouldPaid) updates.push({ ...inv, status: 'paid' })
|
// Ticket sales from FunZone are prepaid on the platform — keep تسویهشده.
|
||||||
|
if (inv.note.includes('funzone:pay:')) continue
|
||||||
|
updates.push({ ...inv, status: 'confirmed' })
|
||||||
|
} else if (inv.status === 'confirmed' && shouldPaid) updates.push({ ...inv, status: 'paid' })
|
||||||
}
|
}
|
||||||
return updates
|
return updates
|
||||||
}
|
}
|
||||||
@@ -478,11 +490,13 @@ export function buildTreasuryVoucher(
|
|||||||
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
|
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
|
||||||
|
|
||||||
if (hasEventSplit) {
|
if (hasEventSplit) {
|
||||||
|
const regarding = txn.eventName || eventNameFromTreasuryDescription(txn.description)
|
||||||
return {
|
return {
|
||||||
id: createId('v-'),
|
id: createId('v-'),
|
||||||
number: nextNumber(data.vouchers),
|
number: nextNumber(data.vouchers),
|
||||||
date: txn.date,
|
date: txn.date,
|
||||||
description: txn.description || label,
|
description: txn.description || label,
|
||||||
|
regarding,
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: voucherSourceForTreasury(txn.id),
|
source: voucherSourceForTreasury(txn.id),
|
||||||
lines: [
|
lines: [
|
||||||
@@ -512,6 +526,7 @@ export function buildTreasuryVoucher(
|
|||||||
number: nextNumber(data.vouchers),
|
number: nextNumber(data.vouchers),
|
||||||
date: txn.date,
|
date: txn.date,
|
||||||
description: txn.description || label,
|
description: txn.description || label,
|
||||||
|
regarding: txn.eventName,
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: voucherSourceForTreasury(txn.id),
|
source: voucherSourceForTreasury(txn.id),
|
||||||
lines,
|
lines,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
|||||||
import type { AppData, TreasuryTxn } from '../../types'
|
import type { AppData, TreasuryTxn } from '../../types'
|
||||||
import { findExistingParty } from '../../parties/funzonePartySync'
|
import { findExistingParty } from '../../parties/funzonePartySync'
|
||||||
import { createId, nextNumber } from '../../utils/id'
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
|
import { formatEventName } from '../../utils/eventName'
|
||||||
import {
|
import {
|
||||||
isEventCustomerPayment,
|
isEventCustomerPayment,
|
||||||
ownerTxnExcludedFromTreasury,
|
ownerTxnExcludedFromTreasury,
|
||||||
@@ -35,8 +36,7 @@ function isoDateFromApi(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function eventLabel(eventName: string | null | undefined): string {
|
function eventLabel(eventName: string | null | undefined): string {
|
||||||
if (!eventName) return '—'
|
return formatEventName(eventName)
|
||||||
return eventName.toLowerCase() === 'wallet' ? 'کیف پول' : eventName
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
@@ -96,6 +96,7 @@ export function buildTreasuryFromCustomerPayment(
|
|||||||
ownerNetAmount: split?.ownerNet ?? 0,
|
ownerNetAmount: split?.ownerNet ?? 0,
|
||||||
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
||||||
description: lines.join('\n'),
|
description: lines.join('\n'),
|
||||||
|
eventName: item.event_name || undefined,
|
||||||
source: treasurySourceCustomerPayment(item.id),
|
source: treasurySourceCustomerPayment(item.id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +246,8 @@ function treasuryUnchanged(a: TreasuryTxn, b: TreasuryTxn): boolean {
|
|||||||
Math.abs((a.platformProfitAmount ?? 0) - (b.platformProfitAmount ?? 0)) < 0.01 &&
|
Math.abs((a.platformProfitAmount ?? 0) - (b.platformProfitAmount ?? 0)) < 0.01 &&
|
||||||
Math.abs((a.ownerNetAmount ?? 0) - (b.ownerNetAmount ?? 0)) < 0.01 &&
|
Math.abs((a.ownerNetAmount ?? 0) - (b.ownerNetAmount ?? 0)) < 0.01 &&
|
||||||
a.reference === b.reference &&
|
a.reference === b.reference &&
|
||||||
a.description === b.description
|
a.description === b.description &&
|
||||||
|
(a.eventName ?? '') === (b.eventName ?? '')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { apiGet, asList } from '../../api/client'
|
import { apiGet, asList } from '../../api/client'
|
||||||
import { ENDPOINTS } from '../../api/config'
|
import { ENDPOINTS } from '../../api/config'
|
||||||
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
||||||
@@ -27,7 +27,9 @@ export function useFunZoneTreasurySync() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [stats, setStats] = useState<FunZoneTreasurySyncStats | null>(null)
|
const [stats, setStats] = useState<FunZoneTreasurySyncStats | null>(null)
|
||||||
const autoSynced = useRef(false)
|
const syncingRef = useRef(false)
|
||||||
|
const dataRef = useRef(data)
|
||||||
|
dataRef.current = data
|
||||||
|
|
||||||
const persistTreasuryBatch = useCallback(
|
const persistTreasuryBatch = useCallback(
|
||||||
(baseData: AppData, items: TreasuryTxn[]): FunZoneTreasurySyncStats => {
|
(baseData: AppData, items: TreasuryTxn[]): FunZoneTreasurySyncStats => {
|
||||||
@@ -59,6 +61,8 @@ export function useFunZoneTreasurySync() {
|
|||||||
|
|
||||||
if (txn.partyId) {
|
if (txn.partyId) {
|
||||||
for (const inv of reconcileInvoicePaymentStatus(txn.partyId, working)) {
|
for (const inv of reconcileInvoicePaymentStatus(txn.partyId, working)) {
|
||||||
|
const current = working.invoices.find((i) => i.id === inv.id)
|
||||||
|
if (current?.status === inv.status) continue
|
||||||
upsertInvoice(inv)
|
upsertInvoice(inv)
|
||||||
working = {
|
working = {
|
||||||
...working,
|
...working,
|
||||||
@@ -83,10 +87,13 @@ export function useFunZoneTreasurySync() {
|
|||||||
[upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice],
|
[upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice],
|
||||||
)
|
)
|
||||||
|
|
||||||
const sync = useCallback(async () => {
|
const sync = useCallback(async (): Promise<FunZoneTreasurySyncStats | null> => {
|
||||||
|
if (syncingRef.current) return null
|
||||||
|
syncingRef.current = true
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
const current = dataRef.current
|
||||||
const [customerPayments, ownerTransactions] = await Promise.all([
|
const [customerPayments, ownerTransactions] = await Promise.all([
|
||||||
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
|
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
|
||||||
(res) => res.payments_and_refunds ?? [],
|
(res) => res.payments_and_refunds ?? [],
|
||||||
@@ -95,9 +102,9 @@ export function useFunZoneTreasurySync() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns } =
|
const { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns } =
|
||||||
buildFunZoneTreasurySync(data, customerPayments, ownerTransactions)
|
buildFunZoneTreasurySync(current, customerPayments, ownerTransactions)
|
||||||
|
|
||||||
let working = { ...data }
|
let working = { ...current }
|
||||||
for (const stale of toRemove) {
|
for (const stale of toRemove) {
|
||||||
const voucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(stale.id))
|
const voucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(stale.id))
|
||||||
if (voucher) removeVoucher(voucher.id)
|
if (voucher) removeVoucher(voucher.id)
|
||||||
@@ -121,20 +128,24 @@ export function useFunZoneTreasurySync() {
|
|||||||
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
|
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
|
||||||
ownerTxnCount: ownerTransactions.filter((t) => t.status !== 'failed' && t.type === 'withdraw').length,
|
ownerTxnCount: ownerTransactions.filter((t) => t.status !== 'failed' && t.type === 'withdraw').length,
|
||||||
})
|
})
|
||||||
|
return {
|
||||||
|
...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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'خطا در همگامسازی خزانه')
|
setError(err instanceof Error ? err.message : 'خطا در همگامسازی خزانه')
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
|
syncingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [data, persistTreasuryBatch])
|
}, [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 }
|
return { sync, loading, error, stats }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface Voucher {
|
|||||||
number: number
|
number: number
|
||||||
date: string
|
date: string
|
||||||
description: string
|
description: string
|
||||||
|
/** Event or subject the voucher relates to (بابت). */
|
||||||
|
regarding?: string
|
||||||
status: VoucherStatus
|
status: VoucherStatus
|
||||||
lines: VoucherLine[]
|
lines: VoucherLine[]
|
||||||
/** Set when the voucher was generated automatically by another module. */
|
/** Set when the voucher was generated automatically by another module. */
|
||||||
@@ -103,6 +105,8 @@ export interface InvoiceLine {
|
|||||||
unitPrice: number
|
unitPrice: number
|
||||||
discount: number
|
discount: number
|
||||||
taxRate: number
|
taxRate: number
|
||||||
|
/** FunZone event the customer paid for (shown in کالا / بابت). */
|
||||||
|
eventName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InvoiceKind = 'sale' | 'purchase'
|
export type InvoiceKind = 'sale' | 'purchase'
|
||||||
@@ -139,6 +143,8 @@ export interface TreasuryTxn {
|
|||||||
ownerNetAmount?: number
|
ownerNetAmount?: number
|
||||||
reference: string
|
reference: string
|
||||||
description: string
|
description: string
|
||||||
|
/** FunZone event name when imported from customer payments. */
|
||||||
|
eventName?: string
|
||||||
/** Set when imported from FunZone platform (dedup key). */
|
/** Set when imported from FunZone platform (dedup key). */
|
||||||
source?: string
|
source?: string
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/utils/eventName.ts
Normal file
22
src/utils/eventName.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** Human-readable label for a FunZone event name (wallet → کیف پول). */
|
||||||
|
export function formatEventName(eventName: string | null | undefined): string {
|
||||||
|
if (!eventName?.trim()) return '—'
|
||||||
|
return eventName.toLowerCase() === 'wallet' ? 'کیف پول' : eventName.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw event name stored on records (wallet stays as wallet). */
|
||||||
|
export function normalizeEventName(value: string): string {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (trimmed === 'کیف پول') return 'wallet'
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses event name from a FunZone treasury import description block. */
|
||||||
|
export function eventNameFromTreasuryDescription(description: string): string | undefined {
|
||||||
|
const line = description.split('\n').find((l) => l.startsWith('رویداد:'))
|
||||||
|
if (!line) return undefined
|
||||||
|
const raw = line.replace(/^رویداد:\s*/, '').trim()
|
||||||
|
if (!raw || raw === '—') return undefined
|
||||||
|
return raw === 'کیف پول' ? 'wallet' : raw
|
||||||
|
}
|
||||||
@@ -48,6 +48,25 @@ export function formatDateTime(iso: string): string {
|
|||||||
return jalaliDateTimeFormatter.format(date)
|
return jalaliDateTimeFormatter.format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Time portion of an ISO datetime (e.g. ۱۴:۳۰). */
|
||||||
|
export function formatTime(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return timeFormatter.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups Iranian IBAN digits for readability (IR + 24 digits). Keeps Latin digits for copy/paste. */
|
||||||
|
export function formatIban(iban: string | null | undefined): string {
|
||||||
|
const raw = iban?.replace(/\s/g, '').toUpperCase()
|
||||||
|
if (!raw) return '—'
|
||||||
|
return raw.replace(/(.{4})/g, '$1 ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
export { todayISO }
|
export { todayISO }
|
||||||
|
|
||||||
const toPersianDigits = (input: string): string =>
|
const toPersianDigits = (input: string): string =>
|
||||||
|
|||||||
132
src/utils/voucherFilters.ts
Normal file
132
src/utils/voucherFilters.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { AppData, Voucher } from '../types'
|
||||||
|
import { resolveVoucherRegarding } from './voucherRegarding'
|
||||||
|
|
||||||
|
export type VoucherCategoryFilter = 'all' | 'customer' | 'supplier' | 'manual'
|
||||||
|
export type VoucherStatusFilter = 'all' | 'draft' | 'posted'
|
||||||
|
export type VoucherOriginFilter = 'all' | 'funzone' | 'manual'
|
||||||
|
|
||||||
|
export interface VoucherFilters {
|
||||||
|
category: VoucherCategoryFilter
|
||||||
|
status: VoucherStatusFilter
|
||||||
|
origin: VoucherOriginFilter
|
||||||
|
dateFrom: string
|
||||||
|
dateTo: string
|
||||||
|
search: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultVoucherFilters: VoucherFilters = {
|
||||||
|
category: 'all',
|
||||||
|
status: 'all',
|
||||||
|
origin: 'all',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
search: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabels: Record<Exclude<VoucherCategoryFilter, 'all'>, string> = {
|
||||||
|
customer: 'مشتریان',
|
||||||
|
supplier: 'تأمینکنندگان',
|
||||||
|
manual: 'دستی',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function voucherCategoryLabel(category: Exclude<VoucherCategoryFilter, 'all'>): string {
|
||||||
|
return categoryLabels[category]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classifies a voucher as customer-related, supplier-related, or manually entered. */
|
||||||
|
export function classifyVoucher(
|
||||||
|
voucher: Voucher,
|
||||||
|
data: Pick<AppData, 'invoices' | 'partyAdjustments' | 'parties' | 'treasury'>,
|
||||||
|
): Exclude<VoucherCategoryFilter, 'all'> {
|
||||||
|
const src = voucher.source ?? ''
|
||||||
|
|
||||||
|
if (src.startsWith('funzone:cust') || src.startsWith('withdrawal:')) return 'customer'
|
||||||
|
if (src.startsWith('funzone:owner-txn')) return 'supplier'
|
||||||
|
|
||||||
|
if (src.startsWith('invoice:')) {
|
||||||
|
const inv = data.invoices.find((i) => i.id === src.slice('invoice:'.length))
|
||||||
|
if (inv?.kind === 'purchase') return 'supplier'
|
||||||
|
if (inv?.kind === 'sale') return 'customer'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src.startsWith('adjustment:')) {
|
||||||
|
const adj = data.partyAdjustments.find((a) => a.id === src.slice('adjustment:'.length))
|
||||||
|
const party = adj ? data.parties.find((p) => p.id === adj.partyId) : undefined
|
||||||
|
return party?.kind === 'supplier' ? 'supplier' : 'customer'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src.startsWith('treasury:')) {
|
||||||
|
const txn = data.treasury.find((t) => t.id === src.slice('treasury:'.length))
|
||||||
|
if (txn) {
|
||||||
|
if (txn.source?.startsWith('funzone:owner-txn')) return 'supplier'
|
||||||
|
if (txn.source?.startsWith('funzone:cust')) return 'customer'
|
||||||
|
if (txn.partyId) {
|
||||||
|
const party = data.parties.find((p) => p.id === txn.partyId)
|
||||||
|
if (party?.kind === 'supplier') return 'supplier'
|
||||||
|
if (party?.kind === 'customer') return 'customer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'manual'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAutomatedVoucher(voucher: Voucher): boolean {
|
||||||
|
return Boolean(voucher.source?.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunZoneSupplierTreasurySource(source: string | undefined): boolean {
|
||||||
|
return Boolean(source?.startsWith('funzone:owner-txn:'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countFunZoneSupplierDocuments(data: AppData): number {
|
||||||
|
const synced = new Set<string>()
|
||||||
|
for (const txn of data.treasury) {
|
||||||
|
if (isFunZoneSupplierTreasurySource(txn.source)) synced.add(txn.source!)
|
||||||
|
}
|
||||||
|
for (const voucher of data.vouchers) {
|
||||||
|
if (voucher.source?.startsWith('funzone:owner-txn:')) synced.add(voucher.source)
|
||||||
|
}
|
||||||
|
return synced.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVouchers(
|
||||||
|
vouchers: Voucher[],
|
||||||
|
data: AppData,
|
||||||
|
filters: VoucherFilters,
|
||||||
|
): Voucher[] {
|
||||||
|
const query = filters.search.trim().toLowerCase()
|
||||||
|
|
||||||
|
return vouchers.filter((voucher) => {
|
||||||
|
if (filters.category !== 'all' && classifyVoucher(voucher, data) !== filters.category) return false
|
||||||
|
|
||||||
|
if (filters.status !== 'all' && voucher.status !== filters.status) return false
|
||||||
|
|
||||||
|
if (filters.origin === 'funzone' && !isAutomatedVoucher(voucher)) return false
|
||||||
|
if (filters.origin === 'manual' && isAutomatedVoucher(voucher)) return false
|
||||||
|
|
||||||
|
if (filters.dateFrom && voucher.date < filters.dateFrom) return false
|
||||||
|
if (filters.dateTo && voucher.date > filters.dateTo) return false
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const regarding = resolveVoucherRegarding(voucher, data.treasury).toLowerCase()
|
||||||
|
const haystack = [voucher.description, regarding, String(voucher.number), voucher.source ?? '']
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
if (!haystack.includes(query)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function voucherFilterSummary(
|
||||||
|
vouchers: Voucher[],
|
||||||
|
data: AppData,
|
||||||
|
): Record<Exclude<VoucherCategoryFilter, 'all'>, number> {
|
||||||
|
const summary = { customer: 0, supplier: 0, manual: 0 }
|
||||||
|
for (const voucher of vouchers) {
|
||||||
|
summary[classifyVoucher(voucher, data)] += 1
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
47
src/utils/voucherRegarding.ts
Normal file
47
src/utils/voucherRegarding.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { TreasuryTxn, Voucher } from '../types'
|
||||||
|
import { eventNameFromTreasuryDescription, formatEventName } from './eventName'
|
||||||
|
|
||||||
|
/** Resolves the بابت (event/subject) label for a voucher row. */
|
||||||
|
export function resolveVoucherRegarding(
|
||||||
|
voucher: Voucher,
|
||||||
|
treasury: TreasuryTxn[] = [],
|
||||||
|
): string {
|
||||||
|
if (voucher.regarding?.trim()) return formatEventName(voucher.regarding)
|
||||||
|
|
||||||
|
if (voucher.source?.startsWith('treasury:')) {
|
||||||
|
const txnId = voucher.source.slice('treasury:'.length)
|
||||||
|
const txn = treasury.find((t) => t.id === txnId)
|
||||||
|
if (txn?.eventName) return formatEventName(txn.eventName)
|
||||||
|
if (txn?.description) {
|
||||||
|
const parsed = eventNameFromTreasuryDescription(txn.description)
|
||||||
|
if (parsed) return formatEventName(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voucher.source?.startsWith('funzone:cust-pay:') || voucher.source?.startsWith('funzone:cust:')) {
|
||||||
|
const eventLine = voucher.lines.find((l) => l.description.includes('درآمد -') || l.description.includes('کاهش درآمد -'))
|
||||||
|
if (eventLine) {
|
||||||
|
const match = eventLine.description.match(/(?:درآمد|کاهش درآمد)\s*-\s*(.+)/)
|
||||||
|
if (match?.[1]) return formatEventName(match[1].trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const regardingLine = voucher.lines.find(
|
||||||
|
(l) => l.description.includes('بابت') || l.description.includes('رویداد'),
|
||||||
|
)
|
||||||
|
if (regardingLine) {
|
||||||
|
const match = regardingLine.description.match(/رویداد[:\s-]+(.+)/)
|
||||||
|
if (match?.[1]) return formatEventName(match[1].trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invoiceLineLabel(
|
||||||
|
line: { eventName?: string; productId: string },
|
||||||
|
productName?: string,
|
||||||
|
): string {
|
||||||
|
if (line.eventName?.trim()) return formatEventName(line.eventName)
|
||||||
|
if (productName) return productName
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user