TorbatYar/frontend/components/accounting/SpecializedOpsPage.tsx
Mortezakoohjani 7953e47f8b Ship accounting UX: Rial integers, auto doc numbers, reverse-and-edit, real reports, customers nav.
Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:52:06 +03:30

280 lines
9.9 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"; options?: { value: string; label: string }[]; required?: boolean }
| { key: string; label: string; kind: "party"; module?: string; required?: boolean };
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 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;
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 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 meta: Record<string, string> = {};
for (const f of fields) {
if (f.kind === "party") continue;
const raw = String((v as Record<string, string>)[f.key] ?? "");
meta[f.key] = f.kind === "money" ? parseMoneyInput(raw) || "0" : raw;
}
const amount = parseMoneyInput(String((v as Record<string, string>)[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 || undefined,
party_id: v.party_id || undefined,
amount,
description: 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()} />;
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: "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={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />}
/>
</FormField>
{fields.map((f) => {
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 === "select") {
return (
<FormField key={f.key} label={f.label}>
<Select {...form.register(f.key as keyof Values)}>
<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>
);
}