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>
|
<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
|
||||||
|
|||||||
@@ -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
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 {
|
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(
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user