Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms. Co-authored-by: Cursor <cursoragent@cursor.com>
653 lines
39 KiB
TypeScript
653 lines
39 KiB
TypeScript
"use client";
|
||
|
||
import { 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,
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
DataTable,
|
||
DatePicker,
|
||
Dialog,
|
||
EmptyState,
|
||
ErrorState,
|
||
FormField,
|
||
Input,
|
||
JalaliDateText,
|
||
LoadingState,
|
||
MoneyInput,
|
||
PageHeader,
|
||
Select,
|
||
StatCard,
|
||
Textarea,
|
||
} from "@/components/ds";
|
||
import { PartyCombobox } from "@/components/accounting/EntityCombobox";
|
||
|
||
const today = () => new Date().toISOString().slice(0, 10);
|
||
const requiredText = z.string().min(1, "این فیلد الزامی است");
|
||
|
||
function Actions({
|
||
onCancel,
|
||
pending,
|
||
submitLabel = "ذخیره",
|
||
}: {
|
||
onCancel: () => void;
|
||
pending: boolean;
|
||
submitLabel?: string;
|
||
}) {
|
||
return (
|
||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||
<Button type="button" variant="outline" onClick={onCancel}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={pending}>
|
||
{submitLabel}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const employeeSchema = z.object({
|
||
employee_code: requiredText,
|
||
first_name: requiredText,
|
||
last_name: requiredText,
|
||
base_salary: requiredText,
|
||
department_id: z.string(),
|
||
});
|
||
|
||
export function PayrollEmployeesPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const form = useForm<z.infer<typeof employeeSchema>>({
|
||
resolver: zodResolver(employeeSchema),
|
||
defaultValues: { employee_code: "", first_name: "", last_name: "", base_salary: "0", department_id: "" },
|
||
});
|
||
const employeesQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payroll-employees"],
|
||
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const departmentsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payroll-departments"],
|
||
queryFn: () => accountingApi.payroll.listDepartments(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (values: z.infer<typeof employeeSchema>) =>
|
||
accountingApi.payroll.createEmployee(tenantId!, {
|
||
...values,
|
||
department_id: values.department_id || undefined,
|
||
}),
|
||
onSuccess: async () => {
|
||
toast.success("کارمند ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payroll-employees"] });
|
||
},
|
||
onError: (error: Error) => toast.error(error.message),
|
||
});
|
||
if (!tenantId || employeesQ.isLoading || departmentsQ.isLoading) return <LoadingState />;
|
||
if (employeesQ.error || departmentsQ.error) {
|
||
const error = employeesQ.error || departmentsQ.error;
|
||
return <ErrorState message={error!.message} onRetry={() => { employeesQ.refetch(); departmentsQ.refetch(); }} />;
|
||
}
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="کارمندان"
|
||
description="تعریف کارکنان و اتصال آنها به واحد سازمانی."
|
||
actions={<Button onClick={() => setOpen(true)}><Plus className="h-4 w-4" />کارمند جدید</Button>}
|
||
/>
|
||
<DataTable
|
||
columns={[{ key: "code", header: "کد پرسنلی" }, { key: "name", header: "نام و نام خانوادگی" }]}
|
||
rows={employeesQ.data as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="کارمندی ثبت نشده" />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت کارمند" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="کد پرسنلی" error={form.formState.errors.employee_code?.message}><Input {...form.register("employee_code")} /></FormField>
|
||
<FormField label="نام" error={form.formState.errors.first_name?.message}><Input {...form.register("first_name")} /></FormField>
|
||
<FormField label="نام خانوادگی" error={form.formState.errors.last_name?.message}><Input {...form.register("last_name")} /></FormField>
|
||
<FormField label="حقوق پایه" error={form.formState.errors.base_salary?.message}><MoneyInput {...form.register("base_salary")} /></FormField>
|
||
<FormField label="واحد سازمانی">
|
||
<Select {...form.register("department_id")}>
|
||
<option value="">بدون واحد</option>
|
||
{(departmentsQ.data ?? []).map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||
</Select>
|
||
</FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const policySchema = z.object({
|
||
code: requiredText,
|
||
name: requiredText,
|
||
policy_type: requiredText,
|
||
rules_config: requiredText.refine((value) => {
|
||
try { JSON.parse(value); return true; } catch { return false; }
|
||
}, "JSON معتبر وارد کنید"),
|
||
});
|
||
|
||
export function CompliancePoliciesPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const form = useForm<z.infer<typeof policySchema>>({
|
||
resolver: zodResolver(policySchema),
|
||
defaultValues: { code: "", name: "", policy_type: "", rules_config: "{}" },
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "compliance-policies"],
|
||
queryFn: () => accountingApi.compliance.listPolicies(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (values: z.infer<typeof policySchema>) => accountingApi.compliance.createPolicy(tenantId!, values),
|
||
onSuccess: async () => {
|
||
toast.success("سیاست ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "compliance-policies"] });
|
||
},
|
||
onError: (error: Error) => toast.error(error.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader title="سیاستها و قوانین" description="تعریف سیاستهای انطباق و پیکربندی قواعد." actions={<Button onClick={() => setOpen(true)}><Plus className="h-4 w-4" />سیاست جدید</Button>} />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" }, { key: "name", header: "نام" }, { key: "policy_type", header: "نوع" },
|
||
{ key: "is_active", header: "وضعیت", render: (r) => <Badge tone={r.is_active ? "success" : "default"}>{r.is_active ? "فعال" : "غیرفعال"}</Badge> },
|
||
]}
|
||
rows={listQ.data as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="سیاستی ثبت نشده" />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="سیاست جدید" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="کد" error={form.formState.errors.code?.message}><Input {...form.register("code")} /></FormField>
|
||
<FormField label="نام" error={form.formState.errors.name?.message}><Input {...form.register("name")} /></FormField>
|
||
<FormField label="نوع سیاست" error={form.formState.errors.policy_type?.message}><Input {...form.register("policy_type")} placeholder="tax, audit, ..." /></FormField>
|
||
<div className="sm:col-span-2"><FormField label="پیکربندی قواعد (JSON)" error={form.formState.errors.rules_config?.message}><Textarea dir="ltr" {...form.register("rules_config")} /></FormField></div>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const riskSchema = z.object({
|
||
code: requiredText,
|
||
title: requiredText,
|
||
risk_category: requiredText,
|
||
risk_level: z.enum(["low", "medium", "high", "critical"]),
|
||
risk_score: z.number().int().min(0),
|
||
});
|
||
|
||
export function ComplianceRisksPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const form = useForm<z.infer<typeof riskSchema>>({
|
||
resolver: zodResolver(riskSchema),
|
||
defaultValues: { code: "", title: "", risk_category: "", risk_level: "low", risk_score: 0 },
|
||
});
|
||
const listQ = useQuery({ queryKey: ["accounting", tenantId, "compliance-risks"], queryFn: () => accountingApi.compliance.listRisks(tenantId!), enabled: !!tenantId });
|
||
const createM = useMutation({
|
||
mutationFn: (values: z.infer<typeof riskSchema>) => accountingApi.compliance.createRisk(tenantId!, values),
|
||
onSuccess: async () => {
|
||
toast.success("ریسک ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "compliance-risks"] });
|
||
},
|
||
onError: (error: Error) => toast.error(error.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader title="مدیریت ریسک" description="ثبت و پایش ریسکهای مالی و عملیاتی." actions={<Button onClick={() => setOpen(true)}><Plus className="h-4 w-4" />ریسک جدید</Button>} />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" }, { key: "title", header: "عنوان" }, { key: "risk_category", header: "دسته" },
|
||
{ key: "risk_level", header: "سطح", render: (r) => <Badge tone={r.risk_level === "critical" || r.risk_level === "high" ? "danger" : r.risk_level === "medium" ? "warning" : "default"}>{String(r.risk_level)}</Badge> },
|
||
{ key: "risk_score", header: "امتیاز" }, { key: "is_resolved", header: "وضعیت", render: (r) => r.is_resolved ? "رفع شده" : "باز" },
|
||
]}
|
||
rows={listQ.data as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="ریسکی ثبت نشده" />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت ریسک" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="کد" error={form.formState.errors.code?.message}><Input {...form.register("code")} /></FormField>
|
||
<FormField label="عنوان" error={form.formState.errors.title?.message}><Input {...form.register("title")} /></FormField>
|
||
<FormField label="دسته ریسک" error={form.formState.errors.risk_category?.message}><Input {...form.register("risk_category")} /></FormField>
|
||
<FormField label="سطح"><Select {...form.register("risk_level")}><option value="low">کم</option><option value="medium">متوسط</option><option value="high">زیاد</option><option value="critical">بحرانی</option></Select></FormField>
|
||
<FormField label="امتیاز" error={form.formState.errors.risk_score?.message}><Input type="number" min={0} {...form.register("risk_score", { valueAsNumber: true })} /></FormField>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const settlementSchema = z.object({
|
||
settlement_number: requiredText,
|
||
settlement_date: requiredText,
|
||
settlement_type: z.enum(["receipt", "payment"]),
|
||
party_id: requiredText,
|
||
total_amount: requiredText,
|
||
allocations: requiredText.refine((value) => {
|
||
try { return Array.isArray(JSON.parse(value)); } catch { return false; }
|
||
}, "آرایه JSON معتبر وارد کنید"),
|
||
});
|
||
|
||
export function SettlementsPage({ partyType }: { partyType: "customer" | "supplier" }) {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const form = useForm<z.infer<typeof settlementSchema>>({
|
||
resolver: zodResolver(settlementSchema),
|
||
defaultValues: { settlement_number: "", settlement_date: today(), settlement_type: partyType === "customer" ? "receipt" : "payment", party_id: "", total_amount: "0", allocations: "[]" },
|
||
});
|
||
const listQ = useQuery({ queryKey: ["accounting", tenantId, "settlements", partyType], queryFn: () => accountingApi.settlements.list(tenantId!, partyType), enabled: !!tenantId });
|
||
const partiesQ = useQuery({
|
||
queryKey: ["accounting", tenantId, partyType === "customer" ? "customers" : "suppliers"],
|
||
queryFn: () => partyType === "customer" ? accountingApi.customers.list(tenantId!) : accountingApi.suppliers.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (values: z.infer<typeof settlementSchema>) => accountingApi.settlements.create(tenantId!, {
|
||
...values,
|
||
total_amount: parseMoneyInput(values.total_amount) || "0",
|
||
party_type: partyType,
|
||
allocations: JSON.parse(values.allocations) as { invoice_id?: string; amount: string }[],
|
||
}),
|
||
onSuccess: async () => {
|
||
toast.success("تسویه ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "settlements", partyType] });
|
||
},
|
||
onError: (error: Error) => toast.error(error.message),
|
||
});
|
||
if (!tenantId || listQ.isLoading || partiesQ.isLoading) return <LoadingState />;
|
||
if (listQ.error || partiesQ.error) return <ErrorState message={(listQ.error || partiesQ.error)!.message} onRetry={() => { listQ.refetch(); partiesQ.refetch(); }} />;
|
||
const partyNames = new Map((partiesQ.data ?? []).map((p) => [p.id, p.name]));
|
||
return (
|
||
<div>
|
||
<PageHeader title={partyType === "customer" ? "تسویه حساب مشتریان" : "تسویه با تأمینکنندگان"} description="ثبت تسویه و تخصیص آن به اسناد طرف حساب." actions={<Button onClick={() => setOpen(true)}><Plus className="h-4 w-4" />تسویه جدید</Button>} />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "settlement_number", header: "شماره" }, { key: "settlement_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.settlement_date)} /> },
|
||
{ key: "party_id", header: "طرف حساب", render: (r) => partyNames.get(String(r.party_id)) || String(r.party_id) },
|
||
{ key: "settlement_type", header: "نوع" }, { key: "total_amount", header: "مبلغ", render: (r) => formatMoney(String(r.total_amount)) },
|
||
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
|
||
]}
|
||
rows={listQ.data as unknown as Record<string, unknown>[]}
|
||
empty={
|
||
<EmptyState
|
||
title="تسویهای ثبت نشده"
|
||
description="برای شروع، تسویه جدید ثبت کنید."
|
||
action={
|
||
<Button type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
تسویه جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت تسویه" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
|
||
<FormField label="شماره" error={form.formState.errors.settlement_number?.message}><Input {...form.register("settlement_number")} /></FormField>
|
||
<FormField label="تاریخ" error={form.formState.errors.settlement_date?.message}><Controller control={form.control} name="settlement_date" render={({ field }) => <DatePicker {...field} />} /></FormField>
|
||
<FormField label="نوع"><Select {...form.register("settlement_type")}><option value="receipt">دریافت</option><option value="payment">پرداخت</option></Select></FormField>
|
||
<FormField label="طرف حساب" error={form.formState.errors.party_id?.message}>
|
||
<PartyCombobox
|
||
module={partyType === "customer" ? "sales" : "purchase"}
|
||
valueName={partyNames.get(form.watch("party_id")) || undefined}
|
||
onChange={(party) => form.setValue("party_id", party?.id || "", { shouldValidate: true })}
|
||
/>
|
||
</FormField>
|
||
<FormField label="مبلغ کل" error={form.formState.errors.total_amount?.message}><MoneyInput {...form.register("total_amount")} /></FormField>
|
||
<div className="sm:col-span-2"><FormField label="تخصیصها (JSON)" error={form.formState.errors.allocations?.message}><Textarea dir="ltr" placeholder='[{"invoice_id":"...","amount":"1000"}]' {...form.register("allocations")} /></FormField></div>
|
||
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const documentSchema = z.object({
|
||
number: requiredText,
|
||
doc_date: requiredText,
|
||
party_name: z.string(),
|
||
party_id: z.string().optional(),
|
||
amount: requiredText,
|
||
description: z.string(),
|
||
});
|
||
const salesPostSchema = z.object({ source_document_id: requiredText, total_amount: requiredText, discount_amount: requiredText, tax_amount: requiredText, profile_id: z.string() });
|
||
|
||
export function SalesInvoiceAccountingPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [profileOpen, setProfileOpen] = useState(false);
|
||
const [postOpen, setPostOpen] = useState(false);
|
||
const [preview, setPreview] = useState<{ is_balanced: boolean; total_debit: string; total_credit: string } | null>(null);
|
||
const docForm = useForm<z.infer<typeof documentSchema>>({ resolver: zodResolver(documentSchema), defaultValues: { number: "", doc_date: today(), party_name: "", amount: "0", description: "" } });
|
||
const profileForm = useForm({ defaultValues: { name: "" } });
|
||
const postForm = useForm<z.infer<typeof salesPostSchema>>({ resolver: zodResolver(salesPostSchema), defaultValues: { source_document_id: "", total_amount: "0", discount_amount: "0", tax_amount: "0", profile_id: "" } });
|
||
const docsQ = useQuery({ queryKey: ["accounting", tenantId, "ops-docs", "sales", "invoice"], queryFn: () => accountingApi.ops.listDocuments(tenantId!, "sales", "invoice"), enabled: !!tenantId });
|
||
const profilesQ = useQuery({ queryKey: ["accounting", tenantId, "sales-profiles"], queryFn: () => accountingApi.salesAccounting.listProfiles(tenantId!), enabled: !!tenantId });
|
||
const createDocM = useMutation({
|
||
mutationFn: (v: z.infer<typeof documentSchema>) => accountingApi.ops.createDocument(tenantId!, { module: "sales", doc_type: "invoice", status: "draft", ...v }),
|
||
onSuccess: async () => { toast.success("فاکتور ثبت شد"); setCreateOpen(false); docForm.reset(); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "sales", "invoice"] }); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
const createProfileM = useMutation({
|
||
mutationFn: (v: { name: string }) => accountingApi.salesAccounting.createProfile(tenantId!, { name: v.name, document_type: "invoice" }),
|
||
onSuccess: async () => { toast.success("پروفایل ثبت شد"); setProfileOpen(false); profileForm.reset(); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "sales-profiles"] }); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
const previewM = useMutation({
|
||
mutationFn: (v: z.infer<typeof salesPostSchema>) => accountingApi.salesAccounting.preview(tenantId!, { document_type: "invoice", total_amount: v.total_amount, discount_amount: v.discount_amount, tax_amount: v.tax_amount, profile_id: v.profile_id || undefined }),
|
||
onSuccess: (data) => { setPreview(data); toast.success("پیشنمایش آماده شد"); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
const postM = useMutation({
|
||
mutationFn: (v: z.infer<typeof salesPostSchema>) => accountingApi.salesAccounting.post(tenantId!, { document_type: "invoice", ...v, profile_id: v.profile_id || undefined }),
|
||
onSuccess: (data) => { toast.success(`سند ${data.voucher_number} ثبت شد`); setPostOpen(false); setPreview(null); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || docsQ.isLoading || profilesQ.isLoading) return <LoadingState />;
|
||
if (docsQ.error || profilesQ.error) return <ErrorState message={(docsQ.error || profilesQ.error)!.message} onRetry={() => { docsQ.refetch(); profilesQ.refetch(); }} />;
|
||
const openPosting = (row: Record<string, unknown>) => {
|
||
postForm.reset({ source_document_id: String(row.id), total_amount: String(row.amount || "0"), discount_amount: "0", tax_amount: "0", profile_id: "" });
|
||
setPreview(null);
|
||
setPostOpen(true);
|
||
};
|
||
return (
|
||
<div>
|
||
<PageHeader title="فاکتور فروش و ثبت حسابداری" description="ثبت فاکتور عملیاتی، پیشنمایش و ارسال به دفتر حسابداری." actions={<><Button variant="outline" onClick={() => setProfileOpen(true)}>پروفایل ثبت</Button><Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4" />فاکتور جدید</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)) },
|
||
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
|
||
{ key: "actions", header: "عملیات", render: (r) => <Button size="sm" variant="outline" onClick={() => openPosting(r)}>ثبت حسابداری</Button> },
|
||
]}
|
||
rows={docsQ.data as unknown as Record<string, unknown>[]}
|
||
empty={
|
||
<EmptyState
|
||
title="فاکتور فروشی ثبت نشده"
|
||
description="برای شروع، فاکتور جدید ثبت کنید."
|
||
action={
|
||
<Button type="button" onClick={() => setCreateOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
فاکتور جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
}
|
||
/>
|
||
<DocumentDialog open={createOpen} onClose={() => setCreateOpen(false)} title="ثبت فاکتور فروش" form={docForm} mutation={createDocM} partyModule="sales" />
|
||
<Dialog open={profileOpen} onClose={() => setProfileOpen(false)} title="پروفایل ثبت فروش">
|
||
<form className="grid gap-3" onSubmit={profileForm.handleSubmit((v) => createProfileM.mutate(v))}><FormField label="نام پروفایل"><Input {...profileForm.register("name", { required: true })} /></FormField><Actions onCancel={() => setProfileOpen(false)} pending={createProfileM.isPending} /></form>
|
||
</Dialog>
|
||
<Dialog open={postOpen} onClose={() => setPostOpen(false)} title="ثبت حسابداری فاکتور" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={postForm.handleSubmit((v) => postM.mutate(v))}>
|
||
<FormField label="شناسه سند مبدأ" error={postForm.formState.errors.source_document_id?.message}><Input dir="ltr" {...postForm.register("source_document_id")} /></FormField>
|
||
<FormField label="پروفایل"><Select {...postForm.register("profile_id")}><option value="">پروفایل پیشفرض</option>{(profilesQ.data ?? []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Select></FormField>
|
||
<FormField label="مبلغ کل"><MoneyInput {...postForm.register("total_amount")} /></FormField>
|
||
<FormField label="تخفیف"><MoneyInput {...postForm.register("discount_amount")} /></FormField>
|
||
<FormField label="مالیات"><MoneyInput {...postForm.register("tax_amount")} /></FormField>
|
||
{preview ? <div className="sm:col-span-2 rounded-xl bg-[var(--surface-muted)] p-3 text-sm">تراز: {preview.is_balanced ? "بله" : "خیر"} — بدهکار: {formatMoney(preview.total_debit)} — بستانکار: {formatMoney(preview.total_credit)}</div> : null}
|
||
<div className="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" disabled={previewM.isPending} onClick={postForm.handleSubmit((v) => previewM.mutate(v))}>پیشنمایش</Button><Button type="submit" disabled={postM.isPending}>ثبت نهایی</Button></div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type DocumentForm = ReturnType<typeof useForm<z.infer<typeof documentSchema>>>;
|
||
type DocumentMutation = ReturnType<typeof useMutation<unknown, Error, z.infer<typeof documentSchema>>>;
|
||
|
||
function DocumentDialog({
|
||
open,
|
||
onClose,
|
||
title,
|
||
form,
|
||
mutation,
|
||
partyModule,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
title: string;
|
||
form: DocumentForm;
|
||
mutation: DocumentMutation;
|
||
partyModule?: string;
|
||
}) {
|
||
return (
|
||
<Dialog open={open} onClose={onClose} title={title} size="lg">
|
||
<form
|
||
className="grid gap-3 sm:grid-cols-2"
|
||
onSubmit={form.handleSubmit((v) =>
|
||
mutation.mutate({ ...v, amount: parseMoneyInput(v.amount) || "0" })
|
||
)}
|
||
>
|
||
<FormField label="شماره" error={form.formState.errors.number?.message}>
|
||
<Input {...form.register("number")} />
|
||
</FormField>
|
||
<FormField label="تاریخ" error={form.formState.errors.doc_date?.message}>
|
||
<Controller
|
||
control={form.control}
|
||
name="doc_date"
|
||
render={({ field }) => <DatePicker {...field} />}
|
||
/>
|
||
</FormField>
|
||
<FormField label="طرف حساب">
|
||
<PartyCombobox
|
||
module={partyModule}
|
||
valueName={form.watch("party_name") || undefined}
|
||
onChange={(party) => {
|
||
form.setValue("party_name", party?.name || "");
|
||
form.setValue("party_id", party?.id || "");
|
||
}}
|
||
/>
|
||
</FormField>
|
||
<FormField label="مبلغ">
|
||
<MoneyInput {...form.register("amount")} />
|
||
</FormField>
|
||
<div className="sm:col-span-2">
|
||
<FormField label="توضیح">
|
||
<Input {...form.register("description")} />
|
||
</FormField>
|
||
</div>
|
||
<Actions onCancel={onClose} pending={mutation.isPending} />
|
||
</form>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
const purchasePostSchema = z.object({ source_document_id: requiredText, amount: requiredText, profile_id: z.string() });
|
||
|
||
export function PurchaseGoodsReceiptPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [postOpen, setPostOpen] = useState(false);
|
||
const [preview, setPreview] = useState<{ is_balanced: boolean; total_debit: string } | null>(null);
|
||
const docForm = useForm<z.infer<typeof documentSchema>>({ resolver: zodResolver(documentSchema), defaultValues: { number: "", doc_date: today(), party_name: "", amount: "0", description: "" } });
|
||
const postForm = useForm<z.infer<typeof purchasePostSchema>>({ resolver: zodResolver(purchasePostSchema), defaultValues: { source_document_id: "", amount: "0", profile_id: "" } });
|
||
const docsQ = useQuery({ queryKey: ["accounting", tenantId, "ops-docs", "purchase", "goods_receipt"], queryFn: () => accountingApi.ops.listDocuments(tenantId!, "purchase", "goods_receipt"), enabled: !!tenantId });
|
||
const profilesQ = useQuery({ queryKey: ["accounting", tenantId, "purchase-profiles"], queryFn: () => accountingApi.purchaseInventory.listProfiles(tenantId!), enabled: !!tenantId });
|
||
const createM = useMutation({
|
||
mutationFn: (v: z.infer<typeof documentSchema>) => accountingApi.ops.createDocument(tenantId!, { module: "purchase", doc_type: "goods_receipt", status: "draft", ...v }),
|
||
onSuccess: async () => { toast.success("رسید کالا ثبت شد"); setCreateOpen(false); docForm.reset(); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "purchase", "goods_receipt"] }); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
const previewM = useMutation({
|
||
mutationFn: (v: z.infer<typeof purchasePostSchema>) => accountingApi.purchaseInventory.previewGoodsReceipt(tenantId!, { ...v, profile_id: v.profile_id || undefined }),
|
||
onSuccess: (data) => { setPreview(data); toast.success("پیشنمایش آماده شد"); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
const postM = useMutation({
|
||
mutationFn: (v: z.infer<typeof purchasePostSchema>) => accountingApi.purchaseInventory.postGoodsReceipt(tenantId!, { ...v, profile_id: v.profile_id || undefined }),
|
||
onSuccess: () => { toast.success("رسید به حسابداری ارسال شد"); setPostOpen(false); setPreview(null); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || docsQ.isLoading || profilesQ.isLoading) return <LoadingState />;
|
||
if (docsQ.error || profilesQ.error) return <ErrorState message={(docsQ.error || profilesQ.error)!.message} onRetry={() => { docsQ.refetch(); profilesQ.refetch(); }} />;
|
||
const openPosting = (r: Record<string, unknown>) => { postForm.reset({ source_document_id: String(r.id), amount: String(r.amount || "0"), profile_id: "" }); setPreview(null); setPostOpen(true); };
|
||
return (
|
||
<div>
|
||
<PageHeader title="رسید کالا" description="ثبت رسید عملیاتی و ارسال آن به حسابداری خرید و انبار." actions={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4" />رسید جدید</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)) },
|
||
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
|
||
{ key: "actions", header: "عملیات", render: (r) => <Button size="sm" variant="outline" onClick={() => openPosting(r)}>ثبت حسابداری</Button> },
|
||
]}
|
||
rows={docsQ.data as unknown as Record<string, unknown>[]}
|
||
empty={
|
||
<EmptyState
|
||
title="رسید کالایی ثبت نشده"
|
||
description="برای شروع، رسید جدید ثبت کنید."
|
||
action={
|
||
<Button type="button" onClick={() => setCreateOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
رسید جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
}
|
||
/>
|
||
<DocumentDialog open={createOpen} onClose={() => setCreateOpen(false)} title="ثبت رسید کالا" form={docForm} mutation={createM} partyModule="purchase" />
|
||
<Dialog open={postOpen} onClose={() => setPostOpen(false)} title="ثبت حسابداری رسید کالا">
|
||
<form className="grid gap-3" onSubmit={postForm.handleSubmit((v) => postM.mutate(v))}>
|
||
<FormField label="شناسه سند مبدأ"><Input dir="ltr" {...postForm.register("source_document_id")} /></FormField>
|
||
<FormField label="مبلغ"><MoneyInput {...postForm.register("amount")} /></FormField>
|
||
<FormField label="پروفایل"><Select {...postForm.register("profile_id")}><option value="">پروفایل پیشفرض</option>{(profilesQ.data ?? []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Select></FormField>
|
||
{preview ? <div className="rounded-xl bg-[var(--surface-muted)] p-3 text-sm">تراز: {preview.is_balanced ? "بله" : "خیر"} — بدهکار: {formatMoney(preview.total_debit)}</div> : null}
|
||
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={postForm.handleSubmit((v) => previewM.mutate(v))} disabled={previewM.isPending}>پیشنمایش</Button><Button type="submit" disabled={postM.isPending}>ثبت نهایی</Button></div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const valuationSchema = z.object({ item_id: requiredText, quantity: requiredText, unit_cost: requiredText, warehouse_id: z.string() });
|
||
|
||
export function InventoryValuationPage() {
|
||
const { tenantId } = useTenantId();
|
||
const [result, setResult] = useState<string | null>(null);
|
||
const form = useForm<z.infer<typeof valuationSchema>>({ resolver: zodResolver(valuationSchema), defaultValues: { item_id: "", quantity: "0", unit_cost: "0", warehouse_id: "" } });
|
||
const itemsQ = useQuery({ queryKey: ["accounting", tenantId, "ops-items"], queryFn: () => accountingApi.ops.listItems(tenantId!), enabled: !!tenantId });
|
||
const warehousesQ = useQuery({ queryKey: ["accounting", tenantId, "ops-warehouses"], queryFn: () => accountingApi.ops.listWarehouses(tenantId!), enabled: !!tenantId });
|
||
const valuationM = useMutation({
|
||
mutationFn: (v: z.infer<typeof valuationSchema>) => accountingApi.purchaseInventory.valuation(tenantId!, { ...v, warehouse_id: v.warehouse_id || undefined }),
|
||
onSuccess: (data) => { setResult(data.total_value); toast.success("ارزشگذاری ثبت شد"); },
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || itemsQ.isLoading || warehousesQ.isLoading) return <LoadingState />;
|
||
if (itemsQ.error || warehousesQ.error) return <ErrorState message={(itemsQ.error || warehousesQ.error)!.message} onRetry={() => { itemsQ.refetch(); warehousesQ.refetch(); }} />;
|
||
return (
|
||
<div>
|
||
<PageHeader title="ارزشگذاری موجودی" description="محاسبه و ثبت ارزش موجودی بر اساس قلم، تعداد و بهای واحد." />
|
||
<Card className="mb-6">
|
||
<CardHeader><CardTitle>ثبت ارزشگذاری</CardTitle></CardHeader>
|
||
<CardContent>
|
||
<form className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4" onSubmit={form.handleSubmit((v) => valuationM.mutate(v))}>
|
||
<FormField label="کالا"><Select {...form.register("item_id")}><option value="">انتخاب…</option>{(itemsQ.data ?? []).map((i) => <option key={i.id} value={i.id}>{i.code} — {i.name}</option>)}</Select></FormField>
|
||
<FormField label="تعداد"><Input {...form.register("quantity")} /></FormField>
|
||
<FormField label="بهای واحد"><MoneyInput {...form.register("unit_cost")} /></FormField>
|
||
<FormField label="انبار (اختیاری)"><Select {...form.register("warehouse_id")}><option value="">بدون انبار</option>{(warehousesQ.data ?? []).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</Select></FormField>
|
||
<div className="flex items-end gap-3 lg:col-span-4"><Button type="submit" disabled={valuationM.isPending}>محاسبه و ثبت</Button>{result ? <span className="text-sm text-secondary">ارزش کل: {formatMoney(result)}</span> : null}</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
<DataTable
|
||
columns={[{ key: "code", header: "کد" }, { key: "name", header: "نام" }, { key: "unit", header: "واحد" }, { key: "quantity_on_hand", header: "موجودی" }, { key: "unit_cost", header: "بهای واحد", render: (r) => formatMoney(String(r.unit_cost)) }]}
|
||
rows={itemsQ.data as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="کالایی تعریف نشده" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MonitoringHealthPage() {
|
||
const { tenantId } = useTenantId();
|
||
const healthQ = useQuery({ queryKey: ["accounting", "health"], queryFn: () => accountingApi.health() });
|
||
const setupQ = useQuery({ queryKey: ["accounting", tenantId, "setup-status"], queryFn: () => accountingApi.setup.status(tenantId!), enabled: !!tenantId });
|
||
if (!tenantId || healthQ.isLoading || setupQ.isLoading) return <LoadingState />;
|
||
if (healthQ.error || setupQ.error) return <ErrorState message={(healthQ.error || setupQ.error)!.message} onRetry={() => { healthQ.refetch(); setupQ.refetch(); }} />;
|
||
return (
|
||
<div>
|
||
<PageHeader title="وضعیت سرویس حسابداری" description="سلامت API و میزان تکمیل پیکربندی مستأجر." />
|
||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||
<StatCard label="وضعیت سرویس" value={healthQ.data!.status} hint={healthQ.data!.service} />
|
||
<StatCard label="نسخه" value={healthQ.data!.version} />
|
||
<StatCard label="پیشرفت راهاندازی" value={`${setupQ.data!.percent}%`} hint={`${setupQ.data!.completed_steps} از ${setupQ.data!.total_steps} مرحله`} />
|
||
<StatCard label="آمادگی پایه" value={setupQ.data!.has_chart && setupQ.data!.has_fiscal_period ? "آماده" : "نیازمند تکمیل"} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AssetSchedulePage() {
|
||
const { tenantId } = useTenantId();
|
||
const [selectedName, setSelectedName] = useState("");
|
||
const [schedule, setSchedule] = useState<Record<string, unknown>[] | null>(null);
|
||
const assetsQ = useQuery({ queryKey: ["accounting", tenantId, "assets"], queryFn: () => accountingApi.assets.list(tenantId!), enabled: !!tenantId });
|
||
const scheduleM = useMutation({
|
||
mutationFn: (id: string) => accountingApi.assets.schedule(tenantId!, id),
|
||
onSuccess: (data) => setSchedule(data.schedule as Record<string, unknown>[]),
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
if (!tenantId || assetsQ.isLoading) return <LoadingState />;
|
||
if (assetsQ.error) return <ErrorState message={assetsQ.error.message} onRetry={() => assetsQ.refetch()} />;
|
||
return (
|
||
<div>
|
||
<PageHeader title="برنامه استهلاک داراییها" description="مشاهده دورهها، مبلغ استهلاک و ارزش دفتری باقیمانده." />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" }, { key: "name", header: "نام دارایی" }, { key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
|
||
{ key: "actions", header: "عملیات", render: (r) => <Button size="sm" variant="outline" disabled={scheduleM.isPending} onClick={() => { setSelectedName(String(r.name)); scheduleM.mutate(String(r.id)); }}>مشاهده برنامه</Button> },
|
||
]}
|
||
rows={assetsQ.data as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="دارایی ثبت نشده" />}
|
||
/>
|
||
<Dialog open={schedule !== null} onClose={() => setSchedule(null)} title={`برنامه استهلاک ${selectedName}`} size="lg">
|
||
<DataTable
|
||
columns={[
|
||
{ key: "period", header: "دوره" },
|
||
{ key: "amount", header: "استهلاک", render: (r) => formatMoney(String(r.amount ?? "0")) },
|
||
{ key: "book_value_after", header: "ارزش دفتری پایان دوره", render: (r) => formatMoney(String(r.book_value_after ?? "0")) },
|
||
]}
|
||
rows={schedule ?? []}
|
||
empty={<EmptyState title="برنامهای محاسبه نشده" />}
|
||
/>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|