Fix live voucher balance totals and show system-posted vouchers in the list.

RHF was caching 0/0 via useMemo on in-place form arrays; MoneyInput now drives Controller. Voucher list exposes source filters for manual vs automatic GL docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-26 18:22:40 +03:30
parent d92e1df332
commit 8caed2cfd2
8 changed files with 295 additions and 43 deletions

View File

@ -72,17 +72,39 @@ async def list_vouchers(
pagination: PaginationParams = Depends(get_pagination), pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
status: str | None = None,
source: str | None = None,
): ):
from sqlalchemy import select """List all vouchers including automatic/system-posted (sales/purchase/treasury/...)."""
from sqlalchemy import or_, select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
stmt = ( stmt = (
select(Voucher) select(Voucher)
.options(selectinload(Voucher.lines)) .options(selectinload(Voucher.lines))
.where(Voucher.tenant_id == tenant_id) .where(Voucher.tenant_id == tenant_id)
)
if status:
try:
stmt = stmt.where(Voucher.status == VoucherStatus(status))
except ValueError:
pass
if source == "manual":
stmt = stmt.where(
or_(Voucher.source_module.is_(None), Voucher.source_module.in_(["", "manual"]))
)
elif source == "auto":
stmt = stmt.where(
Voucher.source_module.is_not(None),
Voucher.source_module.notin_(["", "manual"]),
)
elif source:
stmt = stmt.where(Voucher.source_module == source)
stmt = (
stmt.order_by(Voucher.created_at.desc())
.offset(pagination.offset) .offset(pagination.offset)
.limit(pagination.page_size) .limit(pagination.page_size)
.order_by(Voucher.created_at.desc())
) )
result = await db.execute(stmt) result = await db.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@ -1,8 +1,9 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import type { ChangeEvent } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -191,8 +192,8 @@ export default function VoucherDetailPage() {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
const watchedEditLines = editForm.watch("lines"); const watchedEditLines = useWatch({ control: editForm.control, name: "lines" });
const balance = useMemo(() => sumVoucherLines(watchedEditLines), [watchedEditLines]); const balance = sumVoucherLines(watchedEditLines);
if (!tenantId || voucherQ.isLoading) return <LoadingState />; if (!tenantId || voucherQ.isLoading) return <LoadingState />;
if (voucherQ.error) return <ErrorState message={voucherQ.error.message} onRetry={() => voucherQ.refetch()} />; if (voucherQ.error) return <ErrorState message={voucherQ.error.message} onRetry={() => voucherQ.refetch()} />;
@ -356,11 +357,49 @@ export default function VoucherDetailPage() {
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label>بدهکار</Label> <Label>بدهکار</Label>
<MoneyInput className="mt-1" {...editForm.register(`lines.${index}.debit`)} /> <Controller
control={editForm.control}
name={`lines.${index}.debit`}
render={({ field }) => (
<MoneyInput
className="mt-1"
name={field.name}
ref={field.ref}
value={field.value ?? "0"}
onBlur={field.onBlur}
onChange={(e) => {
const next =
typeof e === "object" && e && "target" in e
? String((e as ChangeEvent<HTMLInputElement>).target.value)
: String(e ?? "0");
field.onChange(next);
}}
/>
)}
/>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label>بستانکار</Label> <Label>بستانکار</Label>
<MoneyInput className="mt-1" {...editForm.register(`lines.${index}.credit`)} /> <Controller
control={editForm.control}
name={`lines.${index}.credit`}
render={({ field }) => (
<MoneyInput
className="mt-1"
name={field.name}
ref={field.ref}
value={field.value ?? "0"}
onBlur={field.onBlur}
onChange={(e) => {
const next =
typeof e === "object" && e && "target" in e
? String((e as ChangeEvent<HTMLInputElement>).target.value)
: String(e ?? "0");
field.onChange(next);
}}
/>
)}
/>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label>مرکز هزینه</Label> <Label>مرکز هزینه</Label>
@ -419,9 +458,15 @@ export default function VoucherDetailPage() {
<span>جمع بدهکار: {formatMoney(String(balance.debit))}</span> <span>جمع بدهکار: {formatMoney(String(balance.debit))}</span>
<span>جمع بستانکار: {formatMoney(String(balance.credit))}</span> <span>جمع بستانکار: {formatMoney(String(balance.credit))}</span>
<span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span> <span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span>
<span className="font-semibold">{balance.isBalanced ? "متوازن ✓" : "نامتوازن — ذخیره مجاز نیست"}</span> <span className="font-semibold">
{balance.isBalanced
? "متوازن ✓"
: !balance.hasAmount
? "مبالغ ردیف‌ها را وارد کنید"
: "نامتوازن — ذخیره مجاز نیست"}
</span>
</div> </div>
{!balance.isBalanced ? ( {balance.hasAmount && !balance.isBalanced ? (
<p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p> <p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p>
) : null} ) : null}
</div> </div>

View File

@ -1,7 +1,8 @@
"use client"; "use client";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import type { ChangeEvent } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@ -130,8 +131,9 @@ export default function NewVoucherPage() {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
const watchedLines = form.watch("lines"); // useWatch (not useMemo on watch) — RHF mutates lines in place; memo would freeze at 0/0.
const balance = useMemo(() => sumVoucherLines(watchedLines), [watchedLines]); const watchedLines = useWatch({ control: form.control, name: "lines" });
const balance = sumVoucherLines(watchedLines);
if (!tenantId || periodsQ.isLoading || accountsQ.isLoading) return <LoadingState />; if (!tenantId || periodsQ.isLoading || accountsQ.isLoading) return <LoadingState />;
if (periodsQ.error || accountsQ.error) { if (periodsQ.error || accountsQ.error) {
@ -258,11 +260,49 @@ export default function NewVoucherPage() {
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label>بدهکار</Label> <Label>بدهکار</Label>
<MoneyInput className="mt-1" {...form.register(`lines.${index}.debit`)} /> <Controller
control={form.control}
name={`lines.${index}.debit`}
render={({ field }) => (
<MoneyInput
className="mt-1"
name={field.name}
ref={field.ref}
value={field.value ?? "0"}
onBlur={field.onBlur}
onChange={(e) => {
const next =
typeof e === "object" && e && "target" in e
? String((e as ChangeEvent<HTMLInputElement>).target.value)
: String(e ?? "0");
field.onChange(next);
}}
/>
)}
/>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label>بستانکار</Label> <Label>بستانکار</Label>
<MoneyInput className="mt-1" {...form.register(`lines.${index}.credit`)} /> <Controller
control={form.control}
name={`lines.${index}.credit`}
render={({ field }) => (
<MoneyInput
className="mt-1"
name={field.name}
ref={field.ref}
value={field.value ?? "0"}
onBlur={field.onBlur}
onChange={(e) => {
const next =
typeof e === "object" && e && "target" in e
? String((e as ChangeEvent<HTMLInputElement>).target.value)
: String(e ?? "0");
field.onChange(next);
}}
/>
)}
/>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label>مرکز هزینه</Label> <Label>مرکز هزینه</Label>
@ -320,9 +360,15 @@ export default function NewVoucherPage() {
<span>جمع بدهکار: {formatMoney(String(balance.debit))}</span> <span>جمع بدهکار: {formatMoney(String(balance.debit))}</span>
<span>جمع بستانکار: {formatMoney(String(balance.credit))}</span> <span>جمع بستانکار: {formatMoney(String(balance.credit))}</span>
<span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span> <span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span>
<span className="font-semibold">{balance.isBalanced ? "متوازن ✓" : "نامتوازن — ثبت مجاز نیست"}</span> <span className="font-semibold">
{balance.isBalanced
? "متوازن ✓"
: !balance.hasAmount
? "مبالغ ردیف‌ها را وارد کنید"
: "نامتوازن — ثبت مجاز نیست"}
</span>
</div> </div>
{!balance.isBalanced ? ( {balance.hasAmount && !balance.isBalanced ? (
<p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p> <p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p>
) : null} ) : null}
</div> </div>

View File

@ -2,7 +2,7 @@
import { Suspense, useMemo, useState } from "react"; import { Suspense, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api"; import { accountingApi } from "@/lib/accounting-api";
@ -18,6 +18,7 @@ import {
Badge, Badge,
JalaliDateText, JalaliDateText,
Pagination, Pagination,
Select,
} from "@/components/ds"; } from "@/components/ds";
const STATUS_FA: Record<string, string> = { const STATUS_FA: Record<string, string> = {
@ -28,41 +29,77 @@ const STATUS_FA: Record<string, string> = {
cancelled: "لغو شده", cancelled: "لغو شده",
}; };
const SOURCE_FA: Record<string, string> = {
manual: "دستی",
sales: "فروش (اتومات)",
purchase: "خرید (اتومات)",
treasury: "خزانه (اتومات)",
payroll: "حقوق (اتومات)",
assets: "دارایی (اتومات)",
};
function sourceLabel(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "manual") return "دستی";
return SOURCE_FA[s] ?? `${s} (سیستمی)`;
}
function isAutoSource(raw: unknown): boolean {
const s = String(raw ?? "").trim();
return !!s && s !== "manual";
}
const STATUS_TITLES: Record<string, string> = { const STATUS_TITLES: Record<string, string> = {
draft: "اسناد پیش‌نویس", draft: "اسناد پیش‌نویس",
reversed: "اسناد برگشتی", reversed: "اسناد برگشتی",
posted: "تأیید و ثبت اسناد", posted: "اسناد ثبت‌قطع‌شده",
}; };
function VouchersList() { function VouchersList() {
const { tenantId } = useTenantId(); const { tenantId } = useTenantId();
const sp = useSearchParams(); const sp = useSearchParams();
const router = useRouter();
const statusFilter = sp.get("status") || ""; const statusFilter = sp.get("status") || "";
const sourceFilter = sp.get("source") || "";
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 50; const pageSize = 100;
const listQ = useQuery({ const listQ = useQuery({
queryKey: ["accounting", tenantId, "vouchers", page, pageSize], queryKey: ["accounting", tenantId, "vouchers", page, pageSize, statusFilter, sourceFilter],
queryFn: () => accountingApi.vouchers.list(tenantId!, page, pageSize), queryFn: () =>
accountingApi.vouchers.list(tenantId!, page, pageSize, {
status: statusFilter || undefined,
source: sourceFilter || undefined,
}),
enabled: !!tenantId, enabled: !!tenantId,
}); });
const rows = useMemo(() => { const rows = useMemo(() => listQ.data ?? [], [listQ.data]);
const all = listQ.data ?? [];
if (!statusFilter) return all; const setSource = (source: string) => {
return all.filter((v) => String(v.status) === statusFilter); const params = new URLSearchParams();
}, [listQ.data, statusFilter]); if (statusFilter) params.set("status", statusFilter);
if (source) params.set("source", source);
const q = params.toString();
router.push(q ? `/accounting/vouchers?${q}` : "/accounting/vouchers");
setPage(1);
};
if (!tenantId || listQ.isLoading) return <LoadingState />; if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />; if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
const title = STATUS_TITLES[statusFilter] || "لیست اسناد حسابداری"; const title =
sourceFilter === "auto"
? "اسناد سیستمی / اتومات"
: sourceFilter === "manual"
? "اسناد دستی"
: STATUS_TITLES[statusFilter] || "لیست اسناد حسابداری";
return ( return (
<div> <div>
<PageHeader <PageHeader
title={title} title={title}
description="چرخه عمر سند از طریق Posting Engine — بدون ایجاد مستقیم Journal Entry." description="همه اسناد دفتر کل — دستی و اتومات (فروش، خرید، خزانه و …) از طریق Posting Engine."
actions={ actions={
<Link href="/accounting/vouchers/new"> <Link href="/accounting/vouchers/new">
<Button> <Button>
@ -72,6 +109,52 @@ function VouchersList() {
</Link> </Link>
} }
/> />
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={!sourceFilter ? "default" : "outline"}
onClick={() => setSource("")}
>
همه منابع
</Button>
<Button
type="button"
size="sm"
variant={sourceFilter === "auto" ? "default" : "outline"}
onClick={() => setSource("auto")}
>
سیستمی / اتومات
</Button>
<Button
type="button"
size="sm"
variant={sourceFilter === "manual" ? "default" : "outline"}
onClick={() => setSource("manual")}
>
دستی
</Button>
</div>
<Select
className="w-auto min-w-[10rem]"
value={
["sales", "purchase", "treasury", "payroll", "assets"].includes(sourceFilter)
? sourceFilter
: ""
}
onChange={(e) => setSource(e.target.value)}
>
<option value="">ماژول خاص</option>
<option value="sales">فروش</option>
<option value="purchase">خرید</option>
<option value="treasury">خزانه</option>
<option value="payroll">حقوق</option>
<option value="assets">دارایی</option>
</Select>
</div>
<DataTable <DataTable
columns={[ columns={[
{ {
@ -88,6 +171,15 @@ function VouchersList() {
header: "تاریخ", header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.voucher_date ?? "")} />, render: (r) => <JalaliDateText value={String(r.voucher_date ?? "")} />,
}, },
{
key: "source_module",
header: "منبع",
render: (r) => (
<Badge tone={isAutoSource(r.source_module) ? "primary" : "default"}>
{sourceLabel(r.source_module)}
</Badge>
),
},
{ {
key: "status", key: "status",
header: "وضعیت", header: "وضعیت",
@ -99,15 +191,28 @@ function VouchersList() {
}, },
{ key: "total_debit", header: "بدهکار", render: (r) => formatMoney(String(r.total_debit)) }, { key: "total_debit", header: "بدهکار", render: (r) => formatMoney(String(r.total_debit)) },
{ key: "total_credit", header: "بستانکار", render: (r) => formatMoney(String(r.total_credit)) }, { key: "total_credit", header: "بستانکار", render: (r) => formatMoney(String(r.total_credit)) },
{ key: "description", header: "شرح" }, {
key: "description",
header: "شرح",
render: (r) => {
const ref = r.reference_number ? String(r.reference_number) : "";
const desc = r.description ? String(r.description) : "";
if (desc && ref) return `${desc} (مرجع: ${ref})`;
return desc || ref || "—";
},
},
]} ]}
rows={rows as unknown as Record<string, unknown>[]} rows={rows as unknown as Record<string, unknown>[]}
empty={ empty={
<EmptyState <EmptyState
title="سندی وجود ندارد" title={
sourceFilter === "auto"
? "هنوز سند اتومات ثبت نشده — ثبت حسابداری فاکتور/رسید/خزانه یا روشن‌کردن اسناد اتوماتیک"
: "سندی وجود ندارد"
}
action={ action={
<Link href="/accounting/vouchers/new"> <Link href="/accounting/vouchers/new">
<Button>ایجاد سند</Button> <Button>ایجاد سند دستی</Button>
</Link> </Link>
} }
/> />

View File

@ -97,14 +97,31 @@ export const MoneyInput = React.forwardRef<
// Keep typing fluid; strip non-digits / separators, then integerize. // Keep typing fluid; strip non-digits / separators, then integerize.
const parsed = toRialInteger(parseMoneyInput(e.target.value)); const parsed = toRialInteger(parseMoneyInput(e.target.value));
if (!isControlled) setInner(parsed); if (!isControlled) setInner(parsed);
e.target.value = parsed; // Clone event target value so RHF watch/Controller always sees digits (not display commas).
onChange?.(e); const next = {
...e,
target: { ...e.target, value: parsed, name: (props.name as string) || e.target.name },
currentTarget: {
...e.currentTarget,
value: parsed,
name: (props.name as string) || e.currentTarget.name,
},
} as typeof e;
onChange?.(next);
}} }}
onBlur={(e) => { onBlur={(e) => {
const parsed = toRialInteger(e.target.value); const parsed = toRialInteger(parseMoneyInput(e.target.value));
if (!isControlled) setInner(parsed); if (!isControlled) setInner(parsed);
e.target.value = parsed; const next = {
onBlur?.(e); ...e,
target: { ...e.target, value: parsed, name: (props.name as string) || e.target.name },
currentTarget: {
...e.currentTarget,
value: parsed,
name: (props.name as string) || e.currentTarget.name,
},
} as typeof e;
onBlur?.(next);
}} }}
/> />
); );

View File

@ -172,6 +172,8 @@ export type Voucher = {
total_debit: string; total_debit: string;
total_credit: string; total_credit: string;
posted_at: string | null; posted_at: string | null;
source_module?: string | null;
reference_number?: string | null;
lines: VoucherLine[]; lines: VoucherLine[];
}; };
@ -558,10 +560,21 @@ export const accountingApi = {
}, },
vouchers: { vouchers: {
list: (tenantId: string, page = 1, pageSize = 50) => list: (
request<Voucher[]>(`/api/v1/posting/vouchers${qs({ page, page_size: pageSize })}`, { tenantId: string,
tenantId, page = 1,
}), pageSize = 100,
opts?: { status?: string; source?: string }
) =>
request<Voucher[]>(
`/api/v1/posting/vouchers${qs({
page,
page_size: pageSize,
status: opts?.status || undefined,
source: opts?.source || undefined,
})}`,
{ tenantId }
),
get: (tenantId: string, id: string) => get: (tenantId: string, id: string) =>
request<Voucher>(`/api/v1/posting/vouchers/${id}`, { tenantId }), request<Voucher>(`/api/v1/posting/vouchers/${id}`, { tenantId }),
create: ( create: (

View File

@ -120,6 +120,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [
items: [ items: [
{ href: "/accounting/vouchers/new", label: "صدور سند" }, { href: "/accounting/vouchers/new", label: "صدور سند" },
{ href: "/accounting/vouchers", label: "لیست اسناد" }, { href: "/accounting/vouchers", label: "لیست اسناد" },
{ href: "/accounting/vouchers?source=auto", label: "اسناد سیستمی" },
{ href: "/accounting/vouchers?status=draft", label: "اسناد پیش‌نویس" }, { href: "/accounting/vouchers?status=draft", label: "اسناد پیش‌نویس" },
{ href: "/accounting/vouchers/recurring", label: "اسناد تکراری" }, { href: "/accounting/vouchers/recurring", label: "اسناد تکراری" },
{ href: "/accounting/vouchers/adjustments", label: "اسناد اصلاحی" }, { href: "/accounting/vouchers/adjustments", label: "اسناد اصلاحی" },

View File

@ -14,11 +14,14 @@ export function sumVoucherLines(lines: BalanceLine[] | undefined) {
void index; void index;
}); });
const difference = debit - credit; const difference = debit - credit;
const hasAmount = debit > 0 || credit > 0;
return { return {
debit, debit,
credit, credit,
difference, difference,
isBalanced: difference === 0 && (lines?.length ?? 0) >= 2, hasAmount,
// Empty 0/0 is not "balanced" for UX — only real equal totals count.
isBalanced: difference === 0 && hasAmount && (lines?.length ?? 0) >= 2,
}; };
} }