"use client";
/**
* Domain workflow screens that replace generic BusinessDocsPage stubs
* where dedicated backend models/APIs exist.
*/
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,
DataTable,
DatePicker,
Dialog,
EmptyState,
ErrorState,
FormField,
Input,
JalaliDateText,
LoadingState,
MoneyInput,
PageHeader,
Select,
Textarea,
} from "@/components/ds";
const today = () => new Date().toISOString().slice(0, 10);
const req = z.string().min(1, "الزامی");
function Actions({ onCancel, pending }: { onCancel: () => void; pending?: boolean }) {
return (
);
}
/* -------------------- Assets lifecycle -------------------- */
export function AssetTransfersPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const schema = z.object({
asset_id: req,
transfer_date: req,
from_location: z.string().optional(),
to_location: z.string().optional(),
});
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: { asset_id: "", transfer_date: today(), from_location: "", to_location: "" },
});
const assetsQ = useQuery({
queryKey: ["accounting", tenantId, "assets"],
queryFn: () => accountingApi.assets.list(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "asset-transfers"],
queryFn: () => accountingApi.assets.listTransfers(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: z.infer) => accountingApi.assets.createTransfer(tenantId!, v),
onSuccess: async () => {
toast.success("انتقال دارایی ثبت شد");
setOpen(false);
form.reset({ asset_id: "", transfer_date: today(), from_location: "", to_location: "" });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "asset-transfers"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading || assetsQ.isLoading) return ;
if (listQ.error || assetsQ.error)
return listQ.refetch()} />;
const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`]));
return (
setOpen(true)}>
انتقال جدید
}
/>
names.get(String(r.asset_id)) || String(r.asset_id) },
{ key: "transfer_date", header: "تاریخ", render: (r) => },
{ key: "from_location", header: "از" },
{ key: "to_location", header: "به" },
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={
setOpen(true)}>
انتقال جدید
}
/>
}
/>
);
}
export function AssetDisposalsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const schema = z.object({
asset_id: req,
disposal_date: req,
disposal_type: req,
sale_amount: z.string().optional(),
});
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: { asset_id: "", disposal_date: today(), disposal_type: "sale", sale_amount: "0" },
});
const assetsQ = useQuery({
queryKey: ["accounting", tenantId, "assets"],
queryFn: () => accountingApi.assets.list(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "asset-disposals"],
queryFn: () => accountingApi.assets.listDisposals(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: z.infer) =>
accountingApi.assets.createDisposal(tenantId!, {
...v,
sale_amount: parseMoneyInput(v.sale_amount || "0") || undefined,
}),
onSuccess: async () => {
toast.success("خروج/فروش دارایی ثبت شد");
setOpen(false);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "asset-disposals"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading || assetsQ.isLoading) return ;
if (listQ.error || assetsQ.error)
return listQ.refetch()} />;
const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`]));
return (
setOpen(true)}>
ثبت خروج/فروش
}
/>
names.get(String(r.asset_id)) || String(r.asset_id) },
{ key: "disposal_date", header: "تاریخ", render: (r) => },
{ key: "disposal_type", header: "نوع" },
{ key: "sale_amount", header: "مبلغ فروش", render: (r) => formatMoney(String(r.sale_amount ?? "0")) },
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={ setOpen(true)}>ثبت} />}
/>
);
}
export function AssetRevaluationsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const schema = z.object({
asset_id: req,
revaluation_date: req,
old_book_value: req,
new_book_value: req,
});
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: { asset_id: "", revaluation_date: today(), old_book_value: "0", new_book_value: "0" },
});
const assetsQ = useQuery({
queryKey: ["accounting", tenantId, "assets"],
queryFn: () => accountingApi.assets.list(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "asset-revaluations"],
queryFn: () => accountingApi.assets.listRevaluations(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: z.infer) =>
accountingApi.assets.createRevaluation(tenantId!, {
...v,
old_book_value: parseMoneyInput(v.old_book_value) || "0",
new_book_value: parseMoneyInput(v.new_book_value) || "0",
}),
onSuccess: async () => {
toast.success("ارزشگذاری ثبت شد");
setOpen(false);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "asset-revaluations"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading || assetsQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
const names = new Map((assetsQ.data ?? []).map((a) => [a.id, `${a.code} — ${a.name}`]));
return (
setOpen(true)}>
ارزشگذاری جدید
}
/>
names.get(String(r.asset_id)) || String(r.asset_id) },
{
key: "revaluation_date",
header: "تاریخ",
render: (r) => ,
},
{ key: "old_book_value", header: "ارزش قبلی", render: (r) => formatMoney(String(r.old_book_value)) },
{ key: "new_book_value", header: "ارزش جدید", render: (r) => formatMoney(String(r.new_book_value)) },
{ key: "difference", header: "اختلاف", render: (r) => formatMoney(String(r.difference)) },
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={ setOpen(true)}>ثبت} />}
/>
);
}
export function AssetAccumulatedPage() {
const { tenantId } = useTenantId();
const assetsQ = useQuery({
queryKey: ["accounting", tenantId, "assets"],
queryFn: () => accountingApi.assets.list(tenantId!),
enabled: !!tenantId,
});
if (!tenantId || assetsQ.isLoading) return ;
if (assetsQ.error) return assetsQ.refetch()} />;
return (
{String(r.status)} },
]}
rows={(assetsQ.data ?? []) as unknown as Record[]}
empty={}
/>
);
}
/* -------------------- Payroll masters -------------------- */
export function PayrollContractsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const schema = z.object({
employee_id: req,
contract_type: req,
start_date: req,
end_date: z.string().optional(),
base_salary: req,
});
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: {
employee_id: "",
contract_type: "permanent",
start_date: today(),
end_date: "",
base_salary: "0",
},
});
const employeesQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-employees"],
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-contracts"],
queryFn: () => accountingApi.payroll.listContracts(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: z.infer) =>
accountingApi.payroll.createContract(tenantId!, {
...v,
end_date: v.end_date || undefined,
base_salary: parseMoneyInput(v.base_salary) || "0",
}),
onSuccess: async () => {
toast.success("قرارداد ثبت شد");
setOpen(false);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payroll-contracts"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading || employeesQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
const names = new Map((employeesQ.data ?? []).map((e) => [e.id, `${e.code} — ${e.name}`]));
return (
setOpen(true)}>
قرارداد جدید
}
/>
names.get(String(r.employee_id)) || String(r.employee_id) },
{ key: "contract_type", header: "نوع" },
{ key: "start_date", header: "شروع", render: (r) => },
{ key: "base_salary", header: "حقوق پایه", render: (r) => formatMoney(String(r.base_salary)) },
{
key: "is_active",
header: "وضعیت",
render: (r) => {r.is_active ? "فعال" : "غیرفعال"},
},
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={ setOpen(true)}>ثبت} />}
/>
);
}
export function PayrollComponentsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const schema = z.object({
code: req,
name: req,
component_type: req,
is_taxable: z.boolean().optional(),
});
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: { code: "", name: "", component_type: "earning", is_taxable: true },
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "salary-components"],
queryFn: () => accountingApi.payroll.listSalaryComponents(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: z.infer) => accountingApi.payroll.createSalaryComponent(tenantId!, v),
onSuccess: async () => {
toast.success("قلم حقوق ثبت شد");
setOpen(false);
form.reset({ code: "", name: "", component_type: "earning", is_taxable: true });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "salary-components"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
return (
setOpen(true)}>
قلم جدید
}
/>
(r.is_taxable ? "بله" : "خیر"),
},
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={ setOpen(true)}>ثبت} />}
/>
);
}
export function PayrollStructuresPage() {
return ;
}
export function PayrollPaymentsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const listQ = useQuery({
queryKey: ["accounting", tenantId, "payrolls"],
queryFn: () => accountingApi.payroll.listPayrolls(tenantId!),
enabled: !!tenantId,
});
const employeesQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-employees"],
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
enabled: !!tenantId,
});
const postM = useMutation({
mutationFn: (id: string) => accountingApi.payroll.post(tenantId!, id),
onSuccess: async () => {
toast.success("حقوق به حسابداری ارسال شد");
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payrolls"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
const names = new Map((employeesQ.data ?? []).map((e) => [e.id, e.name]));
return (
names.get(String(r.employee_id)) || String(r.employee_id) },
{ key: "gross_salary", header: "ناخالص", render: (r) => formatMoney(String(r.gross_salary)) },
{ key: "net_salary", header: "خالص", render: (r) => formatMoney(String(r.net_salary)) },
{ key: "status", header: "وضعیت", render: (r) => {String(r.status)} },
{
key: "id",
header: "عملیات",
render: (r) =>
r.status !== "posted" ? (
) : (
ارسال شده
),
},
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={}
/>
);
}
export function PayrollCalculatePage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const schema = z.object({ payroll_period_id: req, employee_id: req });
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: { payroll_period_id: "", employee_id: "" },
});
const [result, setResult] = useState<{ id: string; gross_salary: string; net_salary: string; status: string } | null>(
null
);
const employeesQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-employees"],
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
enabled: !!tenantId,
});
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-periods"],
queryFn: () => accountingApi.payroll.listPeriods(tenantId!),
enabled: !!tenantId,
});
const calcM = useMutation({
mutationFn: (v: z.infer) => accountingApi.payroll.calculate(tenantId!, v),
onSuccess: async (data) => {
setResult(data);
toast.success("محاسبه انجام شد");
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payrolls"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || employeesQ.isLoading || periodsQ.isLoading) return ;
if (employeesQ.error || periodsQ.error)
return (
{
employeesQ.refetch();
periodsQ.refetch();
}}
/>
);
return (
{result ? (
ناخالص: {formatMoney(result.gross_salary)} — خالص: {formatMoney(result.net_salary)} — وضعیت: {result.status}
) : null}
);
}
/* -------------------- Compliance -------------------- */
export function ComplianceApprovalsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [wfOpen, setWfOpen] = useState(false);
const reqSchema = z.object({ workflow_id: req, resource_type: req, resource_id: req });
const wfSchema = z.object({ name: req, resource_type: req });
const reqForm = useForm>({
resolver: zodResolver(reqSchema),
defaultValues: { workflow_id: "", resource_type: "voucher", resource_id: "" },
});
const wfForm = useForm>({
resolver: zodResolver(wfSchema),
defaultValues: { name: "", resource_type: "voucher" },
});
const workflowsQ = useQuery({
queryKey: ["accounting", tenantId, "workflows"],
queryFn: () => accountingApi.compliance.listWorkflows(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "approvals"],
queryFn: () => accountingApi.compliance.listApprovals(tenantId!),
enabled: !!tenantId,
});
const createWf = useMutation({
mutationFn: (v: z.infer) => accountingApi.compliance.createWorkflow(tenantId!, v),
onSuccess: async () => {
toast.success("گردش کار ایجاد شد");
setWfOpen(false);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "workflows"] });
},
onError: (e: Error) => toast.error(e.message),
});
const createReq = useMutation({
mutationFn: (v: z.infer) => accountingApi.compliance.requestApproval(tenantId!, v),
onSuccess: async () => {
toast.success("درخواست تأیید ثبت شد");
setOpen(false);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "approvals"] });
},
onError: (e: Error) => toast.error(e.message),
});
const approveM = useMutation({
mutationFn: (id: string) => accountingApi.compliance.approve(tenantId!, id),
onSuccess: async () => {
toast.success("تأیید شد");
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "approvals"] });
},
onError: (e: Error) => toast.error(e.message),
});
const rejectM = useMutation({
mutationFn: (id: string) => accountingApi.compliance.reject(tenantId!, id, "رد از رابط کاربری"),
onSuccess: async () => {
toast.success("رد شد");
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "approvals"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading || workflowsQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
return (
>
}
/>
{String(r.status)} },
{
key: "id",
header: "عملیات",
render: (r) =>
r.status === "pending" ? (
) : null,
},
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={ setOpen(true)}>درخواست} />}
/>
);
}
export function ComplianceSodPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const schema = z.object({
code: req,
name: req,
rule_type: z.string().default("sod"),
condition_config: req,
action_config: z.string().optional(),
});
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: {
code: "",
name: "",
rule_type: "sod",
condition_config: '{"creator_ne_approver":true}',
action_config: "",
},
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "governance-rules"],
queryFn: () => accountingApi.compliance.listGovernanceRules(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: z.infer) => accountingApi.compliance.createGovernanceRule(tenantId!, v),
onSuccess: async () => {
toast.success("قاعده SoD ثبت شد");
setOpen(false);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "governance-rules"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
return (
setOpen(true)}>
قاعده جدید
}
/>
{r.is_active ? "بله" : "خیر"},
},
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={ setOpen(true)}>ثبت} />}
/>
);
}
export function ComplianceControlsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const schema = z.object({ code: req, name: req, control_type: req, description: z.string().optional() });
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: { code: "", name: "", control_type: "preventive", description: "" },
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "controls"],
queryFn: () => accountingApi.compliance.listControls(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: z.infer) => accountingApi.compliance.createControl(tenantId!, v),
onSuccess: async () => {
toast.success("کنترل ثبت شد");
setOpen(false);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "controls"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
return (
);
}
export function ComplianceViolationsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const schema = z.object({
policy_id: req,
resource_type: req,
resource_id: req,
severity: req,
description: req,
});
const form = useForm>({
resolver: zodResolver(schema),
defaultValues: {
policy_id: "",
resource_type: "voucher",
resource_id: "",
severity: "warning",
description: "",
},
});
const policiesQ = useQuery({
queryKey: ["accounting", tenantId, "policies"],
queryFn: () => accountingApi.compliance.listPolicies(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "violations"],
queryFn: () => accountingApi.compliance.listViolations(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (v: z.infer) => accountingApi.compliance.createViolation(tenantId!, v),
onSuccess: async () => {
toast.success("تخلف ثبت شد");
setOpen(false);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "violations"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading || policiesQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
return (
setOpen(true)}>
تخلف جدید
}
/>
{String(r.severity)} },
{ key: "resource_type", header: "منبع" },
{ key: "resource_id", header: "شناسه" },
{ key: "description", header: "شرح" },
{ key: "detected_at", header: "زمان", render: (r) => String(r.detected_at).slice(0, 19) },
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={ setOpen(true)}>ثبت} />}
/>
);
}
export function ComplianceRecordsPage() {
const { tenantId } = useTenantId();
const listQ = useQuery({
queryKey: ["accounting", tenantId, "audit-records"],
queryFn: () => accountingApi.compliance.listAudit(tenantId!),
enabled: !!tenantId,
});
if (!tenantId || listQ.isLoading) return ;
if (listQ.error) return listQ.refetch()} />;
return (
String(r.created_at).slice(0, 19) },
]}
rows={(listQ.data ?? []) as unknown as Record[]}
empty={}
/>
);
}