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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>سپیدار فان‌زون | سیستم حسابداری</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin /> <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link <link

View File

@@ -17,6 +17,15 @@ server {
application/xml+rss application/xml+rss
application/json; 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 / { location / {
try_files $uri $uri/ /index.html; 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 { function makeLine(
return { id: createId('vl-'), accountId, debit, credit, description } 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( 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> { async function request<T>(path: string, init?: RequestInit, baseUrl: string = getApiBaseUrl()): Promise<T> {
const token = getToken() const token = getToken()
let response: Response let response: Response
@@ -38,7 +44,10 @@ async function request<T>(path: string, init?: RequestInit, baseUrl: string = ge
...(init?.headers ?? {}), ...(init?.headers ?? {}),
}, },
}) })
} catch { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError('درخواست زمان‌بر شد. دوباره تلاش کنید.', 0)
}
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 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> => export const apiPatch = <T>(path: string, body: unknown, init?: RequestInit): Promise<T> =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }) request<T>(path, { ...init, method: 'PATCH', body: JSON.stringify(body) })
/** Client bound to the standalone accounting backend (shares the admin token). */ /** Client bound to the standalone accounting backend (shares the admin token). */
export const acct = { export const acct = {

View File

@@ -38,6 +38,7 @@ import {
const newLine = (): VoucherLine => ({ const newLine = (): VoucherLine => ({
id: createId('vl-'), id: createId('vl-'),
accountId: '', accountId: '',
partyId: null,
description: '', description: '',
debit: 0, debit: 0,
credit: 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() { export function Vouchers() {
const { data, upsertVoucher, removeVoucher } = useStore() const { data, upsertVoucher, removeVoucher } = useStore()
const { sync, loading: syncLoading, error: syncError, stats: syncStats, lastSyncedAt } = const { sync, loading: syncLoading, error: syncError, stats: syncStats, lastSyncedAt } =
@@ -72,6 +100,10 @@ export function Vouchers() {
() => data.accounts.filter((account) => !account.isGroup), () => data.accounts.filter((account) => !account.isGroup),
[data.accounts], [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 accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
const displayedVouchers = useMemo( const displayedVouchers = useMemo(
@@ -123,7 +155,12 @@ export function Vouchers() {
const handleSave = (status: Voucher['status']) => { const handleSave = (status: Voucher['status']) => {
if (!draft) return 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 if (cleanLines.length < 2) return
const candidate: Voucher = { ...draft, lines: cleanLines, status } const candidate: Voucher = { ...draft, lines: cleanLines, status }
if (status === 'posted' && !voucherBalance(candidate).balanced) return if (status === 'posted' && !voucherBalance(candidate).balanced) return
@@ -168,6 +205,20 @@ export function Vouchers() {
</span> </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', key: 'desc',
header: 'شرح', header: 'شرح',
@@ -227,7 +278,7 @@ export function Vouchers() {
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="اسناد حسابداری" title="اسناد حسابداری"
subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار" subtitle="سند دوطرفه با معین، تفصیل (طرف حساب) و بابت — کنترل تراز بدهکار و بستانکار"
actions={ actions={
<> <>
<Button <Button
@@ -309,7 +360,7 @@ export function Vouchers() {
<Input <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="شماره، شرح، بابت…" placeholder="شماره، شرح، بابت، تفصیل…"
className="min-w-[12rem]" className="min-w-[12rem]"
/> />
</Field> </Field>
@@ -371,6 +422,12 @@ export function Vouchers() {
> >
{draft && ( {draft && (
<div className="space-y-4"> <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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="شماره سند"> <Field label="شماره سند">
<Input <Input
@@ -383,27 +440,31 @@ export function Vouchers() {
<Field label="تاریخ"> <Field label="تاریخ">
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} /> <JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
</Field> </Field>
<Field label="بابت"> <Field label="بابت (برای چی گرفته)">
<Input <Input
value={draft.regarding ?? ''} value={draft.regarding ?? ''}
onChange={(e) => onChange={(e) =>
setDraft({ ...draft, regarding: normalizeEventName(e.target.value) || undefined }) setDraft({ ...draft, regarding: normalizeEventName(e.target.value) || undefined })
} }
placeholder="رویداد / موضوع سند" placeholder="رویداد / موضوع / علت سند"
/> />
</Field> </Field>
<Field label="شرح سند"> <Field label="شرح سند">
<Input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /> <Input
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
placeholder="توضیح کلی سند"
/>
</Field> </Field>
</div> </div>
<div className="overflow-x-auto rounded-xl border border-slate-200"> <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"> <thead className="bg-slate-50 text-slate-500">
<tr> <tr>
<th className="px-3 py-2 text-start font-medium">حساب</th> <th className="px-3 py-2 text-start font-medium">معین</th>
<th className="px-3 py-2 text-start font-medium">تفصیل (کی گرفته)</th>
<th className="px-3 py-2 text-start font-medium">شرح ردیف</th> <th className="px-3 py-2 text-start font-medium">شرح ردیف</th>
<th className="px-3 py-2 text-start font-medium">بابت</th>
<th className="px-3 py-2 text-end font-medium">بدهکار</th> <th className="px-3 py-2 text-end font-medium">بدهکار</th>
<th className="px-3 py-2 text-end font-medium">بستانکار</th> <th className="px-3 py-2 text-end font-medium">بستانکار</th>
<th className="px-3 py-2" /> <th className="px-3 py-2" />
@@ -416,8 +477,9 @@ export function Vouchers() {
<Select <Select
value={line.accountId} value={line.accountId}
onChange={(e) => updateLine(line.id, { accountId: e.target.value })} onChange={(e) => updateLine(line.id, { accountId: e.target.value })}
aria-label="حساب معین"
> >
<option value="">انتخاب حساب</option> <option value="">انتخاب معین</option>
{leafAccounts.map((account) => ( {leafAccounts.map((account) => (
<option key={account.id} value={account.id}> <option key={account.id} value={account.id}>
{account.code} - {account.name} {account.code} - {account.name}
@@ -425,16 +487,30 @@ export function Vouchers() {
))} ))}
</Select> </Select>
</td> </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"> <td className="px-2 py-1.5">
<Input <Input
value={line.description} value={line.description}
onChange={(e) => updateLine(line.id, { description: e.target.value })} onChange={(e) => updateLine(line.id, { description: e.target.value })}
placeholder="شرح" placeholder="شرح ردیف"
/> />
</td> </td>
<td className="px-2 py-1.5 text-sm text-slate-600">
{resolveVoucherRegarding(draft, data.treasury)}
</td>
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<Input <Input
type="number" type="number"
@@ -482,9 +558,16 @@ export function Vouchers() {
افزودن ردیف افزودن ردیف
</Button> </Button>
{draft.lines.some((l) => l.accountId) && ( {draft.lines.some((l) => l.accountId || l.partyId) && (
<p className="text-xs text-slate-400"> <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> </p>
)} )}
</div> </div>

View File

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

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react' 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 { ENDPOINTS } from '../../api/config'
import type { ApiWithdrawal, WalletTxnStatus } from '../../api/types' import type { ApiWithdrawal, WalletTxnStatus } from '../../api/types'
const REFRESH_INTERVAL_MS = 30_000 const REFRESH_INTERVAL_MS = 30_000
const FETCH_TIMEOUT_MS = 20_000
function sortWithdrawalsNewestFirst(list: ApiWithdrawal[]): ApiWithdrawal[] { function sortWithdrawalsNewestFirst(list: ApiWithdrawal[]): ApiWithdrawal[] {
return [...list].sort( return [...list].sort(
@@ -19,6 +20,7 @@ export function usePendingCustomerWithdrawals(enabled = true) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const inFlightRef = useRef(false) const inFlightRef = useRef(false)
const enabledRef = useRef(enabled) const enabledRef = useRef(enabled)
const abortRef = useRef<AbortController | null>(null)
enabledRef.current = enabled enabledRef.current = enabled
const load = useCallback(async (opts?: { soft?: boolean }) => { const load = useCallback(async (opts?: { soft?: boolean }) => {
@@ -28,15 +30,32 @@ export function usePendingCustomerWithdrawals(enabled = true) {
inFlightRef.current = true inFlightRef.current = true
if (!soft) setLoading(true) if (!soft) setLoading(true)
setError(null) 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 { 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 ?? [])) setWithdrawals(sortWithdrawalsNewestFirst(res.withdrawals ?? []))
} catch (err) { } 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 : 'خطا در بارگذاری برداشت‌ها') 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([]) if (!soft) setWithdrawals([])
} finally { } finally {
window.clearTimeout(timeoutId)
if (abortRef.current === controller) abortRef.current = null
inFlightRef.current = false inFlightRef.current = false
setLoading(false) setLoading(false)
} }
@@ -59,32 +78,40 @@ export function usePendingCustomerWithdrawals(enabled = true) {
document.removeEventListener('visibilitychange', softReload) document.removeEventListener('visibilitychange', softReload)
window.removeEventListener('focus', softReload) window.removeEventListener('focus', softReload)
window.clearInterval(intervalId) window.clearInterval(intervalId)
abortRef.current?.abort()
abortRef.current = null
inFlightRef.current = false
} }
}, [enabled, load]) }, [enabled, load])
const toggleStatus = useCallback(async (withdrawal: ApiWithdrawal) => { const toggleStatus = useCallback(
const nextStatus: WalletTxnStatus = async (withdrawal: ApiWithdrawal) => {
withdrawal.status === 'pending' ? 'completed' : 'pending' const nextStatus: WalletTxnStatus =
withdrawal.status === 'pending' ? 'completed' : 'pending'
setUpdatingId(withdrawal.id) setUpdatingId(withdrawal.id)
setError(null) setError(null)
try { try {
await apiPatch(ENDPOINTS.UPDATE_WITHDRAWAL_STATUS(withdrawal.id), { await apiPatch(ENDPOINTS.UPDATE_WITHDRAWAL_STATUS(withdrawal.id), {
status: nextStatus, status: nextStatus,
user_type: withdrawal.user_type, user_type: withdrawal.user_type,
}) })
setWithdrawals((prev) => setWithdrawals((prev) =>
prev.map((w) => (w.id === withdrawal.id ? { ...w, status: nextStatus } : w)), prev.map((w) => (w.id === withdrawal.id ? { ...w, status: nextStatus } : w)),
) )
return nextStatus // Re-fetch soon so production CDN/proxy cannot leave the desk on a stale list.
} catch (err) { window.setTimeout(() => void load({ soft: true }), 400)
const message = err instanceof Error ? err.message : 'خطا در بروزرسانی وضعیت برداشت' return nextStatus
setError(message) } catch (err) {
throw err instanceof Error ? err : new Error(message) const message = err instanceof Error ? err.message : 'خطا در بروزرسانی وضعیت برداشت'
} finally { setError(message)
setUpdatingId(null) throw err instanceof Error ? err : new Error(message)
} } finally {
}, []) setUpdatingId(null)
}
},
[load],
)
const reload = useCallback(() => void load(), [load]) const reload = useCallback(() => void load(), [load])

View File

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

View File

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

View File

@@ -81,7 +81,20 @@ export function filterVouchers(
if (query) { if (query) {
const regarding = resolveVoucherRegarding(voucher, data.treasury).toLowerCase() 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(' ') .join(' ')
.toLowerCase() .toLowerCase()
if (!haystack.includes(query)) return false if (!haystack.includes(query)) return false