TorbatYar/frontend/components/accounting/SpecializedOpsPage.tsx
Mortezakoohjani 9e1fcd9176 Auto-create default purchase posting profile and complete prepayment treasury fields.
Ensures GR/returns no longer fail without a profile; cash/bank selection appears by payment method.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:03:39 +03:30

442 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* Typed operational document workflows for domains without dedicated engines yet.
* Still real /ops API — NOT the generic number/party/amount stub.
*/
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { z } from "zod";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney, parseMoneyInput } from "@/lib/utils";
import {
Badge,
Button,
DataTable,
DatePicker,
Dialog,
EmptyState,
ErrorState,
FormField,
Input,
JalaliDateText,
LoadingState,
MoneyInput,
PageHeader,
Select,
} from "@/components/ds";
import { PartyCombobox } from "@/components/accounting/EntityCombobox";
type FieldDef =
| {
key: string;
label: string;
kind: "text" | "money" | "date" | "select" | "cash_box" | "bank_account";
options?: { value: string; label: string }[];
required?: boolean;
/** Show field only when another field equals this value */
showWhen?: { key: string; value: string | string[] };
}
| { key: string; label: string; kind: "party"; module?: string; required?: boolean; showWhen?: { key: string; value: string | string[] } };
function isShown(f: FieldDef, values: Record<string, string>): boolean {
if (!f.showWhen) return true;
const current = values[f.showWhen.key] ?? "";
const expected = f.showWhen.value;
return Array.isArray(expected) ? expected.includes(current) : current === expected;
}
export function SpecializedOpsPage({
title,
description,
module,
docType,
createLabel,
fields,
amountKey = "amount",
}: {
title: string;
description: string;
module: string;
docType: string;
createLabel: string;
fields: FieldDef[];
amountKey?: string;
}) {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const needsCash = fields.some((f) => f.kind === "cash_box");
const needsBank = fields.some((f) => f.kind === "bank_account");
const cashBoxesQ = useQuery({
queryKey: ["accounting", tenantId, "cash-boxes"],
queryFn: () => accountingApi.treasury.listCashBoxes(tenantId!),
enabled: !!tenantId && needsCash,
});
const bankAccountsQ = useQuery({
queryKey: ["accounting", tenantId, "bank-accounts"],
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
enabled: !!tenantId && needsBank,
});
const banksQ = useQuery({
queryKey: ["accounting", tenantId, "banks"],
queryFn: () => accountingApi.treasury.listBanks(tenantId!),
enabled: !!tenantId && needsBank,
});
const bankNameById = useMemo(() => {
const m = new Map<string, string>();
for (const b of banksQ.data ?? []) m.set(b.id, b.name);
return m;
}, [banksQ.data]);
const shape: Record<string, z.ZodTypeAny> = {
number: z.string().optional(),
doc_date: z.string().min(1, "تاریخ الزامی است"),
party_name: z.string().optional(),
party_id: z.string().optional(),
description: z.string().optional(),
};
for (const f of fields) {
if (f.kind === "party") continue;
// Conditional fields validated in submit — keep optional in zod
if (f.showWhen) {
shape[f.key] = z.string().optional();
} else {
shape[f.key] = f.required === false ? z.string().optional() : z.string().min(1, `${f.label} الزامی است`);
}
}
const schema = z.object(shape);
type Values = z.infer<typeof schema>;
const defaults = useMemo(() => {
const d: Record<string, string> = {
number: "",
doc_date: new Date().toISOString().slice(0, 10),
party_name: "",
party_id: "",
description: "",
};
for (const f of fields) {
if (f.kind === "party") continue;
d[f.key] = f.kind === "money" ? "0" : f.kind === "date" ? new Date().toISOString().slice(0, 10) : "";
}
return d as Values;
}, [fields]);
const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: defaults });
const watched = form.watch() as Record<string, string>;
const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", module, docType],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, module, docType),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: Values) => {
const values = v as Record<string, string>;
for (const f of fields) {
if (f.kind === "party") continue;
if (!isShown(f, values)) continue;
if (f.required === false) continue;
const raw = String(values[f.key] ?? "").trim();
if (!raw || (f.kind === "money" && Number(parseMoneyInput(raw) || "0") <= 0 && f.key === amountKey)) {
if (f.kind === "money" && f.key === amountKey && Number(parseMoneyInput(raw) || "0") <= 0) {
throw new Error(`${f.label} باید بزرگ‌تر از صفر باشد`);
}
if (f.kind !== "money" && !raw) {
throw new Error(`${f.label} الزامی است`);
}
}
}
const meta: Record<string, string> = {};
for (const f of fields) {
if (f.kind === "party") continue;
if (!isShown(f, values)) continue;
const raw = String(values[f.key] ?? "");
meta[f.key] = f.kind === "money" ? parseMoneyInput(raw) || "0" : raw;
if (f.kind === "cash_box") {
const box = (cashBoxesQ.data ?? []).find((c) => c.id === raw);
if (box) meta.cash_box_name = box.name;
}
if (f.kind === "bank_account") {
const bank = (bankAccountsQ.data ?? []).find((b) => b.id === raw);
if (bank) {
meta.bank_account_number = bank.account_number;
meta.bank_name = bankNameById.get(bank.bank_id) || "";
}
}
}
const amount = parseMoneyInput(String(values[amountKey] ?? meta[amountKey] ?? "0")) || "0";
return accountingApi.ops.createDocument(tenantId!, {
module,
doc_type: docType,
number: String(v.number || "").trim() || undefined,
doc_date: String(v.doc_date),
party_name: v.party_name ? String(v.party_name) : undefined,
party_id: v.party_id ? String(v.party_id) : undefined,
amount,
description: v.description ? String(v.description) : undefined,
status: "draft",
meta_json: JSON.stringify(meta),
});
},
onSuccess: async () => {
toast.success(`${title} ثبت شد`);
setOpen(false);
form.reset(defaults);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
},
onError: (e: Error) => toast.error(e.message),
});
const openCreate = async () => {
form.reset(defaults);
setOpen(true);
try {
const seq = await accountingApi.setup.peekNumber(tenantId!, `ops.${module}.${docType}`);
form.setValue("number" as never, seq.preview as never);
} catch {
/* BE allocates on blank */
}
};
const confirmM = useMutation({
mutationFn: (id: string) => accountingApi.ops.confirmDocument(tenantId!, id),
onSuccess: async () => {
toast.success("تأیید شد");
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
const methodLabel = (r: Record<string, unknown>) => {
try {
const meta = JSON.parse(String(r.meta_json || "{}")) as Record<string, string>;
if (meta.method === "cash") return meta.cash_box_name ? `نقد — ${meta.cash_box_name}` : "نقد";
if (meta.method === "bank")
return meta.bank_account_number
? `بانک — ${meta.bank_name ? meta.bank_name + " " : ""}${meta.bank_account_number}`
: "بانک";
if (meta.method === "cheque") return "چک";
return meta.method || "—";
} catch {
return "—";
}
};
return (
<div>
<PageHeader
title={title}
description={description}
actions={
<Button type="button" onClick={() => void openCreate()}>
<Plus className="h-4 w-4" />
{createLabel}
</Button>
}
/>
<DataTable
columns={[
{ key: "number", header: "شماره" },
{ key: "doc_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} /> },
{ key: "party_name", header: "طرف حساب" },
{ key: "amount", header: "مبلغ", render: (r) => formatMoney(String(r.amount ?? "0")) },
{
key: "method",
header: "روش / حساب",
render: (r) => methodLabel(r),
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge tone={r.status === "confirmed" ? "success" : "default"}>{String(r.status)}</Badge>,
},
{
key: "id",
header: "عملیات",
render: (r) =>
r.status === "draft" ? (
<Button size="sm" variant="outline" onClick={() => confirmM.mutate(String(r.id))}>
تأیید
</Button>
) : null,
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title={`${title} ثبت نشده`}
action={
<Button type="button" onClick={() => void openCreate()}>
<Plus className="h-4 w-4" />
{createLabel}
</Button>
}
/>
}
/>
<Dialog open={open} onClose={() => setOpen(false)} title={createLabel} size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
<FormField label="شماره" error={form.formState.errors.number?.message as string | undefined}>
<Input {...form.register("number")} placeholder="خودکار از سیستم" />
</FormField>
<FormField label="تاریخ">
<Controller
control={form.control}
name="doc_date"
render={({ field }) => (
<DatePicker
value={String(field.value ?? "")}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
{fields.map((f) => {
if (!isShown(f, watched)) return null;
if (f.kind === "party") {
return (
<FormField key={f.key} label={f.label} className="sm:col-span-2">
<PartyCombobox
module={f.module}
valueName={(form.watch("party_name") as string) || undefined}
onChange={(party) => {
form.setValue("party_name", party?.name || "");
form.setValue("party_id", party?.id || "");
}}
/>
</FormField>
);
}
if (f.kind === "money") {
return (
<FormField key={f.key} label={f.label}>
<MoneyInput {...form.register(f.key as keyof Values)} />
</FormField>
);
}
if (f.kind === "date") {
return (
<FormField key={f.key} label={f.label}>
<Controller
control={form.control}
name={f.key as keyof Values}
render={({ field }) => (
<DatePicker
value={String(field.value || "")}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
);
}
if (f.kind === "cash_box") {
return (
<FormField
key={f.key}
label={f.label}
hint="صندوق محل پرداخت/دریافت نقد"
error={
!(cashBoxesQ.data ?? []).length
? "صندوقی تعریف نشده — از خزانه صندوق بسازید"
: undefined
}
>
<Select {...form.register(f.key as keyof Values)}>
<option value="">انتخاب صندوق</option>
{(cashBoxesQ.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
</FormField>
);
}
if (f.kind === "bank_account") {
return (
<FormField
key={f.key}
label={f.label}
hint="شماره حساب بانکی مبدأ/مقصد"
error={
!(bankAccountsQ.data ?? []).length
? "حساب بانکی تعریف نشده — از خزانه حساب بانکی بسازید"
: undefined
}
>
<Select {...form.register(f.key as keyof Values)}>
<option value="">انتخاب حساب بانکی</option>
{(bankAccountsQ.data ?? []).map((b) => (
<option key={b.id} value={b.id}>
{(bankNameById.get(b.bank_id) || "بانک") + " — " + b.account_number}
</option>
))}
</Select>
</FormField>
);
}
if (f.kind === "select") {
return (
<FormField key={f.key} label={f.label}>
<Select
{...form.register(f.key as keyof Values)}
onChange={(e) => {
form.setValue(f.key as keyof Values, e.target.value as never);
// Clear dependent treasury fields when method changes
if (f.key === "method") {
form.setValue("cash_box_id" as never, "" as never);
form.setValue("bank_account_id" as never, "" as never);
}
}}
>
<option value="">انتخاب</option>
{(f.options ?? []).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</FormField>
);
}
return (
<FormField key={f.key} label={f.label}>
<Input {...form.register(f.key as keyof Values)} />
</FormField>
);
})}
<FormField label="توضیحات" className="sm:col-span-2">
<Input {...form.register("description")} />
</FormField>
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}