From 8caed2cfd2a6f8068c06fe75f982023618e15cca Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Sun, 26 Jul 2026 18:22:40 +0330 Subject: [PATCH] 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 --- .../services/accounting/app/api/v1/posting.py | 26 +++- .../app/accounting/vouchers/[id]/page.tsx | 61 ++++++-- frontend/app/accounting/vouchers/new/page.tsx | 62 ++++++-- frontend/app/accounting/vouchers/page.tsx | 135 ++++++++++++++++-- frontend/components/ds/FormControls.tsx | 27 +++- frontend/lib/accounting-api.ts | 21 ++- frontend/lib/accounting-nav.ts | 1 + frontend/lib/voucher-balance.ts | 5 +- 8 files changed, 295 insertions(+), 43 deletions(-) diff --git a/backend/services/accounting/app/api/v1/posting.py b/backend/services/accounting/app/api/v1/posting.py index 27641ce..1081377 100644 --- a/backend/services/accounting/app/api/v1/posting.py +++ b/backend/services/accounting/app/api/v1/posting.py @@ -72,17 +72,39 @@ async def list_vouchers( pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _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 stmt = ( select(Voucher) .options(selectinload(Voucher.lines)) .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) .limit(pagination.page_size) - .order_by(Voucher.created_at.desc()) ) result = await db.execute(stmt) return list(result.scalars().all()) diff --git a/frontend/app/accounting/vouchers/[id]/page.tsx b/frontend/app/accounting/vouchers/[id]/page.tsx index 2f97b40..ff561bc 100644 --- a/frontend/app/accounting/vouchers/[id]/page.tsx +++ b/frontend/app/accounting/vouchers/[id]/page.tsx @@ -1,8 +1,9 @@ "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 { Controller, useFieldArray, useForm } from "react-hook-form"; +import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -191,8 +192,8 @@ export default function VoucherDetailPage() { onError: (e: Error) => toast.error(e.message), }); - const watchedEditLines = editForm.watch("lines"); - const balance = useMemo(() => sumVoucherLines(watchedEditLines), [watchedEditLines]); + const watchedEditLines = useWatch({ control: editForm.control, name: "lines" }); + const balance = sumVoucherLines(watchedEditLines); if (!tenantId || voucherQ.isLoading) return ; if (voucherQ.error) return voucherQ.refetch()} />; @@ -356,11 +357,49 @@ export default function VoucherDetailPage() {
- + ( + { + const next = + typeof e === "object" && e && "target" in e + ? String((e as ChangeEvent).target.value) + : String(e ?? "0"); + field.onChange(next); + }} + /> + )} + />
- + ( + { + const next = + typeof e === "object" && e && "target" in e + ? String((e as ChangeEvent).target.value) + : String(e ?? "0"); + field.onChange(next); + }} + /> + )} + />
@@ -419,9 +458,15 @@ export default function VoucherDetailPage() { جمع بدهکار: {formatMoney(String(balance.debit))} جمع بستانکار: {formatMoney(String(balance.credit))} اختلاف: {formatMoney(String(Math.abs(balance.difference)))} - {balance.isBalanced ? "متوازن ✓" : "نامتوازن — ذخیره مجاز نیست"} + + {balance.isBalanced + ? "متوازن ✓" + : !balance.hasAmount + ? "مبالغ ردیف‌ها را وارد کنید" + : "نامتوازن — ذخیره مجاز نیست"} +
- {!balance.isBalanced ? ( + {balance.hasAmount && !balance.isBalanced ? (

{unbalancedMessage(balance.debit, balance.credit)}

) : null} diff --git a/frontend/app/accounting/vouchers/new/page.tsx b/frontend/app/accounting/vouchers/new/page.tsx index bfeb481..6b52103 100644 --- a/frontend/app/accounting/vouchers/new/page.tsx +++ b/frontend/app/accounting/vouchers/new/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useMemo } from "react"; -import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { useEffect } from "react"; +import type { ChangeEvent } from "react"; +import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -130,8 +131,9 @@ export default function NewVoucherPage() { onError: (e: Error) => toast.error(e.message), }); - const watchedLines = form.watch("lines"); - const balance = useMemo(() => sumVoucherLines(watchedLines), [watchedLines]); + // useWatch (not useMemo on watch) — RHF mutates lines in place; memo would freeze at 0/0. + const watchedLines = useWatch({ control: form.control, name: "lines" }); + const balance = sumVoucherLines(watchedLines); if (!tenantId || periodsQ.isLoading || accountsQ.isLoading) return ; if (periodsQ.error || accountsQ.error) { @@ -258,11 +260,49 @@ export default function NewVoucherPage() {
- + ( + { + const next = + typeof e === "object" && e && "target" in e + ? String((e as ChangeEvent).target.value) + : String(e ?? "0"); + field.onChange(next); + }} + /> + )} + />
- + ( + { + const next = + typeof e === "object" && e && "target" in e + ? String((e as ChangeEvent).target.value) + : String(e ?? "0"); + field.onChange(next); + }} + /> + )} + />
@@ -320,9 +360,15 @@ export default function NewVoucherPage() { جمع بدهکار: {formatMoney(String(balance.debit))} جمع بستانکار: {formatMoney(String(balance.credit))} اختلاف: {formatMoney(String(Math.abs(balance.difference)))} - {balance.isBalanced ? "متوازن ✓" : "نامتوازن — ثبت مجاز نیست"} + + {balance.isBalanced + ? "متوازن ✓" + : !balance.hasAmount + ? "مبالغ ردیف‌ها را وارد کنید" + : "نامتوازن — ثبت مجاز نیست"} +
- {!balance.isBalanced ? ( + {balance.hasAmount && !balance.isBalanced ? (

{unbalancedMessage(balance.debit, balance.credit)}

) : null} diff --git a/frontend/app/accounting/vouchers/page.tsx b/frontend/app/accounting/vouchers/page.tsx index e84dd52..c10faea 100644 --- a/frontend/app/accounting/vouchers/page.tsx +++ b/frontend/app/accounting/vouchers/page.tsx @@ -2,7 +2,7 @@ import { Suspense, useMemo, useState } from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { accountingApi } from "@/lib/accounting-api"; @@ -18,6 +18,7 @@ import { Badge, JalaliDateText, Pagination, + Select, } from "@/components/ds"; const STATUS_FA: Record = { @@ -28,41 +29,77 @@ const STATUS_FA: Record = { cancelled: "لغو شده", }; +const SOURCE_FA: Record = { + 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 = { draft: "اسناد پیش‌نویس", reversed: "اسناد برگشتی", - posted: "تأیید و ثبت اسناد", + posted: "اسناد ثبت‌قطع‌شده", }; function VouchersList() { const { tenantId } = useTenantId(); const sp = useSearchParams(); + const router = useRouter(); const statusFilter = sp.get("status") || ""; + const sourceFilter = sp.get("source") || ""; const [page, setPage] = useState(1); - const pageSize = 50; + const pageSize = 100; const listQ = useQuery({ - queryKey: ["accounting", tenantId, "vouchers", page, pageSize], - queryFn: () => accountingApi.vouchers.list(tenantId!, page, pageSize), + queryKey: ["accounting", tenantId, "vouchers", page, pageSize, statusFilter, sourceFilter], + queryFn: () => + accountingApi.vouchers.list(tenantId!, page, pageSize, { + status: statusFilter || undefined, + source: sourceFilter || undefined, + }), enabled: !!tenantId, }); - const rows = useMemo(() => { - const all = listQ.data ?? []; - if (!statusFilter) return all; - return all.filter((v) => String(v.status) === statusFilter); - }, [listQ.data, statusFilter]); + const rows = useMemo(() => listQ.data ?? [], [listQ.data]); + + const setSource = (source: string) => { + const params = new URLSearchParams(); + 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 ; if (listQ.error) return listQ.refetch()} />; - const title = STATUS_TITLES[statusFilter] || "لیست اسناد حسابداری"; + const title = + sourceFilter === "auto" + ? "اسناد سیستمی / اتومات" + : sourceFilter === "manual" + ? "اسناد دستی" + : STATUS_TITLES[statusFilter] || "لیست اسناد حسابداری"; return (
+ + +
+ + + , }, + { + key: "source_module", + header: "منبع", + render: (r) => ( + + {sourceLabel(r.source_module)} + + ), + }, { key: "status", header: "وضعیت", @@ -99,15 +191,28 @@ function VouchersList() { }, { key: "total_debit", header: "بدهکار", render: (r) => formatMoney(String(r.total_debit)) }, { 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[]} empty={ - + } /> diff --git a/frontend/components/ds/FormControls.tsx b/frontend/components/ds/FormControls.tsx index 76f8880..000a11c 100644 --- a/frontend/components/ds/FormControls.tsx +++ b/frontend/components/ds/FormControls.tsx @@ -97,14 +97,31 @@ export const MoneyInput = React.forwardRef< // Keep typing fluid; strip non-digits / separators, then integerize. const parsed = toRialInteger(parseMoneyInput(e.target.value)); if (!isControlled) setInner(parsed); - e.target.value = parsed; - onChange?.(e); + // Clone event target value so RHF watch/Controller always sees digits (not display commas). + 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) => { - const parsed = toRialInteger(e.target.value); + const parsed = toRialInteger(parseMoneyInput(e.target.value)); if (!isControlled) setInner(parsed); - e.target.value = parsed; - onBlur?.(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; + onBlur?.(next); }} /> ); diff --git a/frontend/lib/accounting-api.ts b/frontend/lib/accounting-api.ts index be098b7..ec129e5 100644 --- a/frontend/lib/accounting-api.ts +++ b/frontend/lib/accounting-api.ts @@ -172,6 +172,8 @@ export type Voucher = { total_debit: string; total_credit: string; posted_at: string | null; + source_module?: string | null; + reference_number?: string | null; lines: VoucherLine[]; }; @@ -558,10 +560,21 @@ export const accountingApi = { }, vouchers: { - list: (tenantId: string, page = 1, pageSize = 50) => - request(`/api/v1/posting/vouchers${qs({ page, page_size: pageSize })}`, { - tenantId, - }), + list: ( + tenantId: string, + page = 1, + pageSize = 100, + opts?: { status?: string; source?: string } + ) => + request( + `/api/v1/posting/vouchers${qs({ + page, + page_size: pageSize, + status: opts?.status || undefined, + source: opts?.source || undefined, + })}`, + { tenantId } + ), get: (tenantId: string, id: string) => request(`/api/v1/posting/vouchers/${id}`, { tenantId }), create: ( diff --git a/frontend/lib/accounting-nav.ts b/frontend/lib/accounting-nav.ts index b3e94b3..73cc0c6 100644 --- a/frontend/lib/accounting-nav.ts +++ b/frontend/lib/accounting-nav.ts @@ -120,6 +120,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [ items: [ { href: "/accounting/vouchers/new", label: "صدور سند" }, { href: "/accounting/vouchers", label: "لیست اسناد" }, + { href: "/accounting/vouchers?source=auto", label: "اسناد سیستمی" }, { href: "/accounting/vouchers?status=draft", label: "اسناد پیش‌نویس" }, { href: "/accounting/vouchers/recurring", label: "اسناد تکراری" }, { href: "/accounting/vouchers/adjustments", label: "اسناد اصلاحی" }, diff --git a/frontend/lib/voucher-balance.ts b/frontend/lib/voucher-balance.ts index 81dc67e..8c7b783 100644 --- a/frontend/lib/voucher-balance.ts +++ b/frontend/lib/voucher-balance.ts @@ -14,11 +14,14 @@ export function sumVoucherLines(lines: BalanceLine[] | undefined) { void index; }); const difference = debit - credit; + const hasAmount = debit > 0 || credit > 0; return { debit, credit, 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, }; }