Harden withdrawals freshness and add معین/تفصیل on accounting vouchers.

Cache-bust and soft-refresh withdrawal lists for CDN, and capture party detail (تفصیل) plus بابت purpose on journal documents.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shayan Azadi
2026-08-04 19:21:37 +03:30
parent 459ae43b0f
commit 92d7afc103
12 changed files with 292 additions and 69 deletions

View File

@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>سپیدار فان‌زون | سیستم حسابداری</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link

View File

@@ -17,6 +17,15 @@ server {
application/xml+rss
application/json;
# Never cache the shell HTML — otherwise CDN/browsers keep an old index
# that points at a previous JS bundle (stale withdrawals desk).
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
expires -1;
try_files /index.html =404;
}
location / {
try_files $uri $uri/ /index.html;
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#0F766E"/>
<path d="M8 22V10h3.2c2.4 0 3.9 1.2 3.9 3.1 0 1.3-.7 2.3-1.9 2.8L16.8 22h-3.1l-3-5.6H11V22H8zm3-8.1h.4c.9 0 1.5-.5 1.5-1.3S12.3 11.3 11.4 11.3H11v2.6zM18.2 22l3.3-12h3.2L28 22h-3.1l-.6-2.3h-3.4L20.3 22h-2.1zm4.2-4.7h2.3l-1.1-4.2-1.2 4.2z" fill="#fff"/>
</svg>

View File

@@ -40,8 +40,21 @@ function assertMapping(map: VoucherAccountMap): void {
}
}
function makeLine(accountId: string, debit: number, credit: number, description: string): VoucherLine {
return { id: createId('vl-'), accountId, debit, credit, description }
function makeLine(
accountId: string,
debit: number,
credit: number,
description: string,
partyId?: string | null,
): VoucherLine {
return {
id: createId('vl-'),
accountId,
partyId: partyId ?? null,
debit,
credit,
description,
}
}
function makeVoucher(

View File

@@ -21,6 +21,12 @@ export class ApiError extends Error {
}
}
/** Append a unique query so CDNs that ignore Cache-Control cannot reuse a GET body. */
export function withCacheBust(path: string): string {
const sep = path.includes('?') ? '&' : '?'
return `${path}${sep}_=${Date.now()}`
}
async function request<T>(path: string, init?: RequestInit, baseUrl: string = getApiBaseUrl()): Promise<T> {
const token = getToken()
let response: Response
@@ -38,7 +44,10 @@ async function request<T>(path: string, init?: RequestInit, baseUrl: string = ge
...(init?.headers ?? {}),
},
})
} catch {
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError('درخواست زمان‌بر شد. دوباره تلاش کنید.', 0)
}
throw new ApiError('عدم دسترسی به سرور. آدرس سرور و اتصال شبکه را بررسی کنید.', 0)
}
@@ -59,10 +68,11 @@ async function request<T>(path: string, init?: RequestInit, baseUrl: string = ge
return (text ? JSON.parse(text) : undefined) as T
}
export const apiGet = <T>(path: string): Promise<T> => request<T>(path)
export const apiGet = <T>(path: string, init?: RequestInit): Promise<T> =>
request<T>(path, init)
export const apiPatch = <T>(path: string, body: unknown): Promise<T> =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) })
export const apiPatch = <T>(path: string, body: unknown, init?: RequestInit): Promise<T> =>
request<T>(path, { ...init, method: 'PATCH', body: JSON.stringify(body) })
/** Client bound to the standalone accounting backend (shares the admin token). */
export const acct = {

View File

@@ -38,6 +38,7 @@ import {
const newLine = (): VoucherLine => ({
id: createId('vl-'),
accountId: '',
partyId: null,
description: '',
debit: 0,
credit: 0,
@@ -55,6 +56,33 @@ function createDraft(items: Voucher[]): Voucher {
}
}
function partyLabel(
parties: { id: string; name: string; kind: string }[],
partyId: string | null | undefined,
): string {
if (!partyId) return '—'
const party = parties.find((p) => p.id === partyId)
if (!party) return '—'
return party.name
}
/** Compact تفصیل summary for the documents list. */
function voucherTafsilSummary(
voucher: Voucher,
parties: { id: string; name: string }[],
): string {
const names = [
...new Set(
voucher.lines
.map((line) => (line.partyId ? parties.find((p) => p.id === line.partyId)?.name : null))
.filter((name): name is string => Boolean(name)),
),
]
if (names.length === 0) return '—'
if (names.length === 1) return names[0]
return `${names[0]} +${names.length - 1}`
}
export function Vouchers() {
const { data, upsertVoucher, removeVoucher } = useStore()
const { sync, loading: syncLoading, error: syncError, stats: syncStats, lastSyncedAt } =
@@ -72,6 +100,10 @@ export function Vouchers() {
() => data.accounts.filter((account) => !account.isGroup),
[data.accounts],
)
const partiesSorted = useMemo(
() => [...data.parties].sort((a, b) => a.name.localeCompare(b.name, 'fa')),
[data.parties],
)
const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
const displayedVouchers = useMemo(
@@ -123,7 +155,12 @@ export function Vouchers() {
const handleSave = (status: Voucher['status']) => {
if (!draft) return
const cleanLines = draft.lines.filter((line) => line.accountId && (line.debit > 0 || line.credit > 0))
const cleanLines = draft.lines
.filter((line) => line.accountId && (line.debit > 0 || line.credit > 0))
.map((line) => ({
...line,
partyId: line.partyId || null,
}))
if (cleanLines.length < 2) return
const candidate: Voucher = { ...draft, lines: cleanLines, status }
if (status === 'posted' && !voucherBalance(candidate).balanced) return
@@ -168,6 +205,20 @@ export function Vouchers() {
</span>
),
},
{
key: 'tafsil',
header: 'تفصیل',
truncate: true,
className: 'min-w-0',
render: (v) => {
const summaryText = voucherTafsilSummary(v, data.parties)
return (
<span className="block truncate text-slate-700" title={summaryText}>
{summaryText}
</span>
)
},
},
{
key: 'desc',
header: 'شرح',
@@ -227,7 +278,7 @@ export function Vouchers() {
<div className="space-y-6">
<PageHeader
title="اسناد حسابداری"
subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار"
subtitle="سند دوطرفه با معین، تفصیل (طرف حساب) و بابت — کنترل تراز بدهکار و بستانکار"
actions={
<>
<Button
@@ -309,7 +360,7 @@ export function Vouchers() {
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="شماره، شرح، بابت…"
placeholder="شماره، شرح، بابت، تفصیل…"
className="min-w-[12rem]"
/>
</Field>
@@ -371,6 +422,12 @@ export function Vouchers() {
>
{draft && (
<div className="space-y-4">
<div className="rounded-xl border border-slate-200 bg-slate-50/80 px-3 py-2 text-xs leading-relaxed text-slate-600">
<span className="font-semibold text-slate-700">معین</span> = حساب دفتر کل ·{' '}
<span className="font-semibold text-slate-700">تفصیل</span> = طرف حساب (کی گرفته) ·{' '}
<span className="font-semibold text-slate-700">بابت</span> = موضوع سند (برای چی گرفته)
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="شماره سند">
<Input
@@ -383,27 +440,31 @@ export function Vouchers() {
<Field label="تاریخ">
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
</Field>
<Field label="بابت">
<Field label="بابت (برای چی گرفته)">
<Input
value={draft.regarding ?? ''}
onChange={(e) =>
setDraft({ ...draft, regarding: normalizeEventName(e.target.value) || undefined })
}
placeholder="رویداد / موضوع سند"
placeholder="رویداد / موضوع / علت سند"
/>
</Field>
<Field label="شرح سند">
<Input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
<Input
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
placeholder="توضیح کلی سند"
/>
</Field>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200">
<table className="w-full text-sm">
<table className="w-full min-w-[52rem] 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-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" />
@@ -416,8 +477,9 @@ export function Vouchers() {
<Select
value={line.accountId}
onChange={(e) => updateLine(line.id, { accountId: e.target.value })}
aria-label="حساب معین"
>
<option value="">انتخاب حساب</option>
<option value="">انتخاب معین</option>
{leafAccounts.map((account) => (
<option key={account.id} value={account.id}>
{account.code} - {account.name}
@@ -425,16 +487,30 @@ export function Vouchers() {
))}
</Select>
</td>
<td className="px-2 py-1.5">
<Select
value={line.partyId ?? ''}
onChange={(e) =>
updateLine(line.id, { partyId: e.target.value || null })
}
aria-label="تفصیل طرف حساب"
>
<option value=""> بدون تفصیل </option>
{partiesSorted.map((party) => (
<option key={party.id} value={party.id}>
{party.name}
{party.kind === 'supplier' ? ' (تامین‌کننده)' : ' (مشتری)'}
</option>
))}
</Select>
</td>
<td className="px-2 py-1.5">
<Input
value={line.description}
onChange={(e) => updateLine(line.id, { description: e.target.value })}
placeholder="شرح"
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"
@@ -482,9 +558,16 @@ export function Vouchers() {
افزودن ردیف
</Button>
{draft.lines.some((l) => l.accountId) && (
{draft.lines.some((l) => l.accountId || l.partyId) && (
<p className="text-xs text-slate-400">
حسابهای انتخابشده: {draft.lines.filter((l) => l.accountId).map((l) => accountName(l.accountId)).join('، ')}
{draft.lines
.filter((l) => l.accountId)
.map((l) => {
const moein = accountName(l.accountId)
const tafsir = partyLabel(partiesSorted, l.partyId)
return tafsir !== '—' ? `${moein} / ${tafsir}` : moein
})
.join(' · ')}
</p>
)}
</div>

View File

@@ -228,9 +228,16 @@ export function buildSalesVoucher(
const label = `${documentTypeLabels[docType]} #${invoice.number} - ${party?.name ?? ''}`
const regarding = invoiceRegarding(invoice)
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
const makeLine = (
accountId: string,
debit: number,
credit: number,
desc: string,
partyId?: string | null,
): VoucherLine => ({
id: createId('vl-'),
accountId,
partyId: partyId ?? null,
description: desc,
debit,
credit,
@@ -253,7 +260,7 @@ export function buildSalesVoucher(
status: 'posted',
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice),
lines: [
makeLine(payableId, net, 0, `برداشت کیف پول - ${label}`),
makeLine(payableId, net, 0, `برداشت کیف پول - ${label}`, party?.id),
makeLine(bankId, 0, net, `پرداخت از بانک - ${label}`),
],
}
@@ -298,7 +305,7 @@ export function buildSalesVoucher(
source: voucherSourceForInvoice(invoice),
lines: [
makeLine(bankId, net, 0, `مانده بانکی پس از لغو بلیت - ${cancelLabel}`),
makeLine(customerPayableId, 0, net, `بدهی کیف پول مشتری پس از لغو - ${cancelLabel}`),
makeLine(customerPayableId, 0, net, `بدهی کیف پول مشتری پس از لغو - ${cancelLabel}`, party?.id),
],
}
}
@@ -331,7 +338,13 @@ export function buildSalesVoucher(
status: 'posted',
source: voucherSourceForInvoice(invoice),
lines: [
makeLine(debitAccountId, split.gross, 0, `${debitLabel} - ${label}`),
makeLine(
debitAccountId,
split.gross,
0,
`${debitLabel} - ${label}`,
fromWallet ? party?.id : null,
),
makeLine(ownerPayableId, 0, split.ownerNet, `سهم تامین‌کننده (مالک) - ${label}`),
makeLine(platformIncomeId, 0, split.platformProfit, `سود پلتفرم (۱۴٪) - ${label}`),
makeLine(taxAccountId, 0, split.tax, `مالیات (۱۰٪) - ${label}`),
@@ -346,10 +359,10 @@ export function buildSalesVoucher(
const lines: VoucherLine[] = isReturn
? [
makeLine(salesIncomeAccountId, net, 0, `برگشت فروش - ${label}`),
makeLine(receivableAccountId, 0, net, `کاهش مطالبات - ${label}`),
makeLine(receivableAccountId, 0, net, `کاهش مطالبات - ${label}`, party?.id),
]
: [
makeLine(receivableAccountId, net, 0, `مطالبات مشتری - ${label}`),
makeLine(receivableAccountId, net, 0, `مطالبات مشتری - ${label}`, party?.id),
makeLine(salesIncomeAccountId, 0, net, `درآمد فروش - ${label}`),
]
@@ -382,9 +395,16 @@ export function buildPurchaseVoucher(
const party = data.parties.find((p) => p.id === invoice.partyId)
const label = `فاکتور خرید #${invoice.number} - ${party?.name ?? ''}`
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
const makeLine = (
accountId: string,
debit: number,
credit: number,
desc: string,
partyId?: string | null,
): VoucherLine => ({
id: createId('vl-'),
accountId,
partyId: partyId ?? null,
description: desc,
debit,
credit,
@@ -399,7 +419,7 @@ export function buildPurchaseVoucher(
source: voucherSourceForInvoice(invoice),
lines: [
makeLine(inventoryAccountId, net, 0, `افزایش موجودی - ${label}`),
makeLine(payableAccountId, 0, net, `بدهی به تأمین‌کننده - ${label}`),
makeLine(payableAccountId, 0, net, `بدهی به تأمین‌کننده - ${label}`, party?.id),
],
}
}
@@ -520,9 +540,16 @@ export function buildAdjustmentVoucher(
const kindLabel = adjustment.kind === 'debit' ? 'بدهکار' : 'بستانکار'
const label = `اعلامیه ${kindLabel} #${adjustment.number} - ${party?.name ?? ''}`
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
const makeLine = (
accountId: string,
debit: number,
credit: number,
desc: string,
partyId?: string | null,
): VoucherLine => ({
id: createId('vl-'),
accountId,
partyId: partyId ?? null,
description: desc,
debit,
credit,
@@ -532,12 +559,12 @@ export function buildAdjustmentVoucher(
const lines: VoucherLine[] =
adjustment.kind === 'debit'
? [
makeLine(receivableAccountId, amount, 0, `افزایش مطالبات - ${label}`),
makeLine(receivableAccountId, amount, 0, `افزایش مطالبات - ${label}`, party?.id),
makeLine(salesIncomeAccountId, 0, amount, `اصلاح درآمد - ${label}`),
]
: [
makeLine(salesIncomeAccountId, amount, 0, `اصلاح درآمد - ${label}`),
makeLine(receivableAccountId, 0, amount, `کاهش مطالبات - ${label}`),
makeLine(receivableAccountId, 0, amount, `کاهش مطالبات - ${label}`, party?.id),
]
return {
@@ -579,9 +606,16 @@ export function buildTreasuryVoucher(
const kindLabel = txn.kind === 'receipt' ? 'دریافت' : 'پرداخت'
const label = `${kindLabel} #${txn.number}${party ? ` - ${party.name}` : ''}`
const makeLine = (accountId: string, debit: number, credit: number, desc: string): VoucherLine => ({
const makeLine = (
accountId: string,
debit: number,
credit: number,
desc: string,
partyId?: string | null,
): VoucherLine => ({
id: createId('vl-'),
accountId,
partyId: partyId ?? null,
description: desc,
debit,
credit,
@@ -591,6 +625,7 @@ export function buildTreasuryVoucher(
const tax = txn.taxAmount ?? 0
const profit = txn.platformProfitAmount ?? 0
const ownerNet = txn.ownerNetAmount ?? 0
const partyId = party?.id ?? null
const source = txn.source ?? ''
const isWalletBySource =
@@ -613,7 +648,7 @@ export function buildTreasuryVoucher(
source: voucherSourceForTreasury(txn),
lines: [
makeLine(bankId, amount, 0, `واریز شارژ کیف پول - ${label}`),
makeLine(customerPayableId, 0, amount, `بدهی به مشتری (کیف پول) - ${label}`),
makeLine(customerPayableId, 0, amount, `بدهی به مشتری (کیف پول) - ${label}`, partyId),
],
}
}
@@ -630,7 +665,7 @@ export function buildTreasuryVoucher(
status: 'posted',
source: voucherSourceForTreasury(txn),
lines: [
makeLine(customerPayableId, amount, 0, `برداشت کیف پول مشتری - ${label}`),
makeLine(customerPayableId, amount, 0, `برداشت کیف پول مشتری - ${label}`, partyId),
makeLine(bankId, 0, amount, `پرداخت از بانک - ${label}`),
],
}
@@ -654,7 +689,13 @@ export function buildTreasuryVoucher(
status: 'posted',
source: voucherSourceForTreasury(txn),
lines: [
makeLine(debitAccountId, amount, 0, `${debitLabel} - ${label}`),
makeLine(
debitAccountId,
amount,
0,
`${debitLabel} - ${label}`,
txn.method === 'cash' ? partyId : null,
),
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
makeLine(platformIncomeId!, 0, profit, `سود پلتفرم (۱۴٪) - ${label}`),
makeLine(taxAccountId!, 0, tax, `مالیات (۱۰٪) - ${label}`),
@@ -668,10 +709,10 @@ export function buildTreasuryVoucher(
txn.kind === 'receipt'
? [
makeLine(bankId, amount, 0, `واریز - ${label}`),
makeLine(counterAccountId, 0, amount, `تسویه مطالبات - ${label}`),
makeLine(counterAccountId, 0, amount, `تسویه مطالبات - ${label}`, partyId),
]
: [
makeLine(counterAccountId, amount, 0, `تسویه بدهی - ${label}`),
makeLine(counterAccountId, amount, 0, `تسویه بدهی - ${label}`, partyId),
makeLine(bankId, 0, amount, `برداشت - ${label}`),
]

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { apiGet, apiPatch } from '../../api/client'
import { apiGet, apiPatch, withCacheBust } from '../../api/client'
import { ENDPOINTS } from '../../api/config'
import type { ApiWithdrawal, WalletTxnStatus } from '../../api/types'
const REFRESH_INTERVAL_MS = 30_000
const FETCH_TIMEOUT_MS = 20_000
function sortWithdrawalsNewestFirst(list: ApiWithdrawal[]): ApiWithdrawal[] {
return [...list].sort(
@@ -19,6 +20,7 @@ export function usePendingCustomerWithdrawals(enabled = true) {
const [error, setError] = useState<string | null>(null)
const inFlightRef = useRef(false)
const enabledRef = useRef(enabled)
const abortRef = useRef<AbortController | null>(null)
enabledRef.current = enabled
const load = useCallback(async (opts?: { soft?: boolean }) => {
@@ -28,15 +30,32 @@ export function usePendingCustomerWithdrawals(enabled = true) {
inFlightRef.current = true
if (!soft) setLoading(true)
setError(null)
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
let timedOut = false
const timeoutId = window.setTimeout(() => {
timedOut = true
controller.abort()
}, FETCH_TIMEOUT_MS)
try {
const res = await apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
// Cache-bust query defeats Arvan/CDN GETs that ignore Cache-Control.
const res = await apiGet<{ withdrawals: ApiWithdrawal[] }>(
withCacheBust(ENDPOINTS.WITHDRAWALS),
{ signal: controller.signal },
)
if (controller.signal.aborted) return
setWithdrawals(sortWithdrawalsNewestFirst(res.withdrawals ?? []))
} catch (err) {
// Cleanup abort: ignore. Timeout/network: surface error (keep list on soft).
if (controller.signal.aborted && !timedOut) return
setError(err instanceof Error ? err.message : 'خطا در بارگذاری برداشت‌ها')
// Keep last known list on soft refresh / transient CDN errors so the UI
// does not flash empty while production caches catch up.
if (!soft) setWithdrawals([])
} finally {
window.clearTimeout(timeoutId)
if (abortRef.current === controller) abortRef.current = null
inFlightRef.current = false
setLoading(false)
}
@@ -59,10 +78,14 @@ export function usePendingCustomerWithdrawals(enabled = true) {
document.removeEventListener('visibilitychange', softReload)
window.removeEventListener('focus', softReload)
window.clearInterval(intervalId)
abortRef.current?.abort()
abortRef.current = null
inFlightRef.current = false
}
}, [enabled, load])
const toggleStatus = useCallback(async (withdrawal: ApiWithdrawal) => {
const toggleStatus = useCallback(
async (withdrawal: ApiWithdrawal) => {
const nextStatus: WalletTxnStatus =
withdrawal.status === 'pending' ? 'completed' : 'pending'
@@ -76,6 +99,8 @@ export function usePendingCustomerWithdrawals(enabled = true) {
setWithdrawals((prev) =>
prev.map((w) => (w.id === withdrawal.id ? { ...w, status: nextStatus } : w)),
)
// Re-fetch soon so production CDN/proxy cannot leave the desk on a stale list.
window.setTimeout(() => void load({ soft: true }), 400)
return nextStatus
} catch (err) {
const message = err instanceof Error ? err.message : 'خطا در بروزرسانی وضعیت برداشت'
@@ -84,7 +109,9 @@ export function usePendingCustomerWithdrawals(enabled = true) {
} finally {
setUpdatingId(null)
}
}, [])
},
[load],
)
const reload = useCallback(() => void load(), [load])

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { apiGet, asList } from '../api/client'
import { apiGet, asList, withCacheBust } from '../api/client'
import { ENDPOINTS } from '../api/config'
import type { ApiCustomer, ApiOwner } from '../api/types'
import { useStore } from '../store/AppStore'
@@ -117,9 +117,17 @@ export function usePartyWithdrawals(kind: PartyKind) {
useEffect(() => {
let cancelled = false
const userType = kind === 'supplier' ? 'owner' : 'customer'
let inFlight = false
const load = () => {
apiGet<{ withdrawals: import('../api/types').ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
if (cancelled || inFlight) return
inFlight = true
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 20_000)
apiGet<{ withdrawals: import('../api/types').ApiWithdrawal[] }>(
withCacheBust(ENDPOINTS.WITHDRAWALS),
{ signal: controller.signal },
)
.then((res) => {
if (cancelled) return
setWithdrawals((res.withdrawals ?? []).filter((w) => w.user_type === userType))
@@ -127,6 +135,10 @@ export function usePartyWithdrawals(kind: PartyKind) {
.catch(() => {
// Keep last known list; avoid wiping party stats on a transient/CDN miss.
})
.finally(() => {
window.clearTimeout(timeoutId)
inFlight = false
})
}
load()

View File

@@ -14,7 +14,13 @@ export interface Account {
export interface VoucherLine {
id: ID
/** حساب معین (leaf account in the chart). */
accountId: ID
/**
* تفصیل / طرف حساب — who this line is with («کی گرفته»).
* Optional for accounts that do not need a party (cash, tax, income, …).
*/
partyId?: ID | null
description: string
debit: number
credit: number
@@ -27,7 +33,10 @@ export interface Voucher {
number: number
date: string
description: string
/** Event or subject the voucher relates to (بابت). */
/**
* بابت / موضوع سند — why the entry exists («برای چی گرفته»).
* Event name, wallet, or free-text purpose.
*/
regarding?: string
status: VoucherStatus
lines: VoucherLine[]

View File

@@ -81,7 +81,20 @@ export function filterVouchers(
if (query) {
const regarding = resolveVoucherRegarding(voucher, data.treasury).toLowerCase()
const haystack = [voucher.description, regarding, String(voucher.number), voucher.source ?? '']
const tafsirNames = voucher.lines
.map((line) =>
line.partyId ? data.parties.find((p) => p.id === line.partyId)?.name ?? '' : '',
)
.join(' ')
const lineDescriptions = voucher.lines.map((line) => line.description).join(' ')
const haystack = [
voucher.description,
regarding,
tafsirNames,
lineDescriptions,
String(voucher.number),
voucher.source ?? '',
]
.join(' ')
.toLowerCase()
if (!haystack.includes(query)) return false