Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms. Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
9.9 KiB
TypeScript
280 lines
9.9 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"; 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>
|
||
);
|
||
}
|