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:
parent
d92e1df332
commit
8caed2cfd2
@ -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())
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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: (
|
||||||
|
|||||||
@ -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: "اسناد اصلاحی" },
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user