Ensures GR/returns no longer fail without a profile; cash/bank selection appears by payment method. Co-authored-by: Cursor <cursoragent@cursor.com>
442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|