From f6b6e27294c77eef7702fbb71cbae5571a28df44 Mon Sep 17 00:00:00 2001 From: Shayan Azadi Date: Mon, 6 Jul 2026 22:09:08 +0330 Subject: [PATCH] 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 --- src/accounting/voucherSync.ts | 42 +++- .../business/InvoiceLineGoodsCell.tsx | 55 +++++ src/components/business/TradeModule.tsx | 40 +++- src/pages/Vouchers.tsx | 167 ++++++++++++++- .../purchases/FunZonePurchasesSyncBanner.tsx | 46 ++++ src/pages/purchases/PurchaseSuppliers.tsx | 4 + src/pages/purchases/PurchasesLayout.tsx | 3 + src/pages/purchases/PurchasesSyncContext.tsx | 86 ++++++++ src/pages/sales/FunZoneSalesSyncBanner.tsx | 50 +++++ src/pages/sales/PendingWithdrawalsPanel.tsx | 120 +++++++++-- src/pages/sales/SalesDocumentSection.tsx | 38 +++- src/pages/sales/SalesLayout.tsx | 5 +- src/pages/sales/SalesProcess.tsx | 12 +- src/pages/sales/SalesReview.tsx | 23 +- src/pages/sales/SalesSyncContext.tsx | 163 ++++++++++++++ src/pages/sales/funzoneSalesSync.ts | 202 ++++++++++++++++++ src/pages/sales/salesUtils.ts | 19 +- src/pages/treasury/funzoneTreasurySync.ts | 8 +- src/pages/treasury/useFunZoneTreasurySync.ts | 39 ++-- src/types/index.ts | 6 + src/utils/eventName.ts | 22 ++ src/utils/format.ts | 19 ++ src/utils/voucherFilters.ts | 132 ++++++++++++ src/utils/voucherRegarding.ts | 47 ++++ 24 files changed, 1258 insertions(+), 90 deletions(-) create mode 100644 src/components/business/InvoiceLineGoodsCell.tsx create mode 100644 src/pages/purchases/FunZonePurchasesSyncBanner.tsx create mode 100644 src/pages/purchases/PurchasesSyncContext.tsx create mode 100644 src/pages/sales/FunZoneSalesSyncBanner.tsx create mode 100644 src/pages/sales/SalesSyncContext.tsx create mode 100644 src/pages/sales/funzoneSalesSync.ts create mode 100644 src/utils/eventName.ts create mode 100644 src/utils/voucherFilters.ts create mode 100644 src/utils/voucherRegarding.ts diff --git a/src/accounting/voucherSync.ts b/src/accounting/voucherSync.ts index 80caafc..03e57e6 100644 --- a/src/accounting/voucherSync.ts +++ b/src/accounting/voucherSync.ts @@ -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, 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 => - 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( diff --git a/src/components/business/InvoiceLineGoodsCell.tsx b/src/components/business/InvoiceLineGoodsCell.tsx new file mode 100644 index 0000000..fae6b17 --- /dev/null +++ b/src/components/business/InvoiceLineGoodsCell.tsx @@ -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) => void + onSelectProduct: (productId: string) => void +} + +export function InvoiceLineGoodsCell({ + line, + products, + showEventName = false, + withdrawalReturn = false, + onChange, + onSelectProduct, +}: InvoiceLineGoodsCellProps) { + if (withdrawalReturn) { + return ( + onChange({ eventName: normalizeEventName(e.target.value) || 'wallet' })} + placeholder="رویداد / بابت" + /> + ) + } + + return ( +
+ {showEventName && ( + onChange({ eventName: normalizeEventName(e.target.value) || undefined })} + placeholder="رویداد (بابت پرداخت)" + className="text-xs" + /> + )} + +
+ ) +} diff --git a/src/components/business/TradeModule.tsx b/src/components/business/TradeModule.tsx index 346c92f..c3d635d 100644 --- a/src/components/business/TradeModule.tsx +++ b/src/components/business/TradeModule.tsx @@ -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) => {toFa(i.number)} }, { 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, + ] + : []), { key: 'count', header: 'اقلام', @@ -315,7 +334,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) { - + @@ -328,14 +347,13 @@ function InvoiceSection({ config }: { config: TradeConfig }) { {draft.lines.map((line) => ( +
کالا{config.kind === 'sale' ? 'کالا / رویداد' : 'کالا'} تعداد قیمت واحد تخفیف
- + updateLine(line.id, patch)} + onSelectProduct={(productId) => onSelectProduct(line.id, productId)} + /> ({ 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(null) const [toDelete, setToDelete] = useState(null) + const [category, setCategory] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + const [origin, setOrigin] = useState('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> = [ + { + 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 {voucherCategoryLabel(cat)} + }, + }, { 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) => ( + + {resolveVoucherRegarding(v, data.treasury)} + + ), + }, { 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' ? ثبت قطعی : پیش‌نویس, + v.status === 'posted' ? ( +
+ ثبت قطعی + {isAutomatedVoucher(v) ? خودکار : null} +
+ ) : ( + پیش‌نویس + ), }, { key: 'actions', @@ -149,13 +226,72 @@ export function Vouchers() { } /> +
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + setSearch(e.target.value)} + placeholder="شماره، شرح، بابت…" + className="min-w-[12rem]" + /> + + {hasActiveFilters ? ( + + ) : null} +
+ +

+ نمایش {toFa(filtered.length)} از {toFa(data.vouchers.length)} سند +

+ v.id} - emptyTitle="سندی ثبت نشده است" - emptyDescription="اولین سند حسابداری خود را ایجاد کنید." + emptyTitle={hasActiveFilters ? 'سندی با این فیلتر یافت نشد' : 'سندی ثبت نشده است'} + emptyDescription={ + hasActiveFilters + ? 'فیلترها را تغییر دهید یا پاک کنید.' + : 'اولین سند حسابداری خود را ایجاد کنید.' + } />
@@ -193,7 +329,7 @@ export function Vouchers() { > {draft && (
-
+
setDraft({ ...draft, date })} /> + + + setDraft({ ...draft, regarding: normalizeEventName(e.target.value) || undefined }) + } + placeholder="رویداد / موضوع سند" + /> + setDraft({ ...draft, description: e.target.value })} /> @@ -215,6 +360,7 @@ export function Vouchers() {
حساب شرح ردیفبابت بدهکار بستانکار @@ -243,6 +389,9 @@ export function Vouchers() { placeholder="شرح" /> + + {resolveVoucherRegarding(draft, data.treasury)} + countFunZoneSupplierDocuments(data), [data]) + + return ( + +
+
+

همگام‌سازی با فان‌زون

+

+ برای دریافت برداشت‌ها و بازپرداخت‌های مالکان از فان‌زون و تبدیل آن‌ها به پرداخت و سند حسابداری، + دکمه همگام‌سازی را بزنید. +

+
+ + {toFa(syncedCount)} تراکنش همگام‌شده + + {lastSyncedAt ? ( + + آخرین بروزرسانی: {formatDateTime(lastSyncedAt)} + + ) : null} + {stats ? ( + + پرداخت جدید: {toFa(stats.treasuryCreated)} · سند جدید: {toFa(stats.ownerVouchersCreated)} + + ) : null} +
+ {error ?

{error}

: null} +
+ +
+
+ ) +} diff --git a/src/pages/purchases/PurchaseSuppliers.tsx b/src/pages/purchases/PurchaseSuppliers.tsx index 1cdb41b..32c853b 100644 --- a/src/pages/purchases/PurchaseSuppliers.tsx +++ b/src/pages/purchases/PurchaseSuppliers.tsx @@ -1,7 +1,10 @@ import { FunZonePartySection } from '../../parties/FunZonePartySection' +import { FunZonePurchasesSyncBanner } from './FunZonePurchasesSyncBanner' export function PurchaseSuppliers() { return ( +
+ +
) } diff --git a/src/pages/purchases/PurchasesLayout.tsx b/src/pages/purchases/PurchasesLayout.tsx index bcc963a..b3d2e36 100644 --- a/src/pages/purchases/PurchasesLayout.tsx +++ b/src/pages/purchases/PurchasesLayout.tsx @@ -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 ( +
+
) } diff --git a/src/pages/purchases/PurchasesSyncContext.tsx b/src/pages/purchases/PurchasesSyncContext.tsx new file mode 100644 index 0000000..28e8bf8 --- /dev/null +++ b/src/pages/purchases/PurchasesSyncContext.tsx @@ -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 +} + +const PurchasesSyncContext = createContext(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(null) + const [stats, setStats] = useState(null) + const [lastSyncedAt, setLastSyncedAt] = useState(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(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then( + asList, + ) + 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 ( + + {children} + + ) +} diff --git a/src/pages/sales/FunZoneSalesSyncBanner.tsx b/src/pages/sales/FunZoneSalesSyncBanner.tsx new file mode 100644 index 0000000..4a5857d --- /dev/null +++ b/src/pages/sales/FunZoneSalesSyncBanner.tsx @@ -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 ( + +
+
+

همگام‌سازی با فان‌زون

+

+ برای دریافت پرداخت‌های رویداد و لغو رزرو از فان‌زون و تبدیل آن‌ها به فاکتور فروش/برگشتی و + دریافت و پرداخت، دکمه همگام‌سازی را بزنید. +

+
+ + {toFa(funzoneDocCount)} سند همگام‌شده در فروش + + {lastSyncedAt ? ( + + آخرین بروزرسانی: {formatDateTime(lastSyncedAt)} + + ) : null} + {stats ? ( + + فاکتور جدید: {toFa(stats.invoicesCreated)} · دریافت/پرداخت: {toFa(stats.treasuryCreated)} + + ) : null} +
+ {error ?

{error}

: null} +
+ +
+
+ ) +} diff --git a/src/pages/sales/PendingWithdrawalsPanel.tsx b/src/pages/sales/PendingWithdrawalsPanel.tsx index f63d94e..f884cf2 100644 --- a/src/pages/sales/PendingWithdrawalsPanel.tsx +++ b/src/pages/sales/PendingWithdrawalsPanel.tsx @@ -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 ( +
+

+ {withdrawal.user_name} +

+ {withdrawal.username ? ( +

+ @{withdrawal.username} +

+ ) : null} +
+ ) +} + +function IbanCell({ iban }: { iban: string }) { + const formatted = formatIban(iban) + if (formatted === '—') { + return ثبت نشده + } + + return ( +
+ + {formatted} + +
+ ) +} + +function RequestDateCell({ iso }: { iso: string }) { + return ( +
+

{formatDate(iso.slice(0, 10))}

+

{formatTime(iso)}

+
+ ) +} + export function PendingWithdrawalsPanel({ withdrawals, loading, @@ -30,35 +73,57 @@ export function PendingWithdrawalsPanel({ ) const columns: Array> = [ - { key: 'customer', header: 'مشتری', render: (w) => {w.user_name} }, - { key: 'username', header: 'نام کاربری', render: (w) => w.username || '—' }, + { + key: 'customer', + header: 'مشتری', + headerClassName: 'w-[11rem]', + className: 'w-[11rem]', + render: (w) => , + }, { key: 'amount', - header: 'مبلغ', + header: 'مبلغ (تومان)', align: 'end', - render: (w) => {formatMoney(w.amount)}, + headerClassName: 'w-[6.5rem]', + className: 'w-[6.5rem] whitespace-nowrap', + render: (w) => ( + {formatMoney(w.amount)} + ), }, - { key: 'iban', header: 'شبا', render: (w) => {w.iban || '—'} }, { - key: 'date', + key: 'iban', + header: 'شماره شبا', + headerClassName: 'min-w-[14rem]', + className: 'min-w-[14rem]', + render: (w) => , + }, + { + key: 'requestedAt', header: 'تاریخ درخواست', - render: (w) => formatDateTime(w.created_at), + headerClassName: 'w-[6.5rem]', + className: 'w-[6.5rem]', + render: (w) => , }, { key: 'status', header: 'وضعیت', + headerClassName: 'w-[5.5rem]', + className: 'w-[5.5rem] whitespace-nowrap', render: () => در انتظار, }, { 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 ( -
+
- {error &&

{error}

} + {error && ( +

+ {error} +

+ )} {loading ? ( -

در حال بارگذاری برداشت‌ها…

+

در حال بارگذاری برداشت‌ها…

) : ( - w.id} - emptyTitle="برداشت در انتظاری ثبت نشده است" - /> +
+ w.id} + emptyTitle="برداشت در انتظاری ثبت نشده است" + minWidth="52rem" + size="comfortable" + className="rounded-xl" + /> +
)} ) diff --git a/src/pages/sales/SalesDocumentSection.tsx b/src/pages/sales/SalesDocumentSection.tsx index 41d5670..4e4a65d 100644 --- a/src/pages/sales/SalesDocumentSection.tsx +++ b/src/pages/sales/SalesDocumentSection.tsx @@ -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) => {toFa(i.number)} }, { 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: 'type', header: 'نوع فروش', @@ -469,7 +485,7 @@ export function SalesDocumentSection({ - + @@ -482,14 +498,14 @@ export function SalesDocumentSection({ {draft.lines.map((line) => (
کالاکالا / رویداد تعداد قیمت واحد تخفیف
- + updateLine(line.id, patch)} + onSelectProduct={(productId) => onSelectProduct(line.id, productId)} + /> + +
+
) } diff --git a/src/pages/sales/SalesProcess.tsx b/src/pages/sales/SalesProcess.tsx index 56b7df1..20f664c 100644 --- a/src/pages/sales/SalesProcess.tsx +++ b/src/pages/sales/SalesProcess.tsx @@ -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 (
+

فرایند فروش

@@ -51,11 +58,12 @@ export function SalesProcess() {

-
+
+
diff --git a/src/pages/sales/SalesReview.tsx b/src/pages/sales/SalesReview.tsx index 048e833..d7eef43 100644 --- a/src/pages/sales/SalesReview.tsx +++ b/src/pages/sales/SalesReview.tsx @@ -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) => {toFa(i.number)} }, { 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) => ( - - {statusLabels[i.status]} - +
+ + {statusLabels[i.status]} + + {isFunZoneSalesInvoice(i) ? فان‌زون : null} +
), }, ] return (
+ +
diff --git a/src/pages/sales/SalesSyncContext.tsx b/src/pages/sales/SalesSyncContext.tsx new file mode 100644 index 0000000..6c8e057 --- /dev/null +++ b/src/pages/sales/SalesSyncContext.tsx @@ -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 +} + +const SalesSyncContext = createContext(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(null) + const [stats, setStats] = useState(null) + const [lastSyncedAt, setLastSyncedAt] = useState(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 ( + + {children} + + ) +} diff --git a/src/pages/sales/funzoneSalesSync.ts b/src/pages/sales/funzoneSalesSync.ts new file mode 100644 index 0000000..7ce5be4 --- /dev/null +++ b/src/pages/sales/funzoneSalesSync.ts @@ -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, + 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-'), + })) +} diff --git a/src/pages/sales/salesUtils.ts b/src/pages/sales/salesUtils.ts index 679cae0..0f627ad 100644 --- a/src/pages/sales/salesUtils.ts +++ b/src/pages/sales/salesUtils.ts @@ -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, diff --git a/src/pages/treasury/funzoneTreasurySync.ts b/src/pages/treasury/funzoneTreasurySync.ts index 5e2d7cf..74bdcc6 100644 --- a/src/pages/treasury/funzoneTreasurySync.ts +++ b/src/pages/treasury/funzoneTreasurySync.ts @@ -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 ?? '') ) } diff --git a/src/pages/treasury/useFunZoneTreasurySync.ts b/src/pages/treasury/useFunZoneTreasurySync.ts index 2ce8d12..e03a4c8 100644 --- a/src/pages/treasury/useFunZoneTreasurySync.ts +++ b/src/pages/treasury/useFunZoneTreasurySync.ts @@ -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(null) const [stats, setStats] = useState(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 => { + 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 } } diff --git a/src/types/index.ts b/src/types/index.ts index c5e5ab3..0d4a0ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 } diff --git a/src/utils/eventName.ts b/src/utils/eventName.ts new file mode 100644 index 0000000..144425c --- /dev/null +++ b/src/utils/eventName.ts @@ -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 +} diff --git a/src/utils/format.ts b/src/utils/format.ts index 1b716de..45ec12c 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -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 => diff --git a/src/utils/voucherFilters.ts b/src/utils/voucherFilters.ts new file mode 100644 index 0000000..6b55972 --- /dev/null +++ b/src/utils/voucherFilters.ts @@ -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, string> = { + customer: 'مشتریان', + supplier: 'تأمین‌کنندگان', + manual: 'دستی', +} + +export function voucherCategoryLabel(category: Exclude): string { + return categoryLabels[category] +} + +/** Classifies a voucher as customer-related, supplier-related, or manually entered. */ +export function classifyVoucher( + voucher: Voucher, + data: Pick, +): Exclude { + 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() + 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, number> { + const summary = { customer: 0, supplier: 0, manual: 0 } + for (const voucher of vouchers) { + summary[classifyVoucher(voucher, data)] += 1 + } + return summary +} diff --git a/src/utils/voucherRegarding.ts b/src/utils/voucherRegarding.ts new file mode 100644 index 0000000..3928bb1 --- /dev/null +++ b/src/utils/voucherRegarding.ts @@ -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 '—' +}