TorbatYar/frontend/components/accounting/DomainScreens.tsx
Mortezakoohjani 067b499193 Connect Accounting frontend to domain APIs for phases 5.1-5.11.
Wire sales/purchase posting, settlements, compliance, payroll employees, and ops edit flows; document the integration report.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:17:41 +03:30

561 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
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 } 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";
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, 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="تسویه‌ای ثبت نشده" />}
/>
<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}><Select {...form.register("party_id")}><option value="">انتخاب</option>{(partiesQ.data ?? []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Select></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(), 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="فاکتور فروشی ثبت نشده" />}
/>
<DocumentDialog open={createOpen} onClose={() => setCreateOpen(false)} title="ثبت فاکتور فروش" form={docForm} mutation={createDocM} />
<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 }: { open: boolean; onClose: () => void; title: string; form: DocumentForm; mutation: DocumentMutation }) {
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))}>
<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="طرف حساب"><Input {...form.register("party_name")} /></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="رسید کالایی ثبت نشده" />}
/>
<DocumentDialog open={createOpen} onClose={() => setCreateOpen(false)} title="ثبت رسید کالا" form={docForm} mutation={createM} />
<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>
);
}