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:
@@ -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
|
||||
|
||||
@@ -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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal 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>
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`),
|
||||
]
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user