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:
Shayan Azadi
2026-07-06 22:09:08 +03:30
parent 4508be582a
commit f6b6e27294
24 changed files with 1258 additions and 90 deletions

View File

@@ -1,9 +1,12 @@
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 { useStore } from '../store/AppStore'
import { createId, nextNumber } from '../utils/id'
import { invoiceTotals } from '../utils/accounting'
import { formatEventName } from '../utils/eventName'
import { isWithdrawalReturn } from '../pages/sales/salesUtils'
export interface SyncResult {
@@ -39,25 +42,39 @@ function makeVoucher(
description: string,
lines: VoucherLine[],
source: string,
regarding?: string,
): Voucher {
return {
id: createId('v-'),
number,
date: isoDate.slice(0, 10),
description,
regarding,
status: 'posted',
source,
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(
records: ApiOwnerTransaction[],
map: VoucherAccountMap,
existingSources: Set<string>,
startNumber: number,
treasury: TreasuryTxn[],
vouchers: Voucher[],
): { vouchers: Voucher[]; skipped: number } {
const vouchers: Voucher[] = []
const vouchersOut: Voucher[] = []
let skipped = 0
let number = startNumber
@@ -67,8 +84,8 @@ function buildOwnerVouchers(
const amount = Math.abs(txn.amount)
if (amount <= 0) continue
const source = `funzone:owner-txn:${txn.id}`
if (existingSources.has(source)) {
const source = treasurySourceOwnerTxn(txn.id)
if (existingSources.has(source) || ownerTxnAlreadyBookedViaTreasury(treasury, vouchers, txn.id)) {
skipped += 1
continue
}
@@ -94,11 +111,11 @@ function buildOwnerVouchers(
continue
}
vouchers.push(makeVoucher(number, txn.created_at, description, lines, source))
vouchersOut.push(makeVoucher(number, txn.created_at, description, lines, source))
number += 1
}
return { vouchers, skipped }
return { vouchers: vouchersOut, skipped }
}
function walletWithdrawAlreadyBooked(
@@ -141,6 +158,7 @@ function buildCustomerVouchers(
const isWallet = (item.event_name || '').toLowerCase() === 'wallet'
const label = item.customer_name || 'مشتری'
const regarding = isWallet ? 'wallet' : item.event_name || undefined
let lines: VoucherLine[]
let description: string
@@ -154,7 +172,7 @@ function buildCustomerVouchers(
} else {
lines = [
makeLine(map.bankAccountId, amount, 0, 'دریافت بابت رزرو'),
makeLine(map.salesIncomeAccountId, 0, amount, `درآمد - ${item.event_name}`),
makeLine(map.salesIncomeAccountId, 0, amount, `درآمد - ${formatEventName(item.event_name)}`),
]
description = `فروش/رزرو - ${label}`
}
@@ -170,13 +188,13 @@ function buildCustomerVouchers(
description = `برداشت کیف پول - ${label}`
} else {
lines = [
makeLine(map.salesIncomeAccountId, amount, 0, `کاهش درآمد - ${item.event_name}`),
makeLine(map.salesIncomeAccountId, amount, 0, `کاهش درآمد - ${formatEventName(item.event_name)}`),
makeLine(map.bankAccountId, 0, amount, 'بازپرداخت'),
]
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
}
@@ -215,8 +233,10 @@ export function useVoucherSync() {
const syncOwnerTransactions = useCallback(
(records: ApiOwnerTransaction[]): Promise<SyncResult> =>
run((map, sources, start) => buildOwnerVouchers(records, map, sources, start)),
[run],
run((map, sources, start) =>
buildOwnerVouchers(records, map, sources, start, data.treasury, data.vouchers),
),
[run, data.treasury, data.vouchers],
)
const syncCustomerActivity = useCallback(

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

View File

@@ -19,8 +19,10 @@ import type { Invoice, InvoiceKind, InvoiceLine, InvoiceStatus, Party } from '..
import { createId, nextNumber } from '../../utils/id'
import { formatDate, formatMoney, todayISO, toFa } from '../../utils/format'
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 { InvoiceLineGoodsCell } from './InvoiceLineGoodsCell'
import { formatEventName } from '../../utils/eventName'
interface TradeConfig {
kind: InvoiceKind
@@ -154,7 +156,12 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
const handleSave = () => {
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
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: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
{ 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',
header: 'اقلام',
@@ -315,7 +334,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2 text-start font-medium">کالا</th>
<th className="px-3 py-2 text-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>
@@ -328,14 +347,13 @@ function InvoiceSection({ config }: { config: TradeConfig }) {
{draft.lines.map((line) => (
<tr key={line.id}>
<td className="px-2 py-1.5 min-w-[180px]">
<Select value={line.productId} onChange={(e) => onSelectProduct(line.id, e.target.value)}>
<option value="">انتخاب کالا</option>
{data.products.map((product) => (
<option key={product.id} value={product.id}>
{product.name}
</option>
))}
</Select>
<InvoiceLineGoodsCell
line={line}
products={data.products}
showEventName={config.kind === 'sale'}
onChange={(patch) => updateLine(line.id, patch)}
onSelectProduct={(productId) => onSelectProduct(line.id, productId)}
/>
</td>
<td className="px-2 py-1.5 w-24">
<Input

View File

@@ -12,12 +12,26 @@ import {
Modal,
PageHeader,
Select,
StatCard,
type Column,
} from '../components/ui'
import type { Voucher, VoucherLine } from '../types'
import { createId, nextNumber } from '../utils/id'
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format'
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 => ({
id: createId('vl-'),
@@ -33,6 +47,7 @@ function createDraft(items: Voucher[]): Voucher {
number: nextNumber(items),
date: todayISO(),
description: '',
regarding: '',
status: 'draft',
lines: [newLine(), newLine()],
}
@@ -42,6 +57,12 @@ export function Vouchers() {
const { data, upsertVoucher, removeVoucher } = useStore()
const [draft, setDraft] = 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(
() => 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 sorted = useMemo(
() => [...data.vouchers].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
[data.vouchers],
)
const summary = useMemo(() => voucherFilterSummary(data.vouchers, data), [data])
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
@@ -75,6 +123,17 @@ export function Vouchers() {
}
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',
header: 'شماره',
@@ -89,6 +148,17 @@ export function Vouchers() {
className: 'w-[6.5rem] whitespace-nowrap tabular-nums',
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',
header: 'شرح',
@@ -115,7 +185,14 @@ export function Vouchers() {
headerClassName: 'w-[6.5rem]',
className: 'w-[6.5rem] whitespace-nowrap',
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',
@@ -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">
<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
columns={columns}
rows={sorted}
rows={filtered}
rowKey={(v) => v.id}
emptyTitle="سندی ثبت نشده است"
emptyDescription="اولین سند حسابداری خود را ایجاد کنید."
emptyTitle={hasActiveFilters ? 'سندی با این فیلتر یافت نشد' : 'سندی ثبت نشده است'}
emptyDescription={
hasActiveFilters
? 'فیلترها را تغییر دهید یا پاک کنید.'
: 'اولین سند حسابداری خود را ایجاد کنید.'
}
/>
</Card>
@@ -193,7 +329,7 @@ export function Vouchers() {
>
{draft && (
<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="شماره سند">
<Input
type="number"
@@ -204,6 +340,15 @@ export function Vouchers() {
<Field label="تاریخ">
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
</Field>
<Field label="بابت">
<Input
value={draft.regarding ?? ''}
onChange={(e) =>
setDraft({ ...draft, regarding: normalizeEventName(e.target.value) || undefined })
}
placeholder="رویداد / موضوع سند"
/>
</Field>
<Field label="شرح سند">
<Input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
</Field>
@@ -215,6 +360,7 @@ export function Vouchers() {
<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-end font-medium">بدهکار</th>
<th className="px-3 py-2 text-end font-medium">بستانکار</th>
<th className="px-3 py-2" />
@@ -243,6 +389,9 @@ export function Vouchers() {
placeholder="شرح"
/>
</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">
<Input
type="number"

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

View File

@@ -1,7 +1,10 @@
import { FunZonePartySection } from '../../parties/FunZonePartySection'
import { FunZonePurchasesSyncBanner } from './FunZonePurchasesSyncBanner'
export function PurchaseSuppliers() {
return (
<div className="space-y-6">
<FunZonePurchasesSyncBanner />
<FunZonePartySection
config={{
kind: 'supplier',
@@ -10,5 +13,6 @@ export function PurchaseSuppliers() {
externalSource: 'funzone_owner',
}}
/>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { NavLink, Outlet } from 'react-router-dom'
import { PageHeader } from '../../components/ui'
import { PurchasesSyncProvider } from './PurchasesSyncContext'
const purchaseNavItems = [
{ to: '/purchases/suppliers', label: 'تأمین‌کنندگان', icon: '🏢', end: false },
@@ -9,6 +10,7 @@ const purchaseNavItems = [
export function PurchasesLayout() {
return (
<PurchasesSyncProvider>
<div className="space-y-6">
<PageHeader
title="تأمین‌کنندگان و خرید"
@@ -46,5 +48,6 @@ export function PurchasesLayout() {
</div>
</div>
</div>
</PurchasesSyncProvider>
)
}

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

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

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react'
import type { ApiWithdrawal } from '../../api/types'
import type { Invoice, Party } from '../../types'
import { Badge, Button, Card, DataTable, StatCard, type Column } from '../../components/ui'
import { formatDateTime, formatMoney, toFa } from '../../utils/format'
import { formatDate, formatIban, formatMoney, formatTime, toFa } from '../../utils/format'
import { invoiceForWithdrawal } from './usePendingCustomerWithdrawals'
interface PendingWithdrawalsPanelProps {
@@ -15,6 +15,49 @@ interface PendingWithdrawalsPanelProps {
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({
withdrawals,
loading,
@@ -30,35 +73,57 @@ export function PendingWithdrawalsPanel({
)
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',
header: 'مبلغ',
header: 'مبلغ (تومان)',
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: 'تاریخ درخواست',
render: (w) => formatDateTime(w.created_at),
headerClassName: 'w-[6.5rem]',
className: 'w-[6.5rem]',
render: (w) => <RequestDateCell iso={w.created_at} />,
},
{
key: 'status',
header: 'وضعیت',
headerClassName: 'w-[5.5rem]',
className: 'w-[5.5rem] whitespace-nowrap',
render: () => <Badge tone="amber">در انتظار</Badge>,
},
{
key: 'actions',
header: '',
align: 'end',
sticky: true,
headerClassName: 'w-[9.5rem]',
className: 'w-[9.5rem] whitespace-nowrap',
render: (w) => {
const party = customerList.find((p) => p.externalId === w.user_id)
return (
<Button
size="sm"
variant="ghost"
variant="secondary"
disabled={!party}
onClick={() => onCreateReturn(w)}
title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
@@ -71,12 +136,12 @@ export function PendingWithdrawalsPanel({
]
return (
<Card className="border-amber-200 bg-amber-50/30 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<Card className="border-amber-200 bg-gradient-to-b from-amber-50/60 to-white p-4 sm:p-5">
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-amber-100 pb-4">
<div>
<h3 className="font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
<p className="text-sm text-slate-500">
درخواستهای برداشت کیف پول مشتریان از بخش حسابداری ادمین برای هر مورد فاکتور برگشتی صادر کنید
<h3 className="text-lg font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-slate-500">
درخواستهای برداشت کیف پول مشتریان شبا، مبلغ و تاریخ هر درخواست جدا نمایش داده میشود.
</p>
</div>
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
@@ -84,7 +149,7 @@ export function PendingWithdrawalsPanel({
</Button>
</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
label="برداشت در انتظار"
value={toFa(openWithdrawals.length)}
@@ -99,17 +164,26 @@ export function PendingWithdrawalsPanel({
/>
</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 ? (
<p className="py-6 text-center text-sm text-slate-400">در حال بارگذاری برداشتها</p>
<p className="py-8 text-center text-sm text-slate-400">در حال بارگذاری برداشتها</p>
) : (
<DataTable
columns={columns}
rows={openWithdrawals}
rowKey={(w) => w.id}
emptyTitle="برداشت در انتظاری ثبت نشده است"
/>
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-sm">
<DataTable
columns={columns}
rows={openWithdrawals}
rowKey={(w) => w.id}
emptyTitle="برداشت در انتظاری ثبت نشده است"
minWidth="52rem"
size="comfortable"
className="rounded-xl"
/>
</div>
)}
</Card>
)

View File

@@ -23,11 +23,14 @@ import {
byDocumentType,
customers,
documentTypeLabels,
invoiceRegarding,
isWithdrawalReturn,
reconcileInvoicePaymentStatus,
statusLabels,
} from './salesUtils'
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
import { InvoiceLineGoodsCell } from '../../components/business/InvoiceLineGoodsCell'
import { formatEventName } from '../../utils/eventName'
import {
usePendingCustomerWithdrawals,
withdrawalNoteTag,
@@ -169,7 +172,7 @@ export function SalesDocumentSection({
salesTypeId: source.salesTypeId,
relatedInvoiceId: source.id,
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,
discount: 0,
taxRate: 0,
eventName: 'wallet',
},
],
})
@@ -201,7 +205,7 @@ export function SalesDocumentSection({
const fromWithdrawal = isWithdrawalReturn(draft)
const cleanLines = draft.lines.filter((line) => {
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
@@ -251,6 +255,18 @@ export function SalesDocumentSection({
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
{
key: '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',
header: 'نوع فروش',
@@ -469,7 +485,7 @@ export function SalesDocumentSection({
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2 text-start font-medium">کالا</th>
<th className="px-3 py-2 text-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>
@@ -482,14 +498,14 @@ export function SalesDocumentSection({
{draft.lines.map((line) => (
<tr key={line.id}>
<td className="min-w-[180px] px-2 py-1.5">
<Select value={line.productId} onChange={(e) => onSelectProduct(line.id, e.target.value)}>
<option value="">انتخاب کالا</option>
{data.products.map((product) => (
<option key={product.id} value={product.id}>
{product.name} (موجودی: {toFa(product.stock)})
</option>
))}
</Select>
<InvoiceLineGoodsCell
line={line}
products={data.products}
showEventName
withdrawalReturn={isWithdrawalReturn(draft)}
onChange={(patch) => updateLine(line.id, patch)}
onSelectProduct={(productId) => onSelectProduct(line.id, productId)}
/>
</td>
<td className="w-24 px-2 py-1.5">
<Input

View File

@@ -1,10 +1,12 @@
import { NavLink, Outlet } from 'react-router-dom'
import { PageHeader } from '../../components/ui'
import { salesNavItems } from './salesNavigation'
import { SalesSyncProvider } from './SalesSyncContext'
export function SalesLayout() {
return (
<div className="space-y-6">
<SalesSyncProvider>
<div className="space-y-6">
<PageHeader
title="مشتریان و فروش"
subtitle="مدیریت فرایند فروش، فاکتورها، اعلامیه‌ها و مرور فروش"
@@ -41,5 +43,6 @@ export function SalesLayout() {
</div>
</div>
</div>
</SalesSyncProvider>
)
}

View File

@@ -4,7 +4,9 @@ import { useStore } from '../../store/AppStore'
import { Card, StatCard } from '../../components/ui'
import { formatMoney, toFa } from '../../utils/format'
import { salesNavItems } from './salesNavigation'
import { salesSummary } from './salesUtils'
import { salesDocuments, salesSummary } from './salesUtils'
import { FunZoneSalesSyncBanner } from './FunZoneSalesSyncBanner'
import { isFunZoneSalesInvoice } from './funzoneSalesSync'
const processSteps = [
{ step: 1, label: 'پیش‌فاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
@@ -16,11 +18,16 @@ const processSteps = [
export function SalesProcess() {
const { data } = useStore()
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')
return (
<div className="space-y-6">
<FunZoneSalesSyncBanner />
<Card className="p-5">
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
<p className="mb-6 text-sm text-slate-500">
@@ -51,11 +58,12 @@ export function SalesProcess() {
</div>
</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.invoiceCount)} hint={formatMoney(summary.totalSales)} />
<StatCard label="فاکتور برگشتی" value={toFa(summary.returnCount)} hint={formatMoney(summary.totalReturns)} />
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} hint={`${toFa(summary.unpaidCount)} تسویه‌نشده`} />
<StatCard label="همگام فان‌زون" value={toFa(funzoneSyncedCount)} hint="فاکتور از پرداخت رویداد" tone="brand" />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">

View File

@@ -17,10 +17,14 @@ import { invoiceTotals } from '../../utils/accounting'
import {
customers,
documentTypeLabels,
invoiceRegarding,
salesDocuments,
salesSummary,
statusLabels,
} from './salesUtils'
import { formatEventName } from '../../utils/eventName'
import { FunZoneSalesSyncBanner } from './FunZoneSalesSyncBanner'
import { isFunZoneSalesInvoice } from './funzoneSalesSync'
export function SalesReview() {
const { data } = useStore()
@@ -57,6 +61,14 @@ export function SalesReview() {
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
{
key: 'regarding',
header: 'بابت',
render: (i) => {
const regarding = invoiceRegarding(i)
return regarding ? formatEventName(regarding) : '—'
},
},
{ key: 'salesType', header: 'نوع فروش', render: (i) => salesTypeName(i.salesTypeId) },
{
key: 'net',
@@ -68,15 +80,20 @@ export function SalesReview() {
key: 'status',
header: 'وضعیت',
render: (i) => (
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : 'amber'}>
{statusLabels[i.status]}
</Badge>
<div className="flex flex-col items-start gap-1">
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : 'amber'}>
{statusLabels[i.status]}
</Badge>
{isFunZoneSalesInvoice(i) ? <Badge tone="slate">فانزون</Badge> : null}
</div>
),
},
]
return (
<div className="space-y-6">
<FunZoneSalesSyncBanner />
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard label="کل فروش" value={formatMoney(summary.totalSales)} />
<StatCard label="برگشت" value={formatMoney(summary.totalReturns)} />

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

View 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}\اکتور خودکار از فان‌زون — ${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-'),
}))
}

View File

@@ -12,6 +12,7 @@ import type {
VoucherLine,
} from '../../types'
import { invoiceTotals } from '../../utils/accounting'
import { eventNameFromTreasuryDescription } from '../../utils/eventName'
import { createId, nextNumber } from '../../utils/id'
/** 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
}
/** 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. */
export function affectsStock(invoice: Invoice): boolean {
if (invoice.status === 'draft') return false
@@ -205,6 +211,7 @@ export function buildSalesVoucher(
const party = data.parties.find((p) => p.id === invoice.partyId)
const label = `${documentTypeLabels[docType]} #${invoice.number} - ${party?.name ?? ''}`
const regarding = invoiceRegarding(invoice)
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
id: createId('vl-'),
@@ -226,6 +233,7 @@ export function buildSalesVoucher(
number: nextNumber(data.vouchers),
date: invoice.date,
description: `برداشت کیف پول - ${label}`,
regarding: regarding ?? 'wallet',
status: 'posted',
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice.id),
lines: [
@@ -253,6 +261,7 @@ export function buildSalesVoucher(
number: nextNumber(data.vouchers),
date: invoice.date,
description: label,
regarding,
status: 'posted',
source: voucherSourceForInvoice(invoice.id),
lines,
@@ -373,8 +382,11 @@ export function reconcileInvoicePaymentStatus(
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
const shouldPaid = shouldBePaidIds.has(inv.id)
if (inv.status === 'paid' && !shouldPaid) updates.push({ ...inv, status: 'confirmed' })
else if (inv.status === 'confirmed' && shouldPaid) updates.push({ ...inv, status: 'paid' })
if (inv.status === 'paid' && !shouldPaid) {
// 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
}
@@ -478,11 +490,13 @@ export function buildTreasuryVoucher(
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
if (hasEventSplit) {
const regarding = txn.eventName || eventNameFromTreasuryDescription(txn.description)
return {
id: createId('v-'),
number: nextNumber(data.vouchers),
date: txn.date,
description: txn.description || label,
regarding,
status: 'posted',
source: voucherSourceForTreasury(txn.id),
lines: [
@@ -512,6 +526,7 @@ export function buildTreasuryVoucher(
number: nextNumber(data.vouchers),
date: txn.date,
description: txn.description || label,
regarding: txn.eventName,
status: 'posted',
source: voucherSourceForTreasury(txn.id),
lines,

View File

@@ -2,6 +2,7 @@ import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
import type { AppData, TreasuryTxn } from '../../types'
import { findExistingParty } from '../../parties/funzonePartySync'
import { createId, nextNumber } from '../../utils/id'
import { formatEventName } from '../../utils/eventName'
import {
isEventCustomerPayment,
ownerTxnExcludedFromTreasury,
@@ -35,8 +36,7 @@ function isoDateFromApi(iso: string): string {
}
function eventLabel(eventName: string | null | undefined): string {
if (!eventName) return '—'
return eventName.toLowerCase() === 'wallet' ? 'کیف پول' : eventName
return formatEventName(eventName)
}
function statusLabel(status: string): string {
@@ -96,6 +96,7 @@ export function buildTreasuryFromCustomerPayment(
ownerNetAmount: split?.ownerNet ?? 0,
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
description: lines.join('\n'),
eventName: item.event_name || undefined,
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.ownerNetAmount ?? 0) - (b.ownerNetAmount ?? 0)) < 0.01 &&
a.reference === b.reference &&
a.description === b.description
a.description === b.description &&
(a.eventName ?? '') === (b.eventName ?? '')
)
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { apiGet, asList } from '../../api/client'
import { ENDPOINTS } from '../../api/config'
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
@@ -27,7 +27,9 @@ export function useFunZoneTreasurySync() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | 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(
(baseData: AppData, items: TreasuryTxn[]): FunZoneTreasurySyncStats => {
@@ -59,6 +61,8 @@ export function useFunZoneTreasurySync() {
if (txn.partyId) {
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)
working = {
...working,
@@ -83,10 +87,13 @@ export function useFunZoneTreasurySync() {
[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)
setError(null)
try {
const current = dataRef.current
const [customerPayments, ownerTransactions] = await Promise.all([
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
(res) => res.payments_and_refunds ?? [],
@@ -95,9 +102,9 @@ export function useFunZoneTreasurySync() {
])
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) {
const voucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(stale.id))
if (voucher) removeVoucher(voucher.id)
@@ -121,20 +128,24 @@ export function useFunZoneTreasurySync() {
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').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) {
setError(err instanceof Error ? err.message : 'خطا در همگام‌سازی خزانه')
return null
} finally {
syncingRef.current = false
setLoading(false)
}
}, [data, persistTreasuryBatch])
useEffect(() => {
if (autoSynced.current || data.parties.length === 0) return
const hasFunZoneParties = data.parties.some((p) => p.externalSource?.startsWith('funzone_'))
if (!hasFunZoneParties) return
autoSynced.current = true
void sync()
}, [data.parties, sync])
}, [persistTreasuryBatch])
return { sync, loading, error, stats }
}

View File

@@ -27,6 +27,8 @@ export interface Voucher {
number: number
date: string
description: string
/** Event or subject the voucher relates to (بابت). */
regarding?: string
status: VoucherStatus
lines: VoucherLine[]
/** Set when the voucher was generated automatically by another module. */
@@ -103,6 +105,8 @@ export interface InvoiceLine {
unitPrice: number
discount: number
taxRate: number
/** FunZone event the customer paid for (shown in کالا / بابت). */
eventName?: string
}
export type InvoiceKind = 'sale' | 'purchase'
@@ -139,6 +143,8 @@ export interface TreasuryTxn {
ownerNetAmount?: number
reference: string
description: string
/** FunZone event name when imported from customer payments. */
eventName?: string
/** Set when imported from FunZone platform (dedup key). */
source?: string
}

22
src/utils/eventName.ts Normal file
View 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
}

View File

@@ -48,6 +48,25 @@ export function formatDateTime(iso: string): string {
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 }
const toPersianDigits = (input: string): string =>

132
src/utils/voucherFilters.ts Normal file
View 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
}

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