Migrate Beauty, Healthcare, and Accounting frontend to modular src/modules architecture.

Move business logic out of App Router into module packages, add boundary validation scripts, and keep all routes as thin re-exports without changing URLs or API behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-26 22:28:27 +03:30
parent 071c484530
commit 6f4a484051
983 changed files with 41472 additions and 11725 deletions

View File

@ -2,7 +2,7 @@
"extends": "next/core-web-vitals",
"overrides": [
{
"files": ["app/accounting/**/*", "components/accounting/**/*"],
"files": ["app/accounting/**/*", "src/modules/accounting/**/*"],
"rules": {
"no-restricted-imports": [
"error",
@ -13,7 +13,9 @@
"@/components/beauty/**",
"@/components/healthcare/**",
"@/modules/beauty/**",
"@/modules/healthcare/**"
"@/modules/healthcare/**",
"@/src/modules/beauty/**",
"@/src/modules/healthcare/**"
],
"message": "Accounting must not import beauty or healthcare modules."
}
@ -34,7 +36,9 @@
"@/components/accounting/**",
"@/components/healthcare/**",
"@/modules/accounting/**",
"@/modules/healthcare/**"
"@/modules/healthcare/**",
"@/src/modules/accounting/**",
"@/src/modules/healthcare/**"
],
"message": "Beauty must not import accounting or healthcare modules."
}
@ -56,7 +60,8 @@
"@/components/beauty/**",
"@/modules/beauty/**",
"@/modules/accounting/**",
"@/src/modules/beauty/**"
"@/src/modules/beauty/**",
"@/src/modules/accounting/**"
],
"message": "Healthcare must not import accounting or beauty modules."
}
@ -79,7 +84,9 @@
"@/components/healthcare/**",
"@/modules/beauty/**",
"@/modules/healthcare/**",
"@/modules/accounting/**"
"@/modules/accounting/**",
"@/src/modules/accounting/**",
"@/src/modules/healthcare/**"
],
"message": "CRM must not import accounting, beauty, or healthcare modules."
}
@ -93,6 +100,7 @@
"components/ds/**/*",
"shared/**/*",
"components/providers/**/*",
"src/shared/**/*",
"components/AuthGuard.tsx",
"components/AdminAuthGuard.tsx",
"components/ThemeProvider.tsx",
@ -111,6 +119,9 @@
"@/components/healthcare/**",
"@/modules/beauty/**",
"@/modules/healthcare/**",
"@/modules/hospitality/**",
"@/src/modules/healthcare/**",
"@/src/modules/accounting/**",
"@/modules/accounting/**",
"@/modules/crm/**"
],

View File

@ -1,7 +1,3 @@
"use client";
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <BlockedModulePage title="تحلیل هوشمند" />;
}
export { AiAnalyticsPage as default } from "@/src/modules/accounting/pages/ai/analytics";

View File

@ -1,7 +1,3 @@
"use client";
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <BlockedModulePage title="کشف ناهنجاری" />;
}
export { AiAnomaliesPage as default } from "@/src/modules/accounting/pages/ai/anomalies";

View File

@ -1,7 +1,3 @@
"use client";
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <BlockedModulePage title="تحلیل اسناد" />;
}
export { AiDocumentsPage as default } from "@/src/modules/accounting/pages/ai/documents";

View File

@ -1,7 +1,3 @@
"use client";
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <BlockedModulePage title="پیش‌بینی‌ها" />;
}
export { AiForecastsPage as default } from "@/src/modules/accounting/pages/ai/forecasts";

View File

@ -1,7 +1,3 @@
"use client";
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <BlockedModulePage title="دستیار حسابداری" />;
}
export { AiPage as default } from "@/src/modules/accounting/pages/ai";

View File

@ -1,7 +1,3 @@
"use client";
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <BlockedModulePage title="پرسش و پاسخ" />;
}
export { AiQaPage as default } from "@/src/modules/accounting/pages/ai/qa";

View File

@ -1,7 +1,3 @@
"use client";
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <BlockedModulePage title="پیشنهادهای هوشمند" />;
}
export { AiRecommendationsPage as default } from "@/src/modules/accounting/pages/ai/recommendations";

View File

@ -1,7 +1,3 @@
"use client";
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <BlockedModulePage title="تطبیق هوشمند" />;
}
export { AiReconciliationPage as default } from "@/src/modules/accounting/pages/ai/reconciliation";

View File

@ -1,3 +1,3 @@
"use client";
import { AssetAccumulatedPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <AssetAccumulatedPage />; }
export { AssetsAccumulatedPage as default } from "@/src/modules/accounting/pages/assets/accumulated";

View File

@ -1,5 +1,3 @@
import { redirect } from "next/navigation";
"use client";
export default function Page() {
redirect("/accounting/assets");
}
export { AssetsDashboardPage as default } from "@/src/modules/accounting/pages/assets/dashboard";

View File

@ -1,5 +1,3 @@
import { redirect } from "next/navigation";
"use client";
export default function Page() {
redirect("/accounting/assets");
}
export { AssetsDepreciatePage as default } from "@/src/modules/accounting/pages/assets/depreciate";

View File

@ -1,3 +1,3 @@
"use client";
import { AssetDisposalsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <AssetDisposalsPage />; }
export { AssetsDisposePage as default } from "@/src/modules/accounting/pages/assets/dispose";

View File

@ -1,422 +1,3 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Calendar, Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Drawer,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Tabs,
FormField,
Select,
MoneyInput,
} from "@/components/ds";
const categorySchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
default_useful_life_months: z.number().int().min(1).optional(),
});
const assetSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
category_id: z.string().optional(),
acquisition_cost: z.string().optional(),
residual_value: z.string().optional(),
});
const depreciateSchema = z.object({
expense_account_id: z.string().min(1, "حساب هزینه را انتخاب کنید"),
accumulated_account_id: z.string().min(1, "حساب استهلاک انباشته را انتخاب کنید"),
});
type AssetRow = { id: string; code: string; name: string; status: string };
type TabId = "categories" | "assets";
export default function AssetsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [tab, setTab] = useState<TabId>("assets");
const [categoryOpen, setCategoryOpen] = useState(false);
const [assetOpen, setAssetOpen] = useState(false);
const [depreciateAsset, setDepreciateAsset] = useState<AssetRow | null>(null);
const [scheduleAsset, setScheduleAsset] = useState<AssetRow | null>(null);
const categoriesQ = useQuery({
queryKey: ["accounting", tenantId, "asset-categories"],
queryFn: () => accountingApi.assets.listCategories(tenantId!),
enabled: !!tenantId,
});
const assetsQ = useQuery({
queryKey: ["accounting", tenantId, "assets"],
queryFn: () => accountingApi.assets.list(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const scheduleQ = useQuery({
queryKey: ["accounting", tenantId, "asset-schedule", scheduleAsset?.id],
queryFn: () => accountingApi.assets.schedule(tenantId!, scheduleAsset!.id),
enabled: !!tenantId && !!scheduleAsset,
});
const categoryForm = useForm<z.infer<typeof categorySchema>>({
resolver: zodResolver(categorySchema),
defaultValues: { code: "", name: "", default_useful_life_months: 60 },
});
const assetForm = useForm<z.infer<typeof assetSchema>>({
resolver: zodResolver(assetSchema),
defaultValues: { code: "", name: "", category_id: "", acquisition_cost: "0", residual_value: "0" },
});
const depreciateForm = useForm<z.infer<typeof depreciateSchema>>({
resolver: zodResolver(depreciateSchema),
defaultValues: { expense_account_id: "", accumulated_account_id: "" },
});
const postableAccounts = (accountsQ.data ?? []).filter((a) => a.is_postable && a.status === "active");
const invalidateCategories = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "asset-categories"] });
const invalidateAssets = () => qc.invalidateQueries({ queryKey: ["accounting", tenantId, "assets"] });
const createCategoryM = useMutation({
mutationFn: (d: z.infer<typeof categorySchema>) =>
accountingApi.assets.createCategory(tenantId!, {
code: d.code,
name: d.name,
default_useful_life_months: d.default_useful_life_months,
}),
onSuccess: async () => {
toast.success("دسته‌بندی ایجاد شد");
setCategoryOpen(false);
categoryForm.reset();
await invalidateCategories();
},
onError: (e: Error) => toast.error(e.message),
});
const createAssetM = useMutation({
mutationFn: (d: z.infer<typeof assetSchema>) =>
accountingApi.assets.create(tenantId!, {
code: d.code,
name: d.name,
category_id: d.category_id || undefined,
acquisition_cost: d.acquisition_cost,
residual_value: d.residual_value,
}),
onSuccess: async () => {
toast.success("دارایی ایجاد شد");
setAssetOpen(false);
assetForm.reset();
await invalidateAssets();
},
onError: (e: Error) => toast.error(e.message),
});
const activateM = useMutation({
mutationFn: (id: string) => accountingApi.assets.activate(tenantId!, id),
onSuccess: async () => {
toast.success("دارایی فعال شد");
await invalidateAssets();
},
onError: (e: Error) => toast.error(e.message),
});
const depreciateM = useMutation({
mutationFn: (d: z.infer<typeof depreciateSchema>) =>
accountingApi.assets.depreciate(tenantId!, depreciateAsset!.id, d),
onSuccess: async (res) => {
toast.success(`استهلاک ثبت شد — مبلغ ${formatMoney(res.amount)}`);
setDepreciateAsset(null);
depreciateForm.reset();
await invalidateAssets();
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId) return <LoadingState />;
const loading = categoriesQ.isLoading || assetsQ.isLoading || accountsQ.isLoading;
if (loading) return <LoadingState />;
const err = categoriesQ.error || assetsQ.error || accountsQ.error;
if (err) {
return (
<ErrorState
message={err instanceof Error ? err.message : "خطا"}
onRetry={() => {
void categoriesQ.refetch();
void assetsQ.refetch();
void accountsQ.refetch();
}}
/>
);
}
const statusTone = (s: string) =>
s === "active" ? "success" : s === "draft" ? "default" : "warning";
const tabAction =
tab === "categories" ? (
<Button onClick={() => setCategoryOpen(true)}>
<Plus className="h-4 w-4" />
دسته جدید
</Button>
) : (
<Button onClick={() => setAssetOpen(true)}>
<Plus className="h-4 w-4" />
دارایی جدید
</Button>
);
const scheduleRows = (scheduleQ.data?.schedule ?? []) as Record<string, unknown>[];
const scheduleColumns =
scheduleRows.length > 0
? Object.keys(scheduleRows[0]).map((k) => ({ key: k, header: k }))
: [{ key: "period", header: "دوره" }];
return (
<div>
<PageHeader
title="دارایی‌های ثابت"
description="دسته‌بندی، ثبت، فعال‌سازی و استهلاک دارایی."
actions={tabAction}
/>
<Tabs
className="mb-6"
value={tab}
onChange={(id) => setTab(id as TabId)}
items={[
{ id: "assets", label: "دارایی‌ها" },
{ id: "categories", label: "دسته‌بندی‌ها" },
]}
/>
{tab === "categories" && (
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "default_useful_life_months",
header: "عمر مفید (ماه)",
render: (r) => String(r.default_useful_life_months ?? "—"),
},
]}
rows={(categoriesQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState action={<Button onClick={() => setCategoryOpen(true)}>ایجاد دسته</Button>} />
}
/>
)}
{tab === "assets" && (
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "status",
header: "وضعیت",
render: (r) => <Badge tone={statusTone(String(r.status))}>{String(r.status)}</Badge>,
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const row = r as unknown as AssetRow;
return (
<div className="flex flex-wrap gap-1">
{row.status !== "active" && (
<Button
type="button"
size="sm"
variant="outline"
disabled={activateM.isPending}
onClick={() => activateM.mutate(row.id)}
>
فعالسازی
</Button>
)}
{row.status === "active" && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setDepreciateAsset(row);
depreciateForm.reset();
}}
>
استهلاک
</Button>
)}
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setScheduleAsset(row)}
>
<Calendar className="h-3.5 w-3.5" />
برنامه
</Button>
</div>
);
},
},
]}
rows={(assetsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setAssetOpen(true)}>ایجاد دارایی</Button>} />}
/>
)}
<Dialog open={categoryOpen} onClose={() => setCategoryOpen(false)} title="دسته‌بندی جدید">
<form className="space-y-3" onSubmit={categoryForm.handleSubmit((d) => createCategoryM.mutate(d))}>
<FormField label="کد" error={categoryForm.formState.errors.code?.message}>
<Input {...categoryForm.register("code")} />
</FormField>
<FormField label="نام" error={categoryForm.formState.errors.name?.message}>
<Input {...categoryForm.register("name")} />
</FormField>
<FormField label="عمر مفید پیش‌فرض (ماه)">
<Input
type="number"
{...categoryForm.register("default_useful_life_months", { valueAsNumber: true })}
/>
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCategoryOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createCategoryM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={assetOpen} onClose={() => setAssetOpen(false)} title="دارایی جدید">
<form className="space-y-3" onSubmit={assetForm.handleSubmit((d) => createAssetM.mutate(d))}>
<FormField label="کد" error={assetForm.formState.errors.code?.message}>
<Input {...assetForm.register("code")} />
</FormField>
<FormField label="نام" error={assetForm.formState.errors.name?.message}>
<Input {...assetForm.register("name")} />
</FormField>
<FormField label="دسته‌بندی">
<Select {...assetForm.register("category_id")}>
<option value="">بدون دسته</option>
{(categoriesQ.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
</FormField>
<FormField label="بهای تمام‌شده">
<MoneyInput {...assetForm.register("acquisition_cost")} />
</FormField>
<FormField label="ارزش اسقاط">
<MoneyInput {...assetForm.register("residual_value")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setAssetOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createAssetM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog
open={!!depreciateAsset}
onClose={() => setDepreciateAsset(null)}
title="ثبت استهلاک"
description={depreciateAsset ? `${depreciateAsset.code}${depreciateAsset.name}` : undefined}
>
<form className="space-y-3" onSubmit={depreciateForm.handleSubmit((d) => depreciateM.mutate(d))}>
<FormField label="حساب هزینه استهلاک" error={depreciateForm.formState.errors.expense_account_id?.message}>
<Select {...depreciateForm.register("expense_account_id")}>
<option value="">انتخاب حساب</option>
{postableAccounts.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
<FormField
label="حساب استهلاک انباشته"
error={depreciateForm.formState.errors.accumulated_account_id?.message}
>
<Select {...depreciateForm.register("accumulated_account_id")}>
<option value="">انتخاب حساب</option>
{postableAccounts.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDepreciateAsset(null)}>
انصراف
</Button>
<Button type="submit" disabled={depreciateM.isPending}>
ثبت استهلاک
</Button>
</div>
</form>
</Dialog>
<Drawer
open={!!scheduleAsset}
onClose={() => setScheduleAsset(null)}
title="برنامه استهلاک"
description={scheduleAsset ? `${scheduleAsset.code}${scheduleAsset.name}` : undefined}
width="lg"
>
{scheduleQ.isLoading ? (
<LoadingState label="بارگذاری برنامه…" />
) : scheduleQ.error ? (
<ErrorState message={scheduleQ.error.message} onRetry={() => scheduleQ.refetch()} />
) : (
<DataTable
columns={scheduleColumns.map((c) => ({
...c,
render: (r) => {
const v = r[c.key];
if (typeof v === "number" || (typeof v === "string" && /^\d/.test(v))) {
return formatMoney(String(v));
}
return String(v ?? "—");
},
}))}
rows={scheduleRows}
empty={<EmptyState title="برنامه‌ای موجود نیست" />}
/>
)}
</Drawer>
</div>
);
}
export { AssetsPage as default } from "@/src/modules/accounting/pages/assets";

View File

@ -1,5 +1,3 @@
import { AssetSchedulePage } from "@/components/accounting/DomainScreens";
"use client";
export default function Page() {
return <AssetSchedulePage />;
}
export { AssetsSchedulePage as default } from "@/src/modules/accounting/pages/assets/schedule";

View File

@ -1,3 +1,3 @@
"use client";
import { AssetTransfersPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <AssetTransfersPage />; }
export { AssetsTransfersPage as default } from "@/src/modules/accounting/pages/assets/transfers";

View File

@ -1,3 +1,3 @@
"use client";
import { AssetRevaluationsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <AssetRevaluationsPage />; }
export { AssetsValuationPage as default } from "@/src/modules/accounting/pages/assets/valuation";

View File

@ -1,136 +1,3 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
DataTable,
EmptyState,
LoadingState,
ErrorState,
JalaliDateText,
Tabs,
Badge,
} from "@/components/ds";
type TabId = "compliance" | "posting";
export default function AuditPage() {
const { tenantId } = useTenantId();
const [tab, setTab] = useState<TabId>("compliance");
const complianceQ = useQuery({
queryKey: ["accounting", tenantId, "compliance-audit"],
queryFn: () => accountingApi.compliance.listAudit(tenantId!),
enabled: !!tenantId && tab === "compliance",
});
const postingQ = useQuery({
queryKey: ["accounting", tenantId, "posting-audit-logs"],
queryFn: () => accountingApi.vouchers.auditLogs(tenantId!),
enabled: !!tenantId && tab === "posting",
});
const activeQ = tab === "compliance" ? complianceQ : postingQ;
if (!tenantId) return <LoadingState />;
if (activeQ.isLoading) return <LoadingState />;
if (activeQ.error) {
return <ErrorState message={activeQ.error.message} onRetry={() => activeQ.refetch()} />;
}
return (
<div>
<PageHeader
title="حسابرسی و انطباق"
description="رکوردهای Compliance و لاگ‌های ثبت اسناد."
/>
<Tabs
className="mb-6"
value={tab}
onChange={(id) => setTab(id as TabId)}
items={[
{ id: "compliance", label: "رکوردهای انطباق" },
{ id: "posting", label: "لاگ ثبت اسناد" },
]}
/>
{tab === "compliance" && (
<DataTable
columns={[
{
key: "created_at",
header: "زمان",
render: (r) => <JalaliDateText value={String(r.created_at ?? "")} withTime />,
},
{ key: "action", header: "عملیات" },
{ key: "resource_type", header: "منبع" },
{
key: "actor_user_id",
header: "عامل",
render: (r) => (
<span className="font-mono text-xs" dir="ltr">
{String(r.actor_user_id ?? "—")}
</span>
),
},
]}
rows={(complianceQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title="رکورد حسابرسی نیست"
description="پس از عملیات مالی، رکوردها اینجا ظاهر می‌شوند."
/>
}
/>
)}
{tab === "posting" && (
<DataTable
columns={[
{
key: "created_at",
header: "زمان",
render: (r) => <JalaliDateText value={String(r.created_at ?? "")} withTime />,
},
{
key: "operation",
header: "عملیات",
render: (r) => <Badge>{String(r.operation)}</Badge>,
},
{ key: "voucher_number", header: "شماره سند", render: (r) => String(r.voucher_number ?? "—") },
{ key: "resource_type", header: "نوع منبع" },
{
key: "resource_id",
header: "شناسه",
render: (r) => (
<span className="font-mono text-xs" dir="ltr">
{String(r.resource_id ?? "—")}
</span>
),
},
{
key: "actor_user_id",
header: "عامل",
render: (r) => (
<span className="font-mono text-xs" dir="ltr">
{String(r.actor_user_id ?? "—")}
</span>
),
},
]}
rows={(postingQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title="لاگی ثبت نشده"
description="پس از ایجاد، ویرایش یا ثبت اسناد، لاگ‌ها اینجا نمایش داده می‌شوند."
/>
}
/>
)}
</div>
);
}
export { AuditPage as default } from "@/src/modules/accounting/pages/audit";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="کنترل بودجه"
description="فرم تخصصی بودجه متصل به API عملیاتی حسابداری."
module="budget"
docType="control"
createLabel="کنترل جدید"
fields={[
{ key: "period_label", label: "دوره", kind: "text" },
{ key: "budget_type", label: "نوع", kind: "select", options: [
{ value: "control", label: "کنترل بودجه" },
{ value: "operating", label: "عملیاتی" },
{ value: "capital", label: "سرمایه‌ای" },
]},
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "baseline_amount", label: "مبلغ مبنا", kind: "money", required: false },
]}
/>
);
}
export { BudgetControlPage as default } from "@/src/modules/accounting/pages/budget/control";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="تعریف بودجه"
description="ثبت سرفصل بودجه سالانه با مبلغ مصوب (ذخیره عملیاتی تا موتور بودجه اختصاصی)."
module="budget"
docType="definition"
createLabel="بودجه جدید"
fields={[
{ key: "fiscal_year_label", label: "سال مالی", kind: "text" },
{ key: "budget_type", label: "نوع", kind: "select", options: [
{ value: "operating", label: "عملیاتی" },
{ value: "capital", label: "سرمایه‌ای" },
{ value: "cash", label: "نقدی" },
]},
{ key: "amount", label: "مبلغ مصوب", kind: "money" },
{ key: "cost_center_code", label: "کد مرکز هزینه", kind: "text", required: false },
]}
/>
);
}
export { BudgetDefinitionsPage as default } from "@/src/modules/accounting/pages/budget/definitions";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="پیش‌بینی مالی"
description="فرم تخصصی بودجه متصل به API عملیاتی حسابداری."
module="budget"
docType="forecast"
createLabel="پیش‌بینی جدید"
fields={[
{ key: "period_label", label: "دوره", kind: "text" },
{ key: "budget_type", label: "نوع", kind: "select", options: [
{ value: "forecast", label: "پیش‌بینی مالی" },
{ value: "operating", label: "عملیاتی" },
{ value: "capital", label: "سرمایه‌ای" },
]},
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "baseline_amount", label: "مبلغ مبنا", kind: "money", required: false },
]}
/>
);
}
export { BudgetForecastPage as default } from "@/src/modules/accounting/pages/budget/forecast";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="بودجه‌ریزی عملیاتی"
description="فرم تخصصی بودجه متصل به API عملیاتی حسابداری."
module="budget"
docType="operational"
createLabel="بودجه عملیاتی جدید"
fields={[
{ key: "period_label", label: "دوره", kind: "text" },
{ key: "budget_type", label: "نوع", kind: "select", options: [
{ value: "operating", label: "بودجه‌ریزی عملیاتی" },
{ value: "operating", label: "عملیاتی" },
{ value: "capital", label: "سرمایه‌ای" },
]},
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "baseline_amount", label: "مبلغ مبنا", kind: "money", required: false },
]}
/>
);
}
export { BudgetOperationalPage as default } from "@/src/modules/accounting/pages/budget/operational";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="مقایسه عملکرد"
description="فرم تخصصی بودجه متصل به API عملیاتی حسابداری."
module="budget"
docType="performance"
createLabel="مقایسه جدید"
fields={[
{ key: "period_label", label: "دوره", kind: "text" },
{ key: "budget_type", label: "نوع", kind: "select", options: [
{ value: "performance", label: "مقایسه عملکرد" },
{ value: "operating", label: "عملیاتی" },
{ value: "capital", label: "سرمایه‌ای" },
]},
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "baseline_amount", label: "مبلغ مبنا", kind: "money", required: false },
]}
/>
);
}
export { BudgetPerformancePage as default } from "@/src/modules/accounting/pages/budget/performance";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="گزارش بودجه"
description="فرم تخصصی بودجه متصل به API عملیاتی حسابداری."
module="budget"
docType="report"
createLabel="گزارش جدید"
fields={[
{ key: "period_label", label: "دوره", kind: "text" },
{ key: "budget_type", label: "نوع", kind: "select", options: [
{ value: "report", label: "گزارش بودجه" },
{ value: "operating", label: "عملیاتی" },
{ value: "capital", label: "سرمایه‌ای" },
]},
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "baseline_amount", label: "مبلغ مبنا", kind: "money", required: false },
]}
/>
);
}
export { BudgetReportsPage as default } from "@/src/modules/accounting/pages/budget/reports";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="سناریوهای مالی"
description="فرم تخصصی بودجه متصل به API عملیاتی حسابداری."
module="budget"
docType="scenario"
createLabel="سناریو جدید"
fields={[
{ key: "period_label", label: "دوره", kind: "text" },
{ key: "budget_type", label: "نوع", kind: "select", options: [
{ value: "scenario", label: "سناریوهای مالی" },
{ value: "operating", label: "عملیاتی" },
{ value: "capital", label: "سرمایه‌ای" },
]},
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "baseline_amount", label: "مبلغ مبنا", kind: "money", required: false },
]}
/>
);
}
export { BudgetScenariosPage as default } from "@/src/modules/accounting/pages/budget/scenarios";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="تحلیل انحراف بودجه"
description="فرم تخصصی بودجه متصل به API عملیاتی حسابداری."
module="budget"
docType="variance"
createLabel="تحلیل جدید"
fields={[
{ key: "period_label", label: "دوره", kind: "text" },
{ key: "budget_type", label: "نوع", kind: "select", options: [
{ value: "variance", label: "تحلیل انحراف بودجه" },
{ value: "operating", label: "عملیاتی" },
{ value: "capital", label: "سرمایه‌ای" },
]},
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "baseline_amount", label: "مبلغ مبنا", kind: "money", required: false },
]}
/>
);
}
export { BudgetVariancePage as default } from "@/src/modules/accounting/pages/budget/variance";

View File

@ -1,644 +1,3 @@
"use client";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Archive, Pencil } from "lucide-react";
import { accountingApi, type Account, type ChartOfAccounts } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
Input,
Label,
FieldError,
Textarea,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Select,
Drawer,
TableToolbar,
Tabs,
Checkbox,
} from "@/components/ds";
const chartSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
description: z.string().optional(),
is_default: z.boolean().optional(),
});
const accountSchema = z.object({
chart_id: z.string().uuid("دفتر حساب را انتخاب کنید"),
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
account_type: z.string().min(1),
account_category: z.string().min(1),
parent_id: z.string().optional(),
is_group: z.boolean().optional(),
is_postable: z.boolean().optional(),
description: z.string().optional(),
});
type ChartForm = z.infer<typeof chartSchema>;
const editChartSchema = chartSchema.omit({ code: true }).extend({ code: z.string().optional() });
type EditChartForm = z.infer<typeof editChartSchema>;
type AccountForm = z.infer<typeof accountSchema>;
const ACCOUNT_TYPES = [
{ value: "asset", label: "دارایی" },
{ value: "liability", label: "بدهی" },
{ value: "equity", label: "حقوق صاحبان سهام" },
{ value: "revenue", label: "درآمد" },
{ value: "expense", label: "هزینه" },
];
const ACCOUNT_CATEGORIES = [
{ value: "current_asset", label: "دارایی جاری" },
{ value: "non_current_asset", label: "دارایی غیرجاری" },
{ value: "current_liability", label: "بدهی جاری" },
{ value: "non_current_liability", label: "بدهی غیرجاری" },
{ value: "equity", label: "حقوق صاحبان سهام" },
{ value: "operating_revenue", label: "درآمد عملیاتی" },
{ value: "other_revenue", label: "سایر درآمدها" },
{ value: "operating_expense", label: "هزینه عملیاتی" },
{ value: "other_expense", label: "سایر هزینه‌ها" },
];
export default function ChartOfAccountsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [tab, setTab] = useState("accounts");
const [chartOpen, setChartOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
const [editChart, setEditChart] = useState<ChartOfAccounts | null>(null);
const [selected, setSelected] = useState<Account | null>(null);
const [filterChart, setFilterChart] = useState<string>("");
const [search, setSearch] = useState("");
const [showInactive, setShowInactive] = useState(false);
const chartsQ = useQuery({
queryKey: ["accounting", tenantId, "charts"],
queryFn: () => accountingApi.charts.list(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts", filterChart],
queryFn: () => accountingApi.accounts.list(tenantId!, 1, 500, filterChart || undefined),
enabled: !!tenantId,
});
const chartForm = useForm<ChartForm>({
resolver: zodResolver(chartSchema),
defaultValues: { code: "", name: "", description: "", is_default: false },
});
const accountForm = useForm<AccountForm>({
resolver: zodResolver(accountSchema),
defaultValues: {
chart_id: "",
code: "",
name: "",
account_type: "asset",
account_category: "current_asset",
parent_id: "",
is_group: false,
is_postable: true,
description: "",
},
});
const editChartForm = useForm<EditChartForm>({
resolver: zodResolver(editChartSchema),
});
const editAccountForm = useForm({
defaultValues: { name: "", status: "active", description: "", is_postable: true },
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "charts"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "accounts"] });
};
const createChart = useMutation({
mutationFn: (data: ChartForm) => accountingApi.charts.create(tenantId!, data),
onSuccess: async () => {
toast.success("دفتر حساب ایجاد شد");
setChartOpen(false);
chartForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateChart = useMutation({
mutationFn: (data: ChartForm) =>
accountingApi.charts.update(tenantId!, editChart!.id, {
name: data.name,
description: data.description,
is_default: data.is_default,
}),
onSuccess: async () => {
toast.success("دفتر به‌روز شد");
setEditChart(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveChart = useMutation({
mutationFn: (id: string) => accountingApi.charts.archive(tenantId!, id),
onSuccess: async () => {
toast.success("دفتر آرشیو شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const createAccount = useMutation({
mutationFn: (data: AccountForm) =>
accountingApi.accounts.create(tenantId!, {
...data,
parent_id: data.parent_id || null,
is_postable: data.is_postable ?? true,
is_group: data.is_group ?? false,
}),
onSuccess: async () => {
toast.success("حساب ایجاد شد");
setAccountOpen(false);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateAccount = useMutation({
mutationFn: (body: { name: string; status: string; description?: string; is_postable: boolean }) =>
accountingApi.accounts.update(tenantId!, selected!.id, body),
onSuccess: async () => {
toast.success("حساب به‌روز شد");
setSelected(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveAccount = useMutation({
mutationFn: (id: string) => accountingApi.accounts.archive(tenantId!, id),
onSuccess: async () => {
toast.success("حساب آرشیو شد");
setSelected(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const rows = useMemo(() => {
let list = accountsQ.data ?? [];
if (!showInactive) list = list.filter((a) => a.status === "active");
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter(
(a) => a.code.toLowerCase().includes(q) || a.name.toLowerCase().includes(q)
);
}
return [...list].sort((a, b) => a.code.localeCompare(b.code, "fa"));
}, [accountsQ.data, search, showInactive]);
if (!tenantId) return <LoadingState />;
if (chartsQ.isLoading || accountsQ.isLoading) return <LoadingState />;
if (chartsQ.error || accountsQ.error) {
return (
<ErrorState
message={(chartsQ.error || accountsQ.error)?.message || "خطا"}
onRetry={() => {
void chartsQ.refetch();
void accountsQ.refetch();
}}
/>
);
}
const chartName = (id: string) => chartsQ.data?.find((c) => c.id === id)?.name ?? id;
return (
<div>
<PageHeader
title="دفتر حساب‌ها"
description="درخت حساب‌ها، ویرایش، آرشیو و جزئیات — API واقعی."
actions={
<>
<Button type="button" variant="outline" onClick={() => setChartOpen(true)}>
<Plus className="h-4 w-4" />
دفتر جدید
</Button>
<Button
type="button"
onClick={() => {
if (chartsQ.data?.[0]) accountForm.setValue("chart_id", filterChart || chartsQ.data[0].id);
setAccountOpen(true);
}}
disabled={!chartsQ.data?.length}
>
<Plus className="h-4 w-4" />
حساب جدید
</Button>
</>
}
/>
<Tabs
className="mb-4"
value={tab}
onChange={setTab}
items={[
{ id: "accounts", label: "حساب‌ها" },
{ id: "charts", label: "دفاتر" },
]}
/>
{tab === "charts" ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{chartsQ.data?.map((c) => (
<div key={c.id} className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-4">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold text-secondary">{c.name}</p>
<p className="mt-1 text-sm text-[var(--muted)]" dir="ltr">
{c.code}
</p>
</div>
<div className="flex flex-col items-end gap-1">
{c.is_default ? <Badge tone="primary">پیشفرض</Badge> : null}
<Badge tone={c.is_active ? "success" : "default"}>
{c.is_active ? "فعال" : "آرشیو"}
</Badge>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditChart(c);
editChartForm.reset({
code: c.code,
name: c.name,
description: c.description ?? "",
is_default: c.is_default,
});
}}
>
<Pencil className="h-3.5 w-3.5" />
ویرایش
</Button>
{c.is_active ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => archiveChart.mutate(c.id)}
>
<Archive className="h-3.5 w-3.5" />
آرشیو
</Button>
) : null}
<Button type="button" size="sm" variant="ghost" onClick={() => { setFilterChart(c.id); setTab("accounts"); }}>
مشاهده حسابها
</Button>
</div>
</div>
))}
{!chartsQ.data?.length ? (
<EmptyState
title="دفتری ثبت نشده"
description="ابتدا یک دفتر حساب بسازید یا از راه‌اندازی قالب وارد کنید."
action={
<Button type="button" onClick={() => setChartOpen(true)}>
ایجاد دفتر
</Button>
}
/>
) : null}
</div>
) : (
<>
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="جستجوی کد یا نام…"
actions={
<>
<Select
value={filterChart}
onChange={(e) => setFilterChart(e.target.value)}
className="w-44"
>
<option value="">همه دفاتر</option>
{chartsQ.data?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Checkbox
label="نمایش غیرفعال"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
/>
</>
}
/>
{/* Mobile cards */}
<div className="mb-4 space-y-2 md:hidden">
{rows.map((r) => (
<button
key={r.id}
type="button"
className="w-full rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-3 text-right"
onClick={() => {
setSelected(r);
editAccountForm.reset({
name: r.name,
status: r.status,
description: r.description ?? "",
is_postable: r.is_postable,
});
}}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-secondary">{r.name}</span>
<Badge tone={r.status === "active" ? "success" : "default"}>{r.status}</Badge>
</div>
<p className="mt-1 text-xs text-[var(--muted)]" dir="ltr">
{r.code} · {chartName(r.chart_id)}
</p>
</button>
))}
{!rows.length ? <EmptyState title="حسابی نیست" description="حساب جدید اضافه کنید." /> : null}
</div>
<div className="hidden md:block">
<DataTable
columns={[
{
key: "code",
header: "کد",
render: (r) => (
<span style={{ paddingInlineStart: `${(Number(r.level) - 1) * 12}px` }} dir="ltr">
{String(r.code)}
</span>
),
},
{ key: "name", header: "نام" },
{
key: "chart_id",
header: "دفتر",
render: (r) => chartName(String(r.chart_id)),
},
{
key: "account_type",
header: "نوع",
render: (r) =>
ACCOUNT_TYPES.find((t) => t.value === r.account_type)?.label ?? String(r.account_type),
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={r.status === "active" ? "success" : "default"}>{String(r.status)}</Badge>
),
},
{
key: "id",
header: "",
render: (r) => (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
const acc = r as unknown as Account;
setSelected(acc);
editAccountForm.reset({
name: acc.name,
status: acc.status,
description: acc.description ?? "",
is_postable: acc.is_postable,
});
}}
>
جزئیات
</Button>
),
},
]}
rows={rows as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title="حسابی ثبت نشده"
description="ابتدا دفتر بسازید یا از قالب وارد کنید."
action={
<Button type="button" onClick={() => setChartOpen(true)}>
ایجاد دفتر حساب
</Button>
}
/>
}
/>
</div>
</>
)}
<Dialog open={chartOpen} onClose={() => setChartOpen(false)} title="ایجاد دفتر حساب">
<form className="space-y-3" onSubmit={chartForm.handleSubmit((d) => createChart.mutate(d))}>
<div>
<Label htmlFor="chart-code">کد</Label>
<Input id="chart-code" {...chartForm.register("code")} />
<FieldError>{chartForm.formState.errors.code?.message}</FieldError>
</div>
<div>
<Label htmlFor="chart-name">نام</Label>
<Input id="chart-name" {...chartForm.register("name")} />
<FieldError>{chartForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label htmlFor="chart-desc">توضیح</Label>
<Textarea id="chart-desc" {...chartForm.register("description")} />
</div>
<Checkbox label="دفتر پیش‌فرض" {...chartForm.register("is_default")} />
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setChartOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createChart.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={!!editChart} onClose={() => setEditChart(null)} title="ویرایش دفتر حساب">
<form className="space-y-3" onSubmit={editChartForm.handleSubmit((d) => updateChart.mutate(d as ChartForm))}>
<div>
<Label>کد</Label>
<Input disabled value={editChart?.code ?? ""} />
</div>
<div>
<Label htmlFor="edit-chart-name">نام</Label>
<Input id="edit-chart-name" {...editChartForm.register("name")} />
</div>
<div>
<Label>توضیح</Label>
<Textarea {...editChartForm.register("description")} />
</div>
<Checkbox label="دفتر پیش‌فرض" {...editChartForm.register("is_default")} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditChart(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateChart.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={accountOpen} onClose={() => setAccountOpen(false)} title="ایجاد حساب" size="lg">
<form
className="grid gap-3 sm:grid-cols-2"
onSubmit={accountForm.handleSubmit((d) => createAccount.mutate(d))}
>
<div className="sm:col-span-2">
<Label>دفتر حساب</Label>
<Select {...accountForm.register("chart_id")}>
<option value="">انتخاب کنید</option>
{chartsQ.data?.filter((c) => c.is_active).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
<FieldError>{accountForm.formState.errors.chart_id?.message}</FieldError>
</div>
<div>
<Label>کد</Label>
<Input {...accountForm.register("code")} />
<FieldError>{accountForm.formState.errors.code?.message}</FieldError>
</div>
<div>
<Label>نام</Label>
<Input {...accountForm.register("name")} />
<FieldError>{accountForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label>نوع</Label>
<Select {...accountForm.register("account_type")}>
{ACCOUNT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
</div>
<div>
<Label>دسته</Label>
<Select {...accountForm.register("account_category")}>
{ACCOUNT_CATEGORIES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
</div>
<div className="sm:col-span-2">
<Label>حساب والد</Label>
<Select {...accountForm.register("parent_id")}>
<option value="">بدون والد</option>
{(accountsQ.data ?? [])
.filter((a) => a.status === "active")
.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</div>
<Checkbox label="گروه حساب" {...accountForm.register("is_group")} />
<Checkbox label="قابل ثبت سند" {...accountForm.register("is_postable")} />
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setAccountOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createAccount.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Drawer
open={!!selected}
onClose={() => setSelected(null)}
title={selected ? `${selected.code}${selected.name}` : "حساب"}
description={selected ? chartName(selected.chart_id) : undefined}
>
{selected ? (
<form
className="space-y-3"
onSubmit={editAccountForm.handleSubmit((d) =>
updateAccount.mutate({
name: d.name,
status: d.status,
description: d.description,
is_postable: d.is_postable,
})
)}
>
<div>
<Label>نام</Label>
<Input {...editAccountForm.register("name")} />
</div>
<div>
<Label>وضعیت</Label>
<Select {...editAccountForm.register("status")}>
<option value="active">active</option>
<option value="inactive">inactive</option>
<option value="closed">closed</option>
</Select>
</div>
<div>
<Label>توضیح</Label>
<Textarea {...editAccountForm.register("description")} />
</div>
<Checkbox label="قابل ثبت سند" {...editAccountForm.register("is_postable")} />
<p className="text-xs text-[var(--muted)]">
نوع: {selected.account_type} · دسته: {selected.account_category} · سطح: {selected.level}
</p>
<div className="flex flex-wrap gap-2 pt-2">
<Button type="submit" disabled={updateAccount.isPending}>
ذخیره تغییرات
</Button>
{selected.status === "active" ? (
<Button
type="button"
variant="outline"
onClick={() => archiveAccount.mutate(selected.id)}
>
آرشیو
</Button>
) : null}
</div>
</form>
) : null}
</Drawer>
</div>
);
}
export { ChartOfAccountsPage as default } from "@/src/modules/accounting/pages/chart-of-accounts";

View File

@ -1,3 +1,3 @@
"use client";
import { ComplianceApprovalsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <ComplianceApprovalsPage />; }
export { ComplianceApprovalsPage as default } from "@/src/modules/accounting/pages/compliance/approvals";

View File

@ -1,3 +1,3 @@
"use client";
import { ComplianceControlsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <ComplianceControlsPage />; }
export { ComplianceControlsPage as default } from "@/src/modules/accounting/pages/compliance/controls";

View File

@ -1,5 +1,3 @@
import { CompliancePoliciesPage } from "@/components/accounting/DomainScreens";
"use client";
export default function Page() {
return <CompliancePoliciesPage />;
}
export { CompliancePoliciesPage as default } from "@/src/modules/accounting/pages/compliance/policies";

View File

@ -1,3 +1,3 @@
"use client";
import { ComplianceRecordsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <ComplianceRecordsPage />; }
export { ComplianceRecordsPage as default } from "@/src/modules/accounting/pages/compliance/records";

View File

@ -1,5 +1,3 @@
import { ComplianceRisksPage } from "@/components/accounting/DomainScreens";
"use client";
export default function Page() {
return <ComplianceRisksPage />;
}
export { ComplianceRisksPage as default } from "@/src/modules/accounting/pages/compliance/risks";

View File

@ -1,3 +1,3 @@
"use client";
import { ComplianceSodPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <ComplianceSodPage />; }
export { ComplianceSodPage as default } from "@/src/modules/accounting/pages/compliance/sod";

View File

@ -1,3 +1,3 @@
"use client";
import { ComplianceViolationsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <ComplianceViolationsPage />; }
export { ComplianceViolationsPage as default } from "@/src/modules/accounting/pages/compliance/violations";

View File

@ -1,271 +1,3 @@
"use client";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Pencil, Archive } from "lucide-react";
import { accountingApi, type CostCenter } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
ConfirmDialog,
Input,
Label,
FieldError,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Drawer,
Select,
} from "@/components/ds";
const createSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
parent_id: z.string().optional(),
});
const editSchema = z.object({
name: z.string().min(1, "نام الزامی است"),
parent_id: z.string().optional(),
});
export default function CostCentersPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [selected, setSelected] = useState<CostCenter | null>(null);
const [archiveId, setArchiveId] = useState<string | null>(null);
const createForm = useForm<z.infer<typeof createSchema>>({
resolver: zodResolver(createSchema),
defaultValues: { code: "", name: "", parent_id: "" },
});
const editForm = useForm<z.infer<typeof editSchema>>({
resolver: zodResolver(editSchema),
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "cost-centers"],
queryFn: () => accountingApi.costCenters.list(tenantId!),
enabled: !!tenantId,
});
const activeCenters = useMemo(
() => (listQ.data ?? []).filter((c) => c.is_active),
[listQ.data]
);
const parentName = (id: string | null | undefined) => {
if (!id) return "—";
return listQ.data?.find((c) => c.id === id)?.name ?? id;
};
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "cost-centers"] });
};
const createM = useMutation({
mutationFn: (d: z.infer<typeof createSchema>) =>
accountingApi.costCenters.create(tenantId!, {
code: d.code,
name: d.name,
parent_id: d.parent_id || null,
}),
onSuccess: async () => {
toast.success("مرکز هزینه ایجاد شد");
setCreateOpen(false);
createForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: z.infer<typeof editSchema>) =>
accountingApi.costCenters.update(tenantId!, selected!.id, {
name: d.name,
parent_id: d.parent_id || null,
}),
onSuccess: async () => {
toast.success("مرکز هزینه به‌روز شد");
setSelected(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveM = useMutation({
mutationFn: (id: string) => accountingApi.costCenters.archive(tenantId!, id),
onSuccess: async () => {
toast.success("مرکز هزینه آرشیو شد");
setArchiveId(null);
setSelected(null);
await invalidate();
},
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()} />;
const parentOptions = (excludeId?: string) =>
activeCenters.filter((c) => c.id !== excludeId);
return (
<div>
<PageHeader
title="مراکز هزینه"
description="تعریف، سلسله‌مراتب و آرشیو مراکز هزینه."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
مرکز جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "parent_id",
header: "والد",
render: (r) => parentName(r.parent_id as string | null | undefined),
},
{
key: "is_active",
header: "وضعیت",
render: (r) => (
<Badge tone={r.is_active ? "success" : "default"}>
{r.is_active ? "فعال" : "آرشیو"}
</Badge>
),
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const id = String(r.id);
const active = Boolean(r.is_active);
return active ? (
<div className="flex flex-wrap gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const c = listQ.data?.find((x) => x.id === id);
if (c) {
setSelected(c);
editForm.reset({ name: c.name, parent_id: c.parent_id ?? "" });
}
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setArchiveId(id)}>
<Archive className="h-3.5 w-3.5" />
</Button>
</div>
) : (
"—"
);
},
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState title="مرکز هزینه‌ای ثبت نشده" action={<Button onClick={() => setCreateOpen(true)}>ایجاد</Button>} />
}
/>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="مرکز هزینه جدید">
<form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}>
<div>
<Label>کد</Label>
<Input {...createForm.register("code")} />
<FieldError>{createForm.formState.errors.code?.message}</FieldError>
</div>
<div>
<Label>نام</Label>
<Input {...createForm.register("name")} />
<FieldError>{createForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label>مرکز والد (اختیاری)</Label>
<Select {...createForm.register("parent_id")}>
<option value="">بدون والد</option>
{parentOptions().map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Drawer
open={!!selected}
onClose={() => setSelected(null)}
title="ویرایش مرکز هزینه"
description={selected ? `${selected.code}` : undefined}
>
<form className="space-y-4" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...editForm.register("name")} />
<FieldError>{editForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label>مرکز والد (اختیاری)</Label>
<Select {...editForm.register("parent_id")}>
<option value="">بدون والد</option>
{parentOptions(selected?.id).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setSelected(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateM.isPending}>
ذخیره
</Button>
</div>
</form>
</Drawer>
<ConfirmDialog
open={!!archiveId}
onClose={() => setArchiveId(null)}
onConfirm={() => archiveId && archiveM.mutate(archiveId)}
title="آرشیو مرکز هزینه؟"
description="مرکز آرشیو شده در اسناد جدید قابل انتخاب نیست."
confirmLabel="آرشیو"
danger
loading={archiveM.isPending}
/>
</div>
);
}
export { CostCentersPage as default } from "@/src/modules/accounting/pages/cost-centers";

View File

@ -1,255 +1,3 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Pencil, Archive, Star } from "lucide-react";
import { accountingApi, type Currency } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
ConfirmDialog,
Input,
Label,
FieldError,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Drawer,
Checkbox,
} from "@/components/ds";
const createSchema = z.object({
code: z.string().length(3, "کد ارز ۳ حرفی است"),
name: z.string().min(1, "نام الزامی است"),
symbol: z.string().optional(),
is_base: z.boolean().optional(),
});
const editSchema = z.object({
name: z.string().min(1, "نام الزامی است"),
symbol: z.string().optional(),
});
export default function CurrenciesPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [selected, setSelected] = useState<Currency | null>(null);
const [archiveId, setArchiveId] = useState<string | null>(null);
const createForm = useForm<z.infer<typeof createSchema>>({
resolver: zodResolver(createSchema),
defaultValues: { code: "IRR", name: "ریال", symbol: "﷼", is_base: false },
});
const editForm = useForm<z.infer<typeof editSchema>>({
resolver: zodResolver(editSchema),
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "currencies"],
queryFn: () => accountingApi.currencies.list(tenantId!),
enabled: !!tenantId,
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "currencies"] });
};
const createM = useMutation({
mutationFn: (d: z.infer<typeof createSchema>) => accountingApi.currencies.create(tenantId!, d),
onSuccess: async () => {
toast.success("ارز ایجاد شد");
setCreateOpen(false);
createForm.reset({ code: "", name: "", symbol: "", is_base: false });
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: z.infer<typeof editSchema>) =>
accountingApi.currencies.update(tenantId!, selected!.id, d),
onSuccess: async () => {
toast.success("ارز به‌روز شد");
setSelected(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const setBaseM = useMutation({
mutationFn: (id: string) => accountingApi.currencies.setBase(tenantId!, id),
onSuccess: async () => {
toast.success("ارز پایه تنظیم شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveM = useMutation({
mutationFn: (id: string) => accountingApi.currencies.archive(tenantId!, id),
onSuccess: async () => {
toast.success("ارز آرشیو شد");
setArchiveId(null);
await invalidate();
},
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="ارزها"
description="مدیریت ارزهای فعال، ارز پایه و آرشیو."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
ارز جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "symbol", header: "نماد" },
{
key: "is_base",
header: "پایه",
render: (r) => (r.is_base ? <Badge tone="primary">پایه</Badge> : "—"),
},
{
key: "is_active",
header: "فعال",
render: (r) =>
r.is_active ? <Badge tone="success">فعال</Badge> : <Badge tone="default">آرشیو</Badge>,
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const id = String(r.id);
const active = Boolean(r.is_active);
const isBase = Boolean(r.is_base);
return (
<div className="flex flex-wrap gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const c = listQ.data?.find((x) => x.id === id);
if (c) {
setSelected(c);
editForm.reset({ name: c.name, symbol: c.symbol ?? "" });
}
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{active && !isBase ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={setBaseM.isPending}
onClick={() => setBaseM.mutate(id)}
>
<Star className="h-3.5 w-3.5" />
پایه
</Button>
) : null}
{active && !isBase ? (
<Button type="button" size="sm" variant="ghost" onClick={() => setArchiveId(id)}>
<Archive className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
);
},
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="ارزی ثبت نشده" action={<Button onClick={() => setCreateOpen(true)}>ایجاد ارز</Button>} />}
/>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="ایجاد ارز">
<form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}>
<div>
<Label>کد</Label>
<Input {...createForm.register("code")} dir="ltr" placeholder="IRR" />
<FieldError>{createForm.formState.errors.code?.message}</FieldError>
</div>
<div>
<Label>نام</Label>
<Input {...createForm.register("name")} />
<FieldError>{createForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label>نماد</Label>
<Input {...createForm.register("symbol")} />
</div>
<Checkbox label="ارز پایه" {...createForm.register("is_base")} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Drawer
open={!!selected}
onClose={() => setSelected(null)}
title="ویرایش ارز"
description={selected ? `${selected.code}${selected.name}` : undefined}
>
<form className="space-y-4" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...editForm.register("name")} />
<FieldError>{editForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label>نماد</Label>
<Input {...editForm.register("symbol")} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setSelected(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateM.isPending}>
ذخیره
</Button>
</div>
</form>
</Drawer>
<ConfirmDialog
open={!!archiveId}
onClose={() => setArchiveId(null)}
onConfirm={() => archiveId && archiveM.mutate(archiveId)}
title="آرشیو ارز؟"
description="ارز آرشیو شده دیگر در تراکنش‌های جدید قابل انتخاب نیست."
confirmLabel="آرشیو"
danger
loading={archiveM.isPending}
/>
</div>
);
}
export { CurrenciesPage as default } from "@/src/modules/accounting/pages/currencies";

View File

@ -1,409 +1,3 @@
"use client";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Eye, Pencil, Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Drawer,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
FormField,
MoneyInput,
Checkbox,
DatePicker,
JalaliDateText,
StatCard,
} from "@/components/ds";
const createSchema = z.object({
code: z.string().optional(),
name: z.string().min(1, "نام الزامی است"),
credit_limit: z.string().optional(),
});
const editSchema = z.object({
name: z.string().min(1, "نام الزامی است"),
credit_limit: z.string().optional(),
is_active: z.boolean(),
});
const invoiceSchema = z.object({
invoice_number: z.string().optional(),
invoice_date: z.string().min(1, "تاریخ الزامی است"),
due_date: z.string().optional(),
total_amount: z.string().min(1, "مبلغ الزامی است"),
});
type CustomerRow = {
id: string;
code: string;
name: string;
outstanding_balance: string;
is_active: boolean;
};
export default function CustomersPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editCustomer, setEditCustomer] = useState<CustomerRow | null>(null);
const [detailCustomer, setDetailCustomer] = useState<CustomerRow | null>(null);
const [invoiceOpen, setInvoiceOpen] = useState(false);
const listQ = useQuery({
queryKey: ["accounting", tenantId, "customers"],
queryFn: () => accountingApi.customers.list(tenantId!),
enabled: !!tenantId,
});
const agingQ = useQuery({
queryKey: ["accounting", tenantId, "customer-aging", detailCustomer?.id],
queryFn: () => accountingApi.customers.aging(tenantId!, detailCustomer!.id),
enabled: !!tenantId && !!detailCustomer,
});
const invoicesQ = useQuery({
queryKey: ["accounting", tenantId, "customer-invoices", detailCustomer?.id],
queryFn: () => accountingApi.customers.listInvoices(tenantId!, detailCustomer!.id),
enabled: !!tenantId && !!detailCustomer,
});
const createForm = useForm<z.infer<typeof createSchema>>({
resolver: zodResolver(createSchema),
defaultValues: { code: "", name: "", credit_limit: "" },
});
const editForm = useForm<z.infer<typeof editSchema>>({
resolver: zodResolver(editSchema),
defaultValues: { name: "", credit_limit: "", is_active: true },
});
const invoiceForm = useForm<z.infer<typeof invoiceSchema>>({
resolver: zodResolver(invoiceSchema),
defaultValues: { invoice_number: "", invoice_date: "", due_date: "", total_amount: "" },
});
const invalidateCustomers = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "customers"] });
const invalidateInvoices = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "customer-invoices"] });
const createM = useMutation({
mutationFn: (d: z.infer<typeof createSchema>) =>
accountingApi.customers.create(tenantId!, {
code: d.code?.trim() || undefined,
name: d.name,
credit_limit: d.credit_limit || undefined,
}),
onSuccess: async () => {
toast.success("مشتری ایجاد شد");
setCreateOpen(false);
createForm.reset();
await invalidateCustomers();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: z.infer<typeof editSchema>) =>
accountingApi.customers.update(tenantId!, editCustomer!.id, {
name: d.name,
credit_limit: d.credit_limit || null,
is_active: d.is_active,
}),
onSuccess: async () => {
toast.success("مشتری به‌روز شد");
setEditCustomer(null);
await invalidateCustomers();
},
onError: (e: Error) => toast.error(e.message),
});
const createInvoiceM = useMutation({
mutationFn: (d: z.infer<typeof invoiceSchema>) =>
accountingApi.customers.createInvoice(tenantId!, {
customer_id: detailCustomer!.id,
invoice_number: d.invoice_number?.trim() || undefined,
invoice_date: d.invoice_date,
due_date: d.due_date || undefined,
total_amount: d.total_amount,
}),
onSuccess: async () => {
toast.success("فاکتور ثبت شد");
setInvoiceOpen(false);
invoiceForm.reset();
await invalidateInvoices();
await invalidateCustomers();
if (detailCustomer) {
await qc.invalidateQueries({
queryKey: ["accounting", tenantId, "customer-aging", detailCustomer.id],
});
}
},
onError: (e: Error) => toast.error(e.message),
});
const openEdit = (row: CustomerRow) => {
setEditCustomer(row);
editForm.reset({
name: row.name,
credit_limit: "",
is_active: row.is_active,
});
};
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) {
return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
}
const aging = agingQ.data;
return (
<div>
<PageHeader
title="مشتریان"
description="حساب‌های دریافتنی — مدیریت مشتری، سررسید و فاکتور."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
مشتری جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "outstanding_balance",
header: "مانده",
render: (r) => formatMoney(String(r.outstanding_balance)),
},
{
key: "is_active",
header: "وضعیت",
render: (r) => (
<Badge tone={r.is_active ? "success" : "default"}>
{r.is_active ? "فعال" : "غیرفعال"}
</Badge>
),
},
{
key: "actions",
header: "عملیات",
render: (r) => (
<div className="flex flex-wrap gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDetailCustomer(r as unknown as CustomerRow)}
>
<Eye className="h-3.5 w-3.5" />
جزئیات
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => openEdit(r as unknown as CustomerRow)}
>
<Pencil className="h-3.5 w-3.5" />
ویرایش
</Button>
</div>
),
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setCreateOpen(true)}>ایجاد مشتری</Button>} />}
/>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="مشتری جدید">
<form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}>
<FormField label="کد (اختیاری — خودکار)" error={createForm.formState.errors.code?.message}>
<Input {...createForm.register("code")} placeholder="خودکار" />
</FormField>
<FormField label="نام" error={createForm.formState.errors.name?.message}>
<Input {...createForm.register("name")} />
</FormField>
<FormField label="سقف اعتبار">
<MoneyInput {...createForm.register("credit_limit")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Drawer
open={!!editCustomer}
onClose={() => setEditCustomer(null)}
title="ویرایش مشتری"
description={editCustomer ? `${editCustomer.code}${editCustomer.name}` : undefined}
>
<form className="space-y-4" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
<FormField label="نام" error={editForm.formState.errors.name?.message}>
<Input {...editForm.register("name")} />
</FormField>
<FormField label="سقف اعتبار">
<MoneyInput {...editForm.register("credit_limit")} />
</FormField>
<Checkbox label="فعال" {...editForm.register("is_active")} />
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setEditCustomer(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateM.isPending}>
ذخیره
</Button>
</div>
</form>
</Drawer>
<Drawer
open={!!detailCustomer}
onClose={() => {
setDetailCustomer(null);
setInvoiceOpen(false);
}}
title={detailCustomer?.name ?? "جزئیات مشتری"}
description={detailCustomer ? `کد: ${detailCustomer.code}` : undefined}
width="lg"
>
{detailCustomer && (
<div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-2">
<StatCard label="مانده کل" value={formatMoney(detailCustomer.outstanding_balance)} />
{agingQ.isLoading ? (
<p className="text-sm text-[var(--muted)]">در حال بارگذاری سررسید</p>
) : agingQ.error ? (
<p className="text-sm text-red-600">{agingQ.error.message}</p>
) : aging ? (
<>
<StatCard label="جاری" value={formatMoney(aging.current)} />
<StatCard label="۱–۳۰ روز" value={formatMoney(aging.days_1_30)} />
<StatCard label="۳۱–۶۰ روز" value={formatMoney(aging.days_31_60)} />
<StatCard label="۶۱–۹۰ روز" value={formatMoney(aging.days_61_90)} />
<StatCard label="۹۰+ روز" value={formatMoney(aging.days_90_plus)} />
<StatCard label="مجموع سررسید" value={formatMoney(aging.total)} />
</>
) : null}
</div>
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-secondary">فاکتورهای دریافتنی</h3>
<Button type="button" size="sm" onClick={() => setInvoiceOpen(true)}>
<Plus className="h-3.5 w-3.5" />
فاکتور جدید
</Button>
</div>
{invoicesQ.isLoading ? (
<LoadingState label="بارگذاری فاکتورها…" />
) : invoicesQ.error ? (
<ErrorState message={invoicesQ.error.message} onRetry={() => invoicesQ.refetch()} />
) : (
<DataTable
columns={[
{ key: "invoice_number", header: "شماره" },
{
key: "invoice_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.invoice_date ?? "")} />,
},
{
key: "total_amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.total_amount)),
},
{
key: "remaining_amount",
header: "مانده",
render: (r) => formatMoney(String(r.remaining_amount)),
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge>{String(r.status)}</Badge>,
},
]}
rows={(invoicesQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="فاکتوری ثبت نشده" />}
/>
)}
</div>
</div>
)}
</Drawer>
<Dialog open={invoiceOpen} onClose={() => setInvoiceOpen(false)} title="فاکتور دریافتنی جدید">
<form className="space-y-3" onSubmit={invoiceForm.handleSubmit((d) => createInvoiceM.mutate(d))}>
<FormField label="شماره فاکتور (اختیاری — خودکار)" error={invoiceForm.formState.errors.invoice_number?.message}>
<Input {...invoiceForm.register("invoice_number")} placeholder="خودکار" />
</FormField>
<FormField label="تاریخ فاکتور" error={invoiceForm.formState.errors.invoice_date?.message}>
<Controller
control={invoiceForm.control}
name="invoice_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
<FormField label="سررسید">
<Controller
control={invoiceForm.control}
name="due_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
<FormField label="مبلغ کل" error={invoiceForm.formState.errors.total_amount?.message}>
<MoneyInput {...invoiceForm.register("total_amount")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setInvoiceOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createInvoiceM.isPending}>
ثبت فاکتور
</Button>
</div>
</form>
</Dialog>
</div>
);
}
export { CustomersPage as default } from "@/src/modules/accounting/pages/customers";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="دسته‌بندی اسناد"
description="دسته‌بندی اسناد — فهرست و ثبت متادیتای سند (تا اتصال مخزن فایل اختصاصی)."
module="documents"
docType="categories"
createLabel="ثبت در دسته‌بندی اسناد"
fields={[
{ key: "party", label: "طرف مرتبط", kind: "party" },
{ key: "doc_category", label: "دسته", kind: "text" },
{ key: "amount", label: "مبلغ مرتبط", kind: "money", required: false },
{ key: "retention_days", label: "روز نگهداری", kind: "text", required: false },
]}
/>
);
}
export { DocumentsCategoriesPage as default } from "@/src/modules/accounting/pages/documents/categories";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="اسناد مالی"
description="اسناد مالی — فهرست و ثبت متادیتای سند (تا اتصال مخزن فایل اختصاصی)."
module="documents"
docType="financial"
createLabel="ثبت در اسناد مالی"
fields={[
{ key: "party", label: "طرف مرتبط", kind: "party" },
{ key: "doc_category", label: "دسته", kind: "text" },
{ key: "amount", label: "مبلغ مرتبط", kind: "money", required: false },
{ key: "retention_days", label: "روز نگهداری", kind: "text", required: false },
]}
/>
);
}
export { DocumentsFinancialPage as default } from "@/src/modules/accounting/pages/documents/financial";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="اسناد پرسنلی"
description="اسناد پرسنلی — فهرست و ثبت متادیتای سند (تا اتصال مخزن فایل اختصاصی)."
module="documents"
docType="hr"
createLabel="ثبت در اسناد پرسنلی"
fields={[
{ key: "party", label: "طرف مرتبط", kind: "party" },
{ key: "doc_category", label: "دسته", kind: "text" },
{ key: "amount", label: "مبلغ مرتبط", kind: "money", required: false },
{ key: "retention_days", label: "روز نگهداری", kind: "text", required: false },
]}
/>
);
}
export { DocumentsHrPage as default } from "@/src/modules/accounting/pages/documents/hr";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="اسناد خرید"
description="اسناد خرید — فهرست و ثبت متادیتای سند (تا اتصال مخزن فایل اختصاصی)."
module="documents"
docType="purchase"
createLabel="ثبت در اسناد خرید"
fields={[
{ key: "party", label: "طرف مرتبط", kind: "party" },
{ key: "doc_category", label: "دسته", kind: "text" },
{ key: "amount", label: "مبلغ مرتبط", kind: "money", required: false },
{ key: "retention_days", label: "روز نگهداری", kind: "text", required: false },
]}
/>
);
}
export { DocumentsPurchasePage as default } from "@/src/modules/accounting/pages/documents/purchase";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="نگهداری اسناد"
description="نگهداری اسناد — فهرست و ثبت متادیتای سند (تا اتصال مخزن فایل اختصاصی)."
module="documents"
docType="retention"
createLabel="ثبت در نگهداری اسناد"
fields={[
{ key: "party", label: "طرف مرتبط", kind: "party" },
{ key: "doc_category", label: "دسته", kind: "text" },
{ key: "amount", label: "مبلغ مرتبط", kind: "money", required: false },
{ key: "retention_days", label: "روز نگهداری", kind: "text", required: false },
]}
/>
);
}
export { DocumentsRetentionPage as default } from "@/src/modules/accounting/pages/documents/retention";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="اسناد فروش"
description="اسناد فروش — فهرست و ثبت متادیتای سند (تا اتصال مخزن فایل اختصاصی)."
module="documents"
docType="sales"
createLabel="ثبت در اسناد فروش"
fields={[
{ key: "party", label: "طرف مرتبط", kind: "party" },
{ key: "doc_category", label: "دسته", kind: "text" },
{ key: "amount", label: "مبلغ مرتبط", kind: "money", required: false },
{ key: "retention_days", label: "روز نگهداری", kind: "text", required: false },
]}
/>
);
}
export { DocumentsSalesPage as default } from "@/src/modules/accounting/pages/documents/sales";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="جستجوی اسناد"
description="جستجوی اسناد — فهرست و ثبت متادیتای سند (تا اتصال مخزن فایل اختصاصی)."
module="documents"
docType="search"
createLabel="ثبت در جستجوی اسناد"
fields={[
{ key: "party", label: "طرف مرتبط", kind: "party" },
{ key: "doc_category", label: "دسته", kind: "text" },
{ key: "amount", label: "مبلغ مرتبط", kind: "money", required: false },
{ key: "retention_days", label: "روز نگهداری", kind: "text", required: false },
]}
/>
);
}
export { DocumentsSearchPage as default } from "@/src/modules/accounting/pages/documents/search";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="نسخه‌های سند"
description="نسخه‌های سند — فهرست و ثبت متادیتای سند (تا اتصال مخزن فایل اختصاصی)."
module="documents"
docType="versions"
createLabel="ثبت در نسخه‌های سند"
fields={[
{ key: "party", label: "طرف مرتبط", kind: "party" },
{ key: "doc_category", label: "دسته", kind: "text" },
{ key: "amount", label: "مبلغ مرتبط", kind: "money", required: false },
{ key: "retention_days", label: "روز نگهداری", kind: "text", required: false },
]}
/>
);
}
export { DocumentsVersionsPage as default } from "@/src/modules/accounting/pages/documents/versions";

View File

@ -1,579 +1,3 @@
"use client";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Pencil } from "lucide-react";
import { accountingApi, type FiscalPeriod, type FiscalYear } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
ConfirmDialog,
Input,
Label,
FieldError,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
DatePicker,
JalaliDateText,
Select,
Checkbox,
} from "@/components/ds";
const yearSchema = z.object({
name: z.string().min(1, "نام الزامی است"),
start_date: z.string().min(1, "تاریخ شروع الزامی است"),
end_date: z.string().min(1, "تاریخ پایان الزامی است"),
is_current: z.boolean().optional(),
});
const periodSchema = z.object({
fiscal_year_id: z.string().uuid("سال مالی را انتخاب کنید"),
name: z.string().min(1, "نام الزامی است"),
period_number: z.number().int().min(1),
start_date: z.string().min(1),
end_date: z.string().min(1),
is_current: z.boolean().optional(),
});
const editNameSchema = z.object({ name: z.string().min(1, "نام الزامی است") });
export default function FiscalPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [yearOpen, setYearOpen] = useState(false);
const [periodOpen, setPeriodOpen] = useState(false);
const [editYear, setEditYear] = useState<FiscalYear | null>(null);
const [editPeriod, setEditPeriod] = useState<FiscalPeriod | null>(null);
const [confirm, setConfirm] = useState<
| { type: "year-close"; id: string }
| { type: "period"; id: string; action: "lock" | "close" | "reopen" }
| null
>(null);
const yearsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-years"],
queryFn: () => accountingApi.fiscal.listYears(tenantId!),
enabled: !!tenantId,
});
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const yearForm = useForm<z.infer<typeof yearSchema>>({
resolver: zodResolver(yearSchema),
defaultValues: { name: "", start_date: "", end_date: "", is_current: true },
});
const periodForm = useForm<z.infer<typeof periodSchema>>({
resolver: zodResolver(periodSchema),
defaultValues: {
fiscal_year_id: "",
name: "",
period_number: 1,
start_date: "",
end_date: "",
is_current: false,
},
});
const editYearForm = useForm<z.infer<typeof editNameSchema>>({
resolver: zodResolver(editNameSchema),
});
const editPeriodForm = useForm<z.infer<typeof editNameSchema>>({
resolver: zodResolver(editNameSchema),
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "fiscal-years"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "fiscal-periods"] });
};
const createYear = useMutation({
mutationFn: (d: z.infer<typeof yearSchema>) => accountingApi.fiscal.createYear(tenantId!, d),
onSuccess: async () => {
toast.success("سال مالی ایجاد شد");
setYearOpen(false);
yearForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateYear = useMutation({
mutationFn: (d: z.infer<typeof editNameSchema>) =>
accountingApi.fiscal.updateYear(tenantId!, editYear!.id, { name: d.name }),
onSuccess: async () => {
toast.success("سال مالی به‌روز شد");
setEditYear(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const setCurrentYear = useMutation({
mutationFn: (id: string) => accountingApi.fiscal.setCurrentYear(tenantId!, id),
onSuccess: async () => {
toast.success("سال جاری تنظیم شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const closeYear = useMutation({
mutationFn: (id: string) => accountingApi.fiscal.closeYear(tenantId!, id),
onSuccess: async () => {
toast.success("سال مالی بسته شد");
setConfirm(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const generatePeriods = useMutation({
mutationFn: (id: string) => accountingApi.fiscal.generatePeriods(tenantId!, id),
onSuccess: async (periods) => {
toast.success(`${periods.length} دوره ایجاد شد`);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const createPeriod = useMutation({
mutationFn: (d: z.infer<typeof periodSchema>) => accountingApi.fiscal.createPeriod(tenantId!, d),
onSuccess: async () => {
toast.success("دوره مالی ایجاد شد");
setPeriodOpen(false);
periodForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updatePeriod = useMutation({
mutationFn: (d: z.infer<typeof editNameSchema>) =>
accountingApi.fiscal.updatePeriod(tenantId!, editPeriod!.id, { name: d.name }),
onSuccess: async () => {
toast.success("دوره به‌روز شد");
setEditPeriod(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const setCurrentPeriod = useMutation({
mutationFn: (id: string) => accountingApi.fiscal.setCurrentPeriod(tenantId!, id),
onSuccess: async () => {
toast.success("دوره جاری تنظیم شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const periodAction = useMutation({
mutationFn: async () => {
if (!confirm || confirm.type !== "period") return;
if (confirm.action === "lock") return accountingApi.fiscal.lockPeriod(tenantId!, confirm.id);
if (confirm.action === "close") return accountingApi.fiscal.closePeriod(tenantId!, confirm.id);
return accountingApi.fiscal.reopenPeriod(tenantId!, confirm.id);
},
onSuccess: async () => {
toast.success("وضعیت دوره به‌روز شد");
setConfirm(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId) return <LoadingState />;
if (yearsQ.isLoading || periodsQ.isLoading) return <LoadingState />;
if (yearsQ.error || periodsQ.error) {
return <ErrorState message={(yearsQ.error || periodsQ.error)?.message || "خطا"} />;
}
const yearName = (id: string) => yearsQ.data?.find((y) => y.id === id)?.name ?? id;
const statusTone = (s: string) =>
s === "open" ? "success" : s === "locked" ? "warning" : s === "closed" ? "danger" : "default";
return (
<div>
<PageHeader
title="سال و دوره مالی"
description="مدیریت تقویم مالی، دوره جاری و قفل/بستن دوره‌ها."
actions={
<>
<Button type="button" variant="outline" onClick={() => setYearOpen(true)}>
<Plus className="h-4 w-4" />
سال مالی
</Button>
<Button
type="button"
onClick={() => {
const current = yearsQ.data?.find((y) => y.is_current) ?? yearsQ.data?.[0];
if (current) periodForm.setValue("fiscal_year_id", current.id);
setPeriodOpen(true);
}}
disabled={!yearsQ.data?.length}
>
<Plus className="h-4 w-4" />
دوره مالی
</Button>
</>
}
/>
<h2 className="mb-3 text-sm font-semibold text-secondary">سالهای مالی</h2>
<DataTable
columns={[
{ key: "name", header: "نام" },
{
key: "start_date",
header: "شروع",
render: (r) => <JalaliDateText value={String(r.start_date ?? "")} />,
},
{
key: "end_date",
header: "پایان",
render: (r) => <JalaliDateText value={String(r.end_date ?? "")} />,
},
{
key: "is_current",
header: "جاری",
render: (r) => (r.is_current ? <Badge tone="primary">بله</Badge> : "—"),
},
{
key: "is_closed",
header: "بسته",
render: (r) =>
r.is_closed ? <Badge tone="danger">بسته</Badge> : <Badge tone="success">باز</Badge>,
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const id = String(r.id);
const closed = Boolean(r.is_closed);
const current = Boolean(r.is_current);
return (
<div className="flex flex-wrap gap-1">
{!current && !closed ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={setCurrentYear.isPending}
onClick={() => setCurrentYear.mutate(id)}
>
جاری
</Button>
) : null}
{!closed ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirm({ type: "year-close", id })}
>
بستن سال
</Button>
) : null}
<Button
type="button"
size="sm"
variant="secondary"
disabled={generatePeriods.isPending}
onClick={() => generatePeriods.mutate(id)}
>
تولید دورهها
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
const y = yearsQ.data?.find((x) => x.id === id);
if (y) {
setEditYear(y);
editYearForm.reset({ name: y.name });
}
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
);
},
},
]}
rows={(yearsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState title="سال مالی ندارید" action={<Button onClick={() => setYearOpen(true)}>ایجاد سال</Button>} />
}
/>
<h2 className="mb-3 mt-8 text-sm font-semibold text-secondary">دورههای مالی</h2>
<DataTable
columns={[
{ key: "name", header: "نام" },
{ key: "period_number", header: "شماره" },
{
key: "fiscal_year_id",
header: "سال",
render: (r) => yearName(String(r.fiscal_year_id)),
},
{
key: "start_date",
header: "شروع",
render: (r) => <JalaliDateText value={String(r.start_date ?? "")} />,
},
{
key: "is_current",
header: "جاری",
render: (r) => (r.is_current ? <Badge tone="primary">بله</Badge> : "—"),
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge tone={statusTone(String(r.status))}>{String(r.status)}</Badge>,
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const id = String(r.id);
const status = String(r.status);
const current = Boolean(r.is_current);
return (
<div className="flex flex-wrap gap-1">
{!current && status !== "closed" ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={setCurrentPeriod.isPending}
onClick={() => setCurrentPeriod.mutate(id)}
>
جاری
</Button>
) : null}
{status === "open" ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirm({ type: "period", id, action: "lock" })}
>
قفل
</Button>
) : null}
{status === "open" || status === "locked" ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirm({ type: "period", id, action: "close" })}
>
بستن
</Button>
) : null}
{status === "closed" || status === "locked" ? (
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setConfirm({ type: "period", id, action: "reopen" })}
>
بازگشایی
</Button>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
const p = periodsQ.data?.find((x) => x.id === id);
if (p) {
setEditPeriod(p);
editPeriodForm.reset({ name: p.name });
}
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
);
},
},
]}
rows={(periodsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="دوره‌ای ثبت نشده" />}
/>
<Dialog open={yearOpen} onClose={() => setYearOpen(false)} title="ایجاد سال مالی">
<form className="space-y-3" onSubmit={yearForm.handleSubmit((d) => createYear.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...yearForm.register("name")} placeholder="۱۴۰۵" />
<FieldError>{yearForm.formState.errors.name?.message}</FieldError>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>شروع</Label>
<Controller
control={yearForm.control}
name="start_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
<FieldError>{yearForm.formState.errors.start_date?.message}</FieldError>
</div>
<div>
<Label>پایان</Label>
<Controller
control={yearForm.control}
name="end_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
<FieldError>{yearForm.formState.errors.end_date?.message}</FieldError>
</div>
</div>
<Checkbox label="سال جاری" {...yearForm.register("is_current")} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setYearOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createYear.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={periodOpen} onClose={() => setPeriodOpen(false)} title="ایجاد دوره مالی">
<form className="space-y-3" onSubmit={periodForm.handleSubmit((d) => createPeriod.mutate(d))}>
<div>
<Label>سال مالی</Label>
<Select {...periodForm.register("fiscal_year_id")}>
<option value="">انتخاب</option>
{yearsQ.data?.map((y) => (
<option key={y.id} value={y.id}>
{y.name}
</option>
))}
</Select>
<FieldError>{periodForm.formState.errors.fiscal_year_id?.message}</FieldError>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>نام</Label>
<Input {...periodForm.register("name")} />
<FieldError>{periodForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label>شماره دوره</Label>
<Input type="number" {...periodForm.register("period_number", { valueAsNumber: true })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>شروع</Label>
<Controller
control={periodForm.control}
name="start_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
<div>
<Label>پایان</Label>
<Controller
control={periodForm.control}
name="end_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
</div>
<Checkbox label="دوره جاری" {...periodForm.register("is_current")} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setPeriodOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createPeriod.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={!!editYear} onClose={() => setEditYear(null)} title="ویرایش سال مالی">
<form className="space-y-3" onSubmit={editYearForm.handleSubmit((d) => updateYear.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...editYearForm.register("name")} />
<FieldError>{editYearForm.formState.errors.name?.message}</FieldError>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditYear(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateYear.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={!!editPeriod} onClose={() => setEditPeriod(null)} title="ویرایش دوره مالی">
<form className="space-y-3" onSubmit={editPeriodForm.handleSubmit((d) => updatePeriod.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...editPeriodForm.register("name")} />
<FieldError>{editPeriodForm.formState.errors.name?.message}</FieldError>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditPeriod(null)}>
انصراف
</Button>
<Button type="submit" disabled={updatePeriod.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={confirm?.type === "year-close"}
onClose={() => setConfirm(null)}
onConfirm={() => confirm?.type === "year-close" && closeYear.mutate(confirm.id)}
title="بستن سال مالی؟"
description="پس از بستن، سال دیگر قابل ثبت سند جدید نیست."
confirmLabel="بستن سال"
loading={closeYear.isPending}
/>
<ConfirmDialog
open={confirm?.type === "period"}
onClose={() => setConfirm(null)}
onConfirm={() => periodAction.mutate()}
title="تغییر وضعیت دوره"
description="این عملیات روی دوره مالی واقعی اعمال می‌شود."
confirmLabel="اعمال"
loading={periodAction.isPending}
/>
</div>
);
}
export { FiscalPage as default } from "@/src/modules/accounting/pages/fiscal";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="اتصال بانک"
description="پیکربندی اتصال بانک — endpoint و وضعیت اتصال در سرویس حسابداری ذخیره می‌شود."
module="integration"
docType="bank"
createLabel="ثبت اتصال بانک"
fields={[
{ key: "provider", label: "ارائه‌دهنده", kind: "text" },
{ key: "endpoint", label: "آدرس / Endpoint", kind: "text" },
{ key: "status", label: "وضعیت", kind: "select", options: [
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
{ value: "error", label: "خطا" },
]},
{ key: "amount", label: "سقف/حجم (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { IntegrationBankPage as default } from "@/src/modules/accounting/pages/integration/bank";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="اتصال CRM"
description="پیکربندی اتصال CRM — endpoint و وضعیت اتصال در سرویس حسابداری ذخیره می‌شود."
module="integration"
docType="crm"
createLabel="ثبت اتصال CRM"
fields={[
{ key: "provider", label: "ارائه‌دهنده", kind: "text" },
{ key: "endpoint", label: "آدرس / Endpoint", kind: "text" },
{ key: "status", label: "وضعیت", kind: "select", options: [
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
{ value: "error", label: "خطا" },
]},
{ key: "amount", label: "سقف/حجم (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { IntegrationCrmPage as default } from "@/src/modules/accounting/pages/integration/crm";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="درگاه پرداخت"
description="پیکربندی درگاه پرداخت — endpoint و وضعیت اتصال در سرویس حسابداری ذخیره می‌شود."
module="integration"
docType="gateway"
createLabel="ثبت درگاه پرداخت"
fields={[
{ key: "provider", label: "ارائه‌دهنده", kind: "text" },
{ key: "endpoint", label: "آدرس / Endpoint", kind: "text" },
{ key: "status", label: "وضعیت", kind: "select", options: [
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
{ value: "error", label: "خطا" },
]},
{ key: "amount", label: "سقف/حجم (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { IntegrationGatewayPage as default } from "@/src/modules/accounting/pages/integration/gateway";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="ورود / خروج داده"
description="پیکربندی ورود / خروج داده — endpoint و وضعیت اتصال در سرویس حسابداری ذخیره می‌شود."
module="integration"
docType="import_export"
createLabel="ثبت ورود / خروج داده"
fields={[
{ key: "provider", label: "ارائه‌دهنده", kind: "text" },
{ key: "endpoint", label: "آدرس / Endpoint", kind: "text" },
{ key: "status", label: "وضعیت", kind: "select", options: [
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
{ value: "error", label: "خطا" },
]},
{ key: "amount", label: "سقف/حجم (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { IntegrationImportExportPage as default } from "@/src/modules/accounting/pages/integration/import-export";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="اتصال انبار"
description="پیکربندی اتصال انبار — endpoint و وضعیت اتصال در سرویس حسابداری ذخیره می‌شود."
module="integration"
docType="inventory"
createLabel="ثبت اتصال انبار"
fields={[
{ key: "provider", label: "ارائه‌دهنده", kind: "text" },
{ key: "endpoint", label: "آدرس / Endpoint", kind: "text" },
{ key: "status", label: "وضعیت", kind: "select", options: [
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
{ value: "error", label: "خطا" },
]},
{ key: "amount", label: "سقف/حجم (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { IntegrationInventoryPage as default } from "@/src/modules/accounting/pages/integration/inventory";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="اتصال حقوق"
description="پیکربندی اتصال حقوق — endpoint و وضعیت اتصال در سرویس حسابداری ذخیره می‌شود."
module="integration"
docType="payroll"
createLabel="ثبت اتصال حقوق"
fields={[
{ key: "provider", label: "ارائه‌دهنده", kind: "text" },
{ key: "endpoint", label: "آدرس / Endpoint", kind: "text" },
{ key: "status", label: "وضعیت", kind: "select", options: [
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
{ value: "error", label: "خطا" },
]},
{ key: "amount", label: "سقف/حجم (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { IntegrationPayrollPage as default } from "@/src/modules/accounting/pages/integration/payroll";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="گزارش یکپارچگی"
description="پیکربندی گزارش یکپارچگی — endpoint و وضعیت اتصال در سرویس حسابداری ذخیره می‌شود."
module="integration"
docType="reports"
createLabel="ثبت گزارش یکپارچگی"
fields={[
{ key: "provider", label: "ارائه‌دهنده", kind: "text" },
{ key: "endpoint", label: "آدرس / Endpoint", kind: "text" },
{ key: "status", label: "وضعیت", kind: "select", options: [
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
{ value: "error", label: "خطا" },
]},
{ key: "amount", label: "سقف/حجم (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { IntegrationReportsPage as default } from "@/src/modules/accounting/pages/integration/reports";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="وب‌سرویس‌ها"
description="پیکربندی وب‌سرویس‌ها — endpoint و وضعیت اتصال در سرویس حسابداری ذخیره می‌شود."
module="integration"
docType="webhooks"
createLabel="ثبت وب‌سرویس‌ها"
fields={[
{ key: "provider", label: "ارائه‌دهنده", kind: "text" },
{ key: "endpoint", label: "آدرس / Endpoint", kind: "text" },
{ key: "status", label: "وضعیت", kind: "select", options: [
{ value: "active", label: "فعال" },
{ value: "inactive", label: "غیرفعال" },
{ value: "error", label: "خطا" },
]},
{ key: "amount", label: "سقف/حجم (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { IntegrationWebhooksPage as default } from "@/src/modules/accounting/pages/integration/webhooks";

View File

@ -1,16 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="تعدیل موجودی"
createLabel="تعدیل جدید"
module="inventory"
docType="adjustment"
partyLabel="انبار"
kind="adjustment"
/>
);
}
export { InventoryAdjustmentsPage as default } from "@/src/modules/accounting/pages/inventory/adjustments";

View File

@ -1,342 +1,3 @@
"use client";
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney, parseMoneyInput, toRialInteger } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
FormField,
MoneyInput,
DatePicker,
JalaliDateText,
Select,
Badge,
} from "@/components/ds";
import { ItemCombobox } from "@/components/accounting/EntityCombobox";
type FormValues = {
number: string;
doc_date: string;
reason: "expense" | "charity" | "consumption" | "barter";
item_label: string;
item_id: string;
qty: string;
unit_cost: string;
warehouse_id: string;
debit_account_id: string;
barter_debit_account_id: string;
barter_amount: string;
description: string;
};
const REASON_FA: Record<string, string> = {
expense: "هزینه",
charity: "خیرات / اهدایی",
consumption: "مصرف داخلی (آبدارخانه و …)",
barter: "تهاتر / معاوضه کالا",
};
export default function InventoryIssuesPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", "inventory", "issue"],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, "inventory", "issue"),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const warehousesQ = useQuery({
queryKey: ["accounting", tenantId, "warehouses"],
queryFn: () => accountingApi.ops.listWarehouses(tenantId!),
enabled: !!tenantId,
});
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const form = useForm<FormValues>({
defaultValues: {
number: "",
doc_date: new Date().toISOString().slice(0, 10),
reason: "expense",
item_label: "",
item_id: "",
qty: "1",
unit_cost: "0",
warehouse_id: "",
debit_account_id: "",
barter_debit_account_id: "",
barter_amount: "0",
description: "",
},
});
const reason = form.watch("reason");
const amount = useMemo(() => {
const q = Number(toRialInteger(form.watch("qty")) || "0");
const c = Number(toRialInteger(form.watch("unit_cost")) || "0");
return String(q * c);
}, [form.watch("qty"), form.watch("unit_cost")]);
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
const autoIssue = Boolean(policyQ.data?.auto_post?.inventory_issue ?? true);
const createM = useMutation({
mutationFn: async (d: FormValues) => {
const amt = parseMoneyInput(amount) || "0";
if (Number(amt) <= 0) throw new Error("مبلغ خروج باید بزرگ‌تر از صفر باشد");
if (!d.debit_account_id) throw new Error("حساب هزینه/مقصد الزامی است");
if (d.reason === "barter" && !d.barter_debit_account_id) {
throw new Error("برای تهاتر، حساب کالای دریافتی را انتخاب کنید");
}
const doc = await accountingApi.ops.createDocument(tenantId!, {
module: "inventory",
doc_type: "issue",
number: d.number?.trim() || undefined,
doc_date: d.doc_date,
amount: amt,
description: d.description || REASON_FA[d.reason],
status: "draft",
meta_json: JSON.stringify({
reason: d.reason,
item_id: d.item_id,
item_label: d.item_label,
qty: d.qty,
unit_cost: d.unit_cost,
warehouse_id: d.warehouse_id || undefined,
debit_account_id: d.debit_account_id,
barter_debit_account_id: d.barter_debit_account_id || undefined,
barter_amount: d.barter_amount || undefined,
}),
});
if (autoIssue) {
const posted = await accountingApi.purchaseInventory.postInventoryIssue(tenantId!, {
source_document_id: String(doc.id),
amount: amt,
reason: d.reason,
debit_account_id: d.debit_account_id,
barter_debit_account_id: d.barter_debit_account_id || undefined,
barter_amount:
d.reason === "barter" ? parseMoneyInput(d.barter_amount) || amt : undefined,
});
await accountingApi.ops.confirmDocument(tenantId!, String(doc.id)).catch(() => null);
return posted;
}
return doc;
},
onSuccess: async (res) => {
const vn =
res && typeof res === "object" && "voucher_number" in res
? String((res as { voucher_number?: string }).voucher_number || "")
: "";
toast.success(vn ? `خروج ثبت و سند ${vn} صادر شد` : "خروج کالا ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "inventory", "issue"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
},
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="خروج / هزینه‌کرد کالا"
description="خروج موجودی برای هزینه، خیرات، مصرف داخلی یا تهاتر — سند حسابداری اتومات (Dr هزینه / Cr موجودی)."
actions={
<Button type="button" onClick={() => setOpen(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: "reason",
header: "نوع",
render: (r) => {
try {
const meta = JSON.parse(String(r.meta_json || "{}")) as { reason?: string };
return REASON_FA[meta.reason || ""] || meta.reason || "—";
} catch {
return "—";
}
},
},
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount ?? "0")),
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge tone={r.status === "posted" || r.status === "confirmed" ? "success" : "default"}>{String(r.status)}</Badge>,
},
{ key: "description", header: "شرح" },
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="خروجی ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="خروج / هزینه‌کرد کالا">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<FormField label="نوع خروج">
<Select
value={reason}
onChange={(e) => form.setValue("reason", e.target.value as FormValues["reason"])}
>
<option value="expense">هزینه کردن کالا</option>
<option value="charity">خیرات / اهدایی (بدون دریافت وجه)</option>
<option value="consumption">مصرف داخلی (آبدارخانه و )</option>
<option value="barter">تهاتر کالا میدهم، چیز دیگری میگیرم</option>
</Select>
</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>
<div className="sm:col-span-2">
<FormField label="کالای خروجی" hint="کالایی که از انبار خارج می‌شود">
<ItemCombobox
valueLabel={form.watch("item_label") || undefined}
onSelect={(item) => {
form.setValue("item_id", item.id);
form.setValue("item_label", `${item.code}${item.name}`);
form.setValue("unit_cost", parseMoneyInput(item.unit_cost) || "0");
}}
onClear={() => {
form.setValue("item_id", "");
form.setValue("item_label", "");
}}
/>
</FormField>
</div>
<FormField label="تعداد">
<Input {...form.register("qty", { required: true })} />
</FormField>
<FormField label="بهای واحد">
<MoneyInput {...form.register("unit_cost", { required: true })} />
</FormField>
<FormField label="جمع مبلغ">
<Input value={formatMoney(amount)} readOnly dir="ltr" />
</FormField>
<FormField label="انبار مبدأ">
<Select {...form.register("warehouse_id")}>
<option value="">انتخاب</option>
{(warehousesQ.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.code} {w.name}
</option>
))}
</Select>
</FormField>
<div className="sm:col-span-2">
<FormField
label={
reason === "charity"
? "حساب هزینه خیرات / اهدایی"
: reason === "consumption"
? "حساب هزینه مصرف (مثلاً آبدارخانه)"
: reason === "barter"
? "حساب مابه‌التفاوت (اگر مبلغ‌ها برابر نباشند)"
: "حساب هزینه مقصد"
}
hint="بدهکار سند اتومات — موجودی بستانکار می‌شود"
>
<Select {...form.register("debit_account_id", { required: true })}>
<option value="">انتخاب حساب</option>
{postable.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
</div>
{reason === "barter" ? (
<>
<div className="sm:col-span-2">
<FormField
label="حساب کالای دریافتی"
hint="مثلاً موجودی چای / ملزومات آبدارخانه که در ازای کالای خروجی می‌گیرید"
>
<Select {...form.register("barter_debit_account_id", { required: true })}>
<option value="">انتخاب حساب</option>
{postable.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
</div>
<FormField label="ارزش کالای دریافتی">
<MoneyInput {...form.register("barter_amount")} />
</FormField>
</>
) : null}
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input
placeholder="مثال: اهدا به خیریه / مصرف آبدارخانه در ازای چای"
{...form.register("description")}
/>
</FormField>
</div>
<p className="sm:col-span-2 text-xs text-[var(--muted)]">
{autoIssue
? "با ذخیره، سند حسابداری خودکار از Posting Engine صادر می‌شود."
: "ثبت خودکار خاموش است — فقط سند عملیاتی ذخیره می‌شود (تنظیمات → اسناد اتوماتیک)."}
</p>
<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>
);
}
export { InventoryIssuesPage as default } from "@/src/modules/accounting/pages/inventory/issues";

View File

@ -1,7 +1,3 @@
"use client";
import { InventoryItemsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <InventoryItemsPage />;
}
export { InventoryItemsPage as default } from "@/src/modules/accounting/pages/inventory/items";

View File

@ -1,23 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="گردش کالا"
description="ثبت گردش موجودی با مرجع انبار و مبلغ ارزش."
module="inventory"
docType="movement"
createLabel="گردش جدید"
fields={[
{ key: "warehouse_code", label: "کد انبار", kind: "text" },
{ key: "movement_type", label: "نوع گردش", kind: "select", options: [
{ value: "in", label: "ورود" },
{ value: "out", label: "خروج" },
{ value: "transfer", label: "انتقال" },
]},
{ key: "amount", label: "ارزش", kind: "money" },
{ key: "qty", label: "تعداد", kind: "text" },
]}
/>
);
}
export { InventoryMovementsPage as default } from "@/src/modules/accounting/pages/inventory/movements";

View File

@ -1,18 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="رسید ورود و خروج"
description="طرف حساب و انبار جداگانه مشخص می‌شوند — ورود یا خروج کالا به/از انبار."
createLabel="رسید جدید"
module="inventory"
docType="receipt_issue"
partyLabel="طرف حساب"
kind="receipt"
enableWarehouseContext
/>
);
}
export { InventoryReceiptsIssuesPage as default } from "@/src/modules/accounting/pages/inventory/receipts-issues";

View File

@ -1,8 +1,3 @@
"use client";
import { InventoryValuationPage } from "@/components/accounting/DomainScreens";
/** گزارش‌های انبار — از همان موتور ارزش‌گذاری و لیست موجودی استفاده می‌کند. */
export default function Page() {
return <InventoryValuationPage />;
}
export { InventoryReportsPage as default } from "@/src/modules/accounting/pages/inventory/reports";

View File

@ -1,7 +1,3 @@
"use client";
import { WarehousesPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() {
return <WarehousesPage />;
}
export { InventorySettingsPage as default } from "@/src/modules/accounting/pages/inventory/settings";

View File

@ -1,16 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="انتقال انبار"
createLabel="انتقال جدید"
module="inventory"
docType="transfer"
partyLabel="انبار مبدأ/مقصد"
kind="transfer"
/>
);
}
export { InventoryTransfersPage as default } from "@/src/modules/accounting/pages/inventory/transfers";

View File

@ -1,5 +1,3 @@
import { InventoryValuationPage } from "@/components/accounting/DomainScreens";
"use client";
export default function Page() {
return <InventoryValuationPage />;
}
export { InventoryValuationPage as default } from "@/src/modules/accounting/pages/inventory/valuation";

View File

@ -1,72 +1,12 @@
"use client";
import { AuthGuard } from "@/components/AuthGuard";
import { AccountingShell } from "@/components/accounting/AccountingShell";
import { RouteProgress } from "@/components/RouteProgress";
import { useMe } from "@/hooks/useMe";
import { accountingApi } from "@/lib/accounting-api";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { LoadingState, ErrorState } from "@/components/ds";
/** Prefetch shared accounting lookups so sidebar hops skip cold API waits. */
function useWarmAccountingCache(tenantId: string | null) {
useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
staleTime: 60_000,
});
useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
staleTime: 60_000,
});
useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
staleTime: 60_000,
});
}
function AccountingGate({ children }: { children: React.ReactNode }) {
const { me, loading, error, reload } = useMe();
const router = useRouter();
const tenantId = me?.current_tenant_id ?? null;
useWarmAccountingCache(tenantId);
useEffect(() => {
if (loading) return;
if (me?.onboarding_required) router.replace("/onboarding");
}, [me, loading, router]);
// Keep shell mounted once session is known — never blank the sidebar on soft nav.
if (loading && !me) return <LoadingState label="در حال آماده‌سازی ماژول حسابداری…" />;
if (error && !me) return <ErrorState message={error} onRetry={reload} />;
if (!loading && !me?.current_tenant_id) {
return (
<ErrorState message="برای استفاده از حسابداری باید یک workspace فعال داشته باشید." />
);
}
if (!me?.current_tenant_id) {
return <LoadingState label="در حال آماده‌سازی ماژول حسابداری…" />;
}
return (
<>
<RouteProgress />
<AccountingShell>{children}</AccountingShell>
</>
);
}
import { AccountingLayoutGate } from "@/src/modules/accounting/layout/AccountingLayoutGate";
export default function AccountingLayout({ children }: { children: React.ReactNode }) {
return (
<AuthGuard>
<AccountingGate>{children}</AccountingGate>
<AccountingLayoutGate>{children}</AccountingLayoutGate>
</AuthGuard>
);
}

View File

@ -1,219 +1,3 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { RefreshCw } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Select,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Card,
CardContent,
StatCard,
} from "@/components/ds";
export default function LedgerPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const defaultPeriod = useMemo(() => {
const periods = periodsQ.data ?? [];
return periods.find((p) => p.is_current)?.id ?? periods[0]?.id ?? "";
}, [periodsQ.data]);
const [periodId, setPeriodId] = useState("");
const activePeriodId = periodId || defaultPeriod;
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const balancesQ = useQuery({
queryKey: ["accounting", tenantId, "ledger-balances", activePeriodId],
queryFn: () => accountingApi.ledger.balances(tenantId!, activePeriodId),
enabled: !!tenantId && !!activePeriodId,
});
const trialQ = useQuery({
queryKey: ["accounting", tenantId, "trial-balance", activePeriodId],
queryFn: () => accountingApi.ledger.trialBalance(tenantId!, activePeriodId),
enabled: !!tenantId && !!activePeriodId,
});
const recalcM = useMutation({
mutationFn: () => accountingApi.ledger.recalculate(tenantId!, activePeriodId),
onSuccess: async (res) => {
toast.success(`${res.recalculated_accounts} حساب محاسبه مجدد شد`);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ledger-balances", activePeriodId] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "trial-balance", activePeriodId] });
},
onError: (e: Error) => toast.error(e.message),
});
const accountMap = useMemo(
() => new Map((accountsQ.data ?? []).map((a) => [a.id, `${a.code}${a.name}`])),
[accountsQ.data]
);
const rows = useMemo(() => {
const balances = balancesQ.data ?? [];
return [...balances]
.map((b) => ({
...b,
account_label: accountMap.get(b.account_id) ?? b.account_id,
}))
.sort((a, b) => a.account_label.localeCompare(b.account_label, "fa"));
}, [balancesQ.data, accountMap]);
if (!tenantId || periodsQ.isLoading) return <LoadingState />;
if (periodsQ.error) {
return <ErrorState message={periodsQ.error.message} onRetry={() => periodsQ.refetch()} />;
}
const periodName =
periodsQ.data?.find((p) => p.id === activePeriodId)?.name ?? "—";
return (
<div>
<PageHeader
title="دفتر کل"
description="مانده حساب‌ها، تراز آزمایشی و محاسبه مجدد — API واقعی Ledger."
actions={
<Button
type="button"
variant="outline"
disabled={!activePeriodId || recalcM.isPending}
onClick={() => recalcM.mutate()}
>
<RefreshCw className="h-4 w-4" />
محاسبه مجدد
</Button>
}
/>
<div className="mb-6 flex flex-wrap items-end gap-4">
<div className="min-w-[220px]">
<label className="mb-1.5 block text-sm font-medium text-secondary">دوره مالی</label>
<Select value={activePeriodId} onChange={(e) => setPeriodId(e.target.value)}>
<option value="">انتخاب دوره</option>
{(periodsQ.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.is_current ? "(جاری)" : ""}
</option>
))}
</Select>
</div>
{activePeriodId ? (
<p className="text-sm text-[var(--muted)]">
دوره انتخابشده: <span className="font-medium text-secondary">{periodName}</span>
</p>
) : null}
</div>
{!activePeriodId ? (
<EmptyState
title="دوره مالی انتخاب نشده"
description="ابتدا در بخش سال و دوره مالی یک دوره ایجاد کنید."
/>
) : (
<>
<div className="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{trialQ.isLoading ? (
<LoadingState />
) : trialQ.error ? (
<ErrorState message={trialQ.error.message} onRetry={() => trialQ.refetch()} />
) : trialQ.data ? (
<>
<StatCard label="جمع بدهکار" value={formatMoney(trialQ.data.total_debit)} />
<StatCard label="جمع بستانکار" value={formatMoney(trialQ.data.total_credit)} />
<StatCard label="اختلاف" value={formatMoney(trialQ.data.difference)} />
<Card>
<CardContent className="pt-5">
<p className="text-xs text-[var(--muted)]">تراز آزمایشی</p>
<Badge
className="mt-2"
tone={trialQ.data.is_balanced ? "success" : "danger"}
>
{trialQ.data.is_balanced ? "متوازن" : "نامتوازن"}
</Badge>
</CardContent>
</Card>
</>
) : null}
</div>
{balancesQ.isLoading ? (
<LoadingState />
) : balancesQ.error ? (
<ErrorState message={balancesQ.error.message} onRetry={() => balancesQ.refetch()} />
) : (
<DataTable
columns={[
{
key: "account_label",
header: "حساب",
render: (r) => String(r.account_label ?? r.account_id),
},
{
key: "opening_balance",
header: "افتتاحیه",
render: (r) => formatMoney(String(r.opening_balance)),
},
{
key: "debit_total",
header: "گردش بدهکار",
render: (r) => formatMoney(String(r.debit_total)),
},
{
key: "credit_total",
header: "گردش بستانکار",
render: (r) => formatMoney(String(r.credit_total)),
},
{
key: "closing_balance",
header: "اختتامیه",
render: (r) => formatMoney(String(r.closing_balance)),
},
{
key: "current_balance",
header: "مانده جاری",
render: (r) => formatMoney(String(r.current_balance)),
},
]}
rows={rows as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title="مانده‌ای ثبت نشده"
description="پس از ثبت اسناد، محاسبه مجدد را اجرا کنید."
action={
<Button type="button" onClick={() => recalcM.mutate()} disabled={recalcM.isPending}>
محاسبه مجدد
</Button>
}
/>
}
/>
)}
</>
)}
</div>
);
}
export { LedgerPage as default } from "@/src/modules/accounting/pages/ledger";

View File

@ -1,3 +1,3 @@
"use client";
import { ComplianceRecordsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <ComplianceRecordsPage />; }
export { MonitoringActivityPage as default } from "@/src/modules/accounting/pages/monitoring/activity";

View File

@ -1,22 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="هشدارها"
description="هشدارها — ثبت آستانه/شاخص پایش."
module="monitoring"
docType="alert"
createLabel="ثبت هشدارها"
fields={[
{ key: "metric_name", label: "نام شاخص", kind: "text" },
{ key: "threshold", label: "آستانه", kind: "money" },
{ key: "severity", label: "اولویت", kind: "select", options: [
{ value: "info", label: "اطلاعات" },
{ value: "warning", label: "هشدار" },
{ value: "critical", label: "بحرانی" },
]},
]}
/>
);
}
export { MonitoringAlertsPage as default } from "@/src/modules/accounting/pages/monitoring/alerts";

View File

@ -1,5 +1,3 @@
import { MonitoringHealthPage } from "@/components/accounting/DomainScreens";
"use client";
export default function Page() {
return <MonitoringHealthPage />;
}
export { MonitoringHealthPage as default } from "@/src/modules/accounting/pages/monitoring/health";

View File

@ -1,22 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="شاخص‌های کلیدی"
description="شاخص‌های کلیدی — ثبت آستانه/شاخص پایش."
module="monitoring"
docType="kpi"
createLabel="ثبت شاخص‌های کلیدی"
fields={[
{ key: "metric_name", label: "نام شاخص", kind: "text" },
{ key: "threshold", label: "آستانه", kind: "money" },
{ key: "severity", label: "اولویت", kind: "select", options: [
{ value: "info", label: "اطلاعات" },
{ value: "warning", label: "هشدار" },
{ value: "critical", label: "بحرانی" },
]},
]}
/>
);
}
export { MonitoringKpisPage as default } from "@/src/modules/accounting/pages/monitoring/kpis";

View File

@ -1,3 +1,3 @@
"use client";
import { MonitoringHealthPage } from "@/components/accounting/DomainScreens";
export default function Page() { return <MonitoringHealthPage />; }
export { MonitoringPage as default } from "@/src/modules/accounting/pages/monitoring";

View File

@ -1,22 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="عملکرد سیستم"
description="عملکرد سیستم — ثبت آستانه/شاخص پایش."
module="monitoring"
docType="performance"
createLabel="ثبت عملکرد سیستم"
fields={[
{ key: "metric_name", label: "نام شاخص", kind: "text" },
{ key: "threshold", label: "آستانه", kind: "money" },
{ key: "severity", label: "اولویت", kind: "select", options: [
{ value: "info", label: "اطلاعات" },
{ value: "warning", label: "هشدار" },
{ value: "critical", label: "بحرانی" },
]},
]}
/>
);
}
export { MonitoringPerformancePage as default } from "@/src/modules/accounting/pages/monitoring/performance";

View File

@ -1,3 +1,3 @@
"use client";
import { ComplianceRecordsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <ComplianceRecordsPage />; }
export { MonitoringRecentPage as default } from "@/src/modules/accounting/pages/monitoring/recent";

View File

@ -1,178 +1,3 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { CompletionScoreboard } from "@/components/accounting/CompletionScoreboard";
import {
PageHeader,
StatCard,
LoadingState,
ErrorState,
Badge,
Button,
Card,
CardContent,
} from "@/components/ds";
export default function AccountingDashboardPage() {
const { tenantId, loading: tenantLoading } = 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,
});
const chartsQ = useQuery({
queryKey: ["accounting", tenantId, "charts"],
queryFn: () => accountingApi.charts.list(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const yearsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-years"],
queryFn: () => accountingApi.fiscal.listYears(tenantId!),
enabled: !!tenantId,
});
const vouchersQ = useQuery({
queryKey: ["accounting", tenantId, "vouchers"],
queryFn: () => accountingApi.vouchers.list(tenantId!),
enabled: !!tenantId,
});
if (tenantLoading || !tenantId) return <LoadingState />;
const err =
chartsQ.error || accountsQ.error || yearsQ.error || vouchersQ.error || healthQ.error;
if (err) {
return (
<ErrorState
message={err instanceof Error ? err.message : "خطا در دریافت داده‌ها"}
onRetry={() => {
void chartsQ.refetch();
void accountsQ.refetch();
void yearsQ.refetch();
void vouchersQ.refetch();
}}
/>
);
}
const loading = chartsQ.isLoading || accountsQ.isLoading || yearsQ.isLoading || vouchersQ.isLoading;
if (loading) return <LoadingState />;
const setup = setupQ.data;
const setupIncomplete = setup && setup.percent < 100;
return (
<div>
<PageHeader
title="داشبورد حسابداری"
description="نمای کلی وضعیت مالی workspace — داده‌ها از سرویس حسابداری خوانده می‌شوند."
actions={
<div className="flex items-center gap-2">
{healthQ.data ? (
<Badge tone={healthQ.data.status === "ok" ? "success" : "danger"}>
سرویس {healthQ.data.version}
</Badge>
) : null}
<Link href="/accounting/vouchers/new">
<Button>سند جدید</Button>
</Link>
</div>
}
/>
{setupIncomplete ? (
<Card className="mb-6 border-primary/30">
<CardContent className="flex flex-col gap-3 pt-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold text-secondary">راهاندازی حسابداری</p>
<p className="mt-1 text-sm text-[var(--muted)]">
{setup!.completed_steps} از {setup!.total_steps} مرحله تکمیل شده ({setup!.percent}٪)
</p>
</div>
<Link href="/accounting/setup">
<Button variant="outline">ادامه راهاندازی</Button>
</Link>
</CardContent>
</Card>
) : null}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="دفاتر حساب" value={chartsQ.data?.length ?? 0} />
<StatCard label="حساب‌ها" value={accountsQ.data?.length ?? 0} />
<StatCard label="سال‌های مالی" value={yearsQ.data?.length ?? 0} />
<StatCard label="اسناد" value={vouchersQ.data?.length ?? 0} />
</div>
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<CompletionScoreboard compact />
<section className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-5">
<h2 className="mb-3 font-semibold text-secondary">آخرین اسناد</h2>
{(vouchersQ.data?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted)]">هنوز سندی ثبت نشده است.</p>
) : (
<ul className="space-y-2">
{vouchersQ.data!.slice(0, 5).map((v) => (
<li key={v.id}>
<Link
href={`/accounting/vouchers/${v.id}`}
className="flex items-center justify-between rounded-xl px-3 py-2 text-sm hover:bg-[var(--surface-muted)]"
>
<span className="font-medium text-secondary">{v.voucher_number}</span>
<Badge
tone={
v.status === "posted"
? "success"
: v.status === "draft"
? "default"
: "warning"
}
>
{v.status}
</Badge>
</Link>
</li>
))}
</ul>
)}
</section>
</div>
<section className="mt-8 rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-5">
<h2 className="mb-3 font-semibold text-secondary">دسترسی سریع</h2>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{[
["/accounting/chart-of-accounts", "دفتر حساب‌ها"],
["/accounting/fiscal", "سال و دوره مالی"],
["/accounting/ledger", "دفتر کل"],
["/accounting/vouchers", "اسناد حسابداری"],
["/accounting/setup", "راه‌اندازی"],
["/accounting/reports", "گزارش‌ها"],
].map(([href, label]) => (
<Link
key={href}
href={href}
className="rounded-xl border border-[var(--border)] px-4 py-3 text-sm hover:bg-[var(--surface-muted)]"
>
{label}
</Link>
))}
</div>
</section>
</div>
);
}
export { AccountingDashboardPage as default } from "@/src/modules/accounting/pages/dashboard";

View File

@ -1,19 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="تسهیم هزینه حقوق"
description="تخصیص هزینه حقوق به مراکز هزینه/پروژه."
module="payroll"
docType="allocation"
createLabel="تسهیم جدید"
fields={[
{ key: "cost_center_code", label: "مرکز هزینه", kind: "text" },
{ key: "project_code", label: "پروژه", kind: "text", required: false },
{ key: "amount", label: "مبلغ تسهیم", kind: "money" },
{ key: "period_label", label: "دوره", kind: "text" },
]}
/>
);
}
export { PayrollAllocationPage as default } from "@/src/modules/accounting/pages/payroll/allocation";

View File

@ -1,3 +1,3 @@
"use client";
import { PayrollCalculatePage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <PayrollCalculatePage />; }
export { PayrollCalculatePage as default } from "@/src/modules/accounting/pages/payroll/calculate";

View File

@ -1,3 +1,3 @@
"use client";
import { PayrollContractsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <PayrollContractsPage />; }
export { PayrollContractsPage as default } from "@/src/modules/accounting/pages/payroll/contracts";

View File

@ -1,5 +1,3 @@
import { PayrollEmployeesPage } from "@/components/accounting/DomainScreens";
"use client";
export default function Page() {
return <PayrollEmployeesPage />;
}
export { PayrollEmployeesPage as default } from "@/src/modules/accounting/pages/payroll/employees";

View File

@ -1,3 +1,3 @@
"use client";
import { PayrollComponentsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <PayrollComponentsPage />; }
export { PayrollItemsPage as default } from "@/src/modules/accounting/pages/payroll/items";

View File

@ -1,457 +1,3 @@
"use client";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Calculator, Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Tabs,
FormField,
Select,
MoneyInput,
DatePicker,
JalaliDateText,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ds";
const deptSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
});
const employeeSchema = z.object({
employee_code: z.string().min(1, "کد پرسنلی الزامی است"),
first_name: z.string().min(1, "نام الزامی است"),
last_name: z.string().min(1, "نام خانوادگی الزامی است"),
base_salary: z.string().optional(),
department_id: z.string().optional(),
});
const periodSchema = z.object({
name: z.string().min(1, "نام دوره الزامی است"),
start_date: z.string().min(1, "تاریخ شروع الزامی است"),
end_date: z.string().min(1, "تاریخ پایان الزامی است"),
});
const calcSchema = z.object({
payroll_period_id: z.string().min(1, "دوره حقوق را انتخاب کنید"),
employee_id: z.string().min(1, "کارمند را انتخاب کنید"),
});
type TabId = "departments" | "employees" | "periods" | "operations";
export default function PayrollPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [tab, setTab] = useState<TabId>("employees");
const [deptOpen, setDeptOpen] = useState(false);
const [employeeOpen, setEmployeeOpen] = useState(false);
const [periodOpen, setPeriodOpen] = useState(false);
const [lastPayroll, setLastPayroll] = useState<{
id: string;
gross_salary: string;
net_salary: string;
status: string;
} | null>(null);
const departmentsQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-departments"],
queryFn: () => accountingApi.payroll.listDepartments(tenantId!),
enabled: !!tenantId,
});
const employeesQ = useQuery({
queryKey: ["accounting", tenantId, "employees"],
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
enabled: !!tenantId,
});
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-periods"],
queryFn: () => accountingApi.payroll.listPeriods(tenantId!),
enabled: !!tenantId,
});
const deptForm = useForm<z.infer<typeof deptSchema>>({
resolver: zodResolver(deptSchema),
defaultValues: { code: "", name: "" },
});
const employeeForm = useForm<z.infer<typeof employeeSchema>>({
resolver: zodResolver(employeeSchema),
defaultValues: { employee_code: "", first_name: "", last_name: "", base_salary: "0", department_id: "" },
});
const periodForm = useForm<z.infer<typeof periodSchema>>({
resolver: zodResolver(periodSchema),
defaultValues: { name: "", start_date: "", end_date: "" },
});
const calcForm = useForm<z.infer<typeof calcSchema>>({
resolver: zodResolver(calcSchema),
defaultValues: { payroll_period_id: "", employee_id: "" },
});
const invalidateDepts = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payroll-departments"] });
const invalidateEmployees = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "employees"] });
const invalidatePeriods = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payroll-periods"] });
const createDeptM = useMutation({
mutationFn: (d: z.infer<typeof deptSchema>) => accountingApi.payroll.createDepartment(tenantId!, d),
onSuccess: async () => {
toast.success("واحد سازمانی ثبت شد");
setDeptOpen(false);
deptForm.reset();
await invalidateDepts();
},
onError: (e: Error) => toast.error(e.message),
});
const createEmployeeM = useMutation({
mutationFn: (d: z.infer<typeof employeeSchema>) =>
accountingApi.payroll.createEmployee(tenantId!, {
employee_code: d.employee_code,
first_name: d.first_name,
last_name: d.last_name,
base_salary: d.base_salary,
department_id: d.department_id || undefined,
}),
onSuccess: async () => {
toast.success("کارمند ثبت شد");
setEmployeeOpen(false);
employeeForm.reset();
await invalidateEmployees();
},
onError: (e: Error) => toast.error(e.message),
});
const createPeriodM = useMutation({
mutationFn: (d: z.infer<typeof periodSchema>) => accountingApi.payroll.createPeriod(tenantId!, d),
onSuccess: async () => {
toast.success("دوره حقوق ایجاد شد");
setPeriodOpen(false);
periodForm.reset();
await invalidatePeriods();
},
onError: (e: Error) => toast.error(e.message),
});
const calculateM = useMutation({
mutationFn: (d: z.infer<typeof calcSchema>) => accountingApi.payroll.calculate(tenantId!, d),
onSuccess: (res) => {
setLastPayroll(res);
toast.success("حقوق محاسبه شد");
},
onError: (e: Error) => toast.error(e.message),
});
const postM = useMutation({
mutationFn: (payrollId: string) => accountingApi.payroll.post(tenantId!, payrollId),
onSuccess: (res) => {
toast.success("حقوق ثبت حسابداری شد");
setLastPayroll((prev) => (prev ? { ...prev, status: res.status } : prev));
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId) return <LoadingState />;
const loading = departmentsQ.isLoading || employeesQ.isLoading || periodsQ.isLoading;
if (loading) return <LoadingState />;
const err = departmentsQ.error || employeesQ.error || periodsQ.error;
if (err) {
return (
<ErrorState
message={err instanceof Error ? err.message : "خطا"}
onRetry={() => {
void departmentsQ.refetch();
void employeesQ.refetch();
void periodsQ.refetch();
}}
/>
);
}
const tabActions: Record<TabId, React.ReactNode> = {
departments: (
<Button onClick={() => setDeptOpen(true)}>
<Plus className="h-4 w-4" />
واحد جدید
</Button>
),
employees: (
<Button onClick={() => setEmployeeOpen(true)}>
<Plus className="h-4 w-4" />
کارمند جدید
</Button>
),
periods: (
<Button onClick={() => setPeriodOpen(true)}>
<Plus className="h-4 w-4" />
دوره جدید
</Button>
),
operations: null,
};
const periodStatusTone = (s: string) =>
s === "open" ? "success" : s === "closed" ? "danger" : "default";
return (
<div>
<PageHeader
title="حقوق و دستمزد"
description="واحدها، کارکنان، دوره‌ها و ثبت حقوق."
actions={tabActions[tab]}
/>
<Tabs
className="mb-6"
value={tab}
onChange={(id) => setTab(id as TabId)}
items={[
{ id: "departments", label: "واحدها" },
{ id: "employees", label: "کارکنان" },
{ id: "periods", label: "دوره‌ها" },
{ id: "operations", label: "محاسبه و ثبت" },
]}
/>
{tab === "departments" && (
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
]}
rows={(departmentsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setDeptOpen(true)}>ثبت واحد</Button>} />}
/>
)}
{tab === "employees" && (
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
]}
rows={(employeesQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setEmployeeOpen(true)}>ثبت کارمند</Button>} />}
/>
)}
{tab === "periods" && (
<DataTable
columns={[
{ key: "name", header: "نام" },
{
key: "start_date",
header: "شروع",
render: (r) => <JalaliDateText value={String(r.start_date ?? "")} />,
},
{
key: "end_date",
header: "پایان",
render: (r) => <JalaliDateText value={String(r.end_date ?? "")} />,
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={periodStatusTone(String(r.status))}>{String(r.status)}</Badge>
),
},
]}
rows={(periodsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setPeriodOpen(true)}>ایجاد دوره</Button>} />}
/>
)}
{tab === "operations" && (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-4 w-4" />
محاسبه حقوق
</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-3" onSubmit={calcForm.handleSubmit((d) => calculateM.mutate(d))}>
<FormField label="دوره حقوق" error={calcForm.formState.errors.payroll_period_id?.message}>
<Select {...calcForm.register("payroll_period_id")}>
<option value="">انتخاب دوره</option>
{(periodsQ.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Select>
</FormField>
<FormField label="کارمند" error={calcForm.formState.errors.employee_id?.message}>
<Select {...calcForm.register("employee_id")}>
<option value="">انتخاب کارمند</option>
{(employeesQ.data ?? []).map((e) => (
<option key={e.id} value={e.id}>
{e.code} {e.name}
</option>
))}
</Select>
</FormField>
<Button type="submit" disabled={calculateM.isPending} className="w-full sm:w-auto">
محاسبه
</Button>
</form>
</CardContent>
</Card>
{lastPayroll && (
<Card>
<CardHeader>
<CardTitle>نتیجه محاسبه</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-[var(--muted)]">ناخالص</span>
<span className="font-medium">{formatMoney(lastPayroll.gross_salary)}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--muted)]">خالص</span>
<span className="font-medium">{formatMoney(lastPayroll.net_salary)}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--muted)]">وضعیت</span>
<Badge>{lastPayroll.status}</Badge>
</div>
<Button
type="button"
className="w-full"
disabled={postM.isPending || lastPayroll.status === "posted"}
onClick={() => postM.mutate(lastPayroll.id)}
>
ثبت حسابداری (Post)
</Button>
</CardContent>
</Card>
)}
</div>
)}
<Dialog open={deptOpen} onClose={() => setDeptOpen(false)} title="واحد سازمانی جدید">
<form className="space-y-3" onSubmit={deptForm.handleSubmit((d) => createDeptM.mutate(d))}>
<FormField label="کد" error={deptForm.formState.errors.code?.message}>
<Input {...deptForm.register("code")} />
</FormField>
<FormField label="نام" error={deptForm.formState.errors.name?.message}>
<Input {...deptForm.register("name")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDeptOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createDeptM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={employeeOpen} onClose={() => setEmployeeOpen(false)} title="کارمند جدید">
<form className="space-y-3" onSubmit={employeeForm.handleSubmit((d) => createEmployeeM.mutate(d))}>
<FormField label="کد پرسنلی" error={employeeForm.formState.errors.employee_code?.message}>
<Input {...employeeForm.register("employee_code")} />
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="نام" error={employeeForm.formState.errors.first_name?.message}>
<Input {...employeeForm.register("first_name")} />
</FormField>
<FormField label="نام خانوادگی" error={employeeForm.formState.errors.last_name?.message}>
<Input {...employeeForm.register("last_name")} />
</FormField>
</div>
<FormField label="واحد">
<Select {...employeeForm.register("department_id")}>
<option value="">بدون واحد</option>
{(departmentsQ.data ?? []).map((d) => (
<option key={d.id} value={d.id}>
{d.code} {d.name}
</option>
))}
</Select>
</FormField>
<FormField label="حقوق پایه">
<MoneyInput {...employeeForm.register("base_salary")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEmployeeOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createEmployeeM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={periodOpen} onClose={() => setPeriodOpen(false)} title="دوره حقوق جدید">
<form className="space-y-3" onSubmit={periodForm.handleSubmit((d) => createPeriodM.mutate(d))}>
<FormField label="نام دوره" error={periodForm.formState.errors.name?.message}>
<Input {...periodForm.register("name")} />
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="شروع" error={periodForm.formState.errors.start_date?.message}>
<Controller
control={periodForm.control}
name="start_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
<FormField label="پایان" error={periodForm.formState.errors.end_date?.message}>
<Controller
control={periodForm.control}
name="end_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setPeriodOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createPeriodM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}
export { PayrollPage as default } from "@/src/modules/accounting/pages/payroll";

View File

@ -1,3 +1,3 @@
"use client";
import { PayrollPaymentsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <PayrollPaymentsPage />; }
export { PayrollPaymentsPage as default } from "@/src/modules/accounting/pages/payroll/payments";

View File

@ -1,3 +1,3 @@
"use client";
import { PayrollPaymentsPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <PayrollPaymentsPage />; }
export { PayrollReportsPage as default } from "@/src/modules/accounting/pages/payroll/reports";

View File

@ -1,3 +1,3 @@
"use client";
import { PayrollStructuresPage } from "@/components/accounting/WorkflowScreens";
export default function Page() { return <PayrollStructuresPage />; }
export { PayrollStructuresPage as default } from "@/src/modules/accounting/pages/payroll/structures";

View File

@ -1,295 +1,3 @@
"use client";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Pencil, Archive } from "lucide-react";
import { accountingApi, type Project } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
ConfirmDialog,
Input,
Label,
FieldError,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Drawer,
DatePicker,
JalaliDateText,
} from "@/components/ds";
const createSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
start_date: z.string().optional(),
end_date: z.string().optional(),
});
const editSchema = z.object({
name: z.string().min(1, "نام الزامی است"),
start_date: z.string().optional(),
end_date: z.string().optional(),
});
export default function ProjectsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [selected, setSelected] = useState<Project | null>(null);
const [archiveId, setArchiveId] = useState<string | null>(null);
const createForm = useForm<z.infer<typeof createSchema>>({
resolver: zodResolver(createSchema),
defaultValues: { code: "", name: "", start_date: "", end_date: "" },
});
const editForm = useForm<z.infer<typeof editSchema>>({
resolver: zodResolver(editSchema),
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "projects"],
queryFn: () => accountingApi.projects.list(tenantId!),
enabled: !!tenantId,
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "projects"] });
};
const createM = useMutation({
mutationFn: (d: z.infer<typeof createSchema>) =>
accountingApi.projects.create(tenantId!, {
code: d.code,
name: d.name,
start_date: d.start_date || undefined,
end_date: d.end_date || undefined,
}),
onSuccess: async () => {
toast.success("پروژه ایجاد شد");
setCreateOpen(false);
createForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: z.infer<typeof editSchema>) =>
accountingApi.projects.update(tenantId!, selected!.id, {
name: d.name,
start_date: d.start_date || null,
end_date: d.end_date || null,
}),
onSuccess: async () => {
toast.success("پروژه به‌روز شد");
setSelected(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveM = useMutation({
mutationFn: (id: string) => accountingApi.projects.archive(tenantId!, id),
onSuccess: async () => {
toast.success("پروژه آرشیو شد");
setArchiveId(null);
setSelected(null);
await invalidate();
},
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="پروژه‌ها"
description="تعریف پروژه‌ها با بازه زمانی و آرشیو."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
پروژه جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "start_date",
header: "شروع",
render: (r) =>
r.start_date ? <JalaliDateText value={String(r.start_date)} /> : "—",
},
{
key: "end_date",
header: "پایان",
render: (r) => (r.end_date ? <JalaliDateText value={String(r.end_date)} /> : "—"),
},
{
key: "is_active",
header: "وضعیت",
render: (r) => (
<Badge tone={r.is_active ? "success" : "default"}>
{r.is_active ? "فعال" : "آرشیو"}
</Badge>
),
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const id = String(r.id);
const active = Boolean(r.is_active);
return active ? (
<div className="flex flex-wrap gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const p = listQ.data?.find((x) => x.id === id);
if (p) {
setSelected(p);
editForm.reset({
name: p.name,
start_date: p.start_date ?? "",
end_date: p.end_date ?? "",
});
}
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setArchiveId(id)}>
<Archive className="h-3.5 w-3.5" />
</Button>
</div>
) : (
"—"
);
},
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState title="پروژه‌ای ثبت نشده" action={<Button onClick={() => setCreateOpen(true)}>ایجاد</Button>} />
}
/>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="پروژه جدید">
<form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}>
<div>
<Label>کد</Label>
<Input {...createForm.register("code")} />
<FieldError>{createForm.formState.errors.code?.message}</FieldError>
</div>
<div>
<Label>نام</Label>
<Input {...createForm.register("name")} />
<FieldError>{createForm.formState.errors.name?.message}</FieldError>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>تاریخ شروع</Label>
<Controller
control={createForm.control}
name="start_date"
render={({ field }) => (
<DatePicker value={field.value ?? ""} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
<div>
<Label>تاریخ پایان</Label>
<Controller
control={createForm.control}
name="end_date"
render={({ field }) => (
<DatePicker value={field.value ?? ""} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Drawer
open={!!selected}
onClose={() => setSelected(null)}
title="ویرایش پروژه"
description={selected ? `${selected.code}` : undefined}
>
<form className="space-y-4" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...editForm.register("name")} />
<FieldError>{editForm.formState.errors.name?.message}</FieldError>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>تاریخ شروع</Label>
<Controller
control={editForm.control}
name="start_date"
render={({ field }) => (
<DatePicker value={field.value ?? ""} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
<div>
<Label>تاریخ پایان</Label>
<Controller
control={editForm.control}
name="end_date"
render={({ field }) => (
<DatePicker value={field.value ?? ""} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setSelected(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateM.isPending}>
ذخیره
</Button>
</div>
</form>
</Drawer>
<ConfirmDialog
open={!!archiveId}
onClose={() => setArchiveId(null)}
onConfirm={() => archiveId && archiveM.mutate(archiveId)}
title="آرشیو پروژه؟"
description="پروژه آرشیو شده در اسناد جدید قابل انتخاب نیست."
confirmLabel="آرشیو"
danger
loading={archiveM.isPending}
/>
</div>
);
}
export { ProjectsPage as default } from "@/src/modules/accounting/pages/projects";

View File

@ -1,19 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="رسید کالا"
description="ورود کالا به انبار مشخص — تأمین‌کننده بستانکار یا GRNI؛ پس از تأیید در صورت روشن بودن اسناد اتومات، سند دفتر کل صادر می‌شود."
createLabel="رسید کالای جدید"
module="purchase"
docType="goods_receipt"
partyLabel="تأمین‌کننده (مبدأ کالا)"
kind="receipt"
enableAccountingPost="purchase_gr"
enableWarehouseContext
/>
);
}
export { PurchaseGoodsReceiptsPage as default } from "@/src/modules/accounting/pages/purchase/goods-receipts";

View File

@ -1,17 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="فاکتور خرید"
createLabel="فاکتور خرید جدید"
module="purchase"
docType="invoice"
partyLabel="تأمین‌کننده"
kind="invoice"
enableAccountingPost="purchase_invoice"
/>
);
}
export { PurchaseInvoicesPage as default } from "@/src/modules/accounting/pages/purchase/invoices";

View File

@ -1,16 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="سفارش خرید"
createLabel="سفارش خرید جدید"
module="purchase"
docType="order"
partyLabel="تأمین‌کننده"
kind="order"
/>
);
}
export { PurchaseOrdersPage as default } from "@/src/modules/accounting/pages/purchase/orders";

View File

@ -1,47 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="پیش‌پرداخت‌ها"
description="پیش‌پرداخت به تأمین‌کننده — روش پرداخت و صندوق/حساب بانکی را مشخص کنید."
module="purchase"
docType="prepayment"
createLabel="پیش‌پرداخت جدید"
fields={[
{ key: "party", label: "تأمین‌کننده", kind: "party", module: "purchase" },
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "due_date", label: "سررسید", kind: "date" },
{
key: "method",
label: "روش پرداخت",
kind: "select",
options: [
{ value: "cash", label: "نقد / صندوق" },
{ value: "bank", label: "حواله بانکی" },
{ value: "cheque", label: "چک" },
],
},
{
key: "cash_box_id",
label: "صندوق",
kind: "cash_box",
showWhen: { key: "method", value: "cash" },
},
{
key: "bank_account_id",
label: "حساب بانکی",
kind: "bank_account",
showWhen: { key: "method", value: "bank" },
},
{
key: "cheque_number",
label: "شماره چک",
kind: "text",
showWhen: { key: "method", value: "cheque" },
required: false,
},
]}
/>
);
}
export { PurchasePrepaymentsPage as default } from "@/src/modules/accounting/pages/purchase/prepayments";

View File

@ -1,16 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="درخواست خرید"
createLabel="درخواست خرید جدید"
module="purchase"
docType="request"
partyLabel="تأمین‌کننده"
kind="request"
/>
);
}
export { PurchaseRequestsPage as default } from "@/src/modules/accounting/pages/purchase/requests";

View File

@ -1,417 +1,3 @@
"use client";
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney, parseMoneyInput } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
FormField,
DatePicker,
JalaliDateText,
Select,
Badge,
} from "@/components/ds";
type InvoiceLine = {
title: string;
qty: string;
unit_price: string;
note?: string;
item_id?: string;
};
type ReturnLine = InvoiceLine & {
return_qty: string;
selected: boolean;
invoice_line_index: number;
};
type FormValues = {
number: string;
doc_date: string;
source_invoice_id: string;
description: string;
};
function parseMeta(meta: unknown): {
lines?: InvoiceLine[];
source_invoice_id?: string;
source_invoice_number?: string;
accounting_voucher?: { voucher_number?: string };
} {
if (!meta) return {};
try {
const raw = typeof meta === "string" ? JSON.parse(meta) : meta;
return (raw && typeof raw === "object" ? raw : {}) as ReturnType<typeof parseMeta>;
} catch {
return {};
}
}
function lineAmount(qty: string, unitPrice: string): number {
return Number(parseMoneyInput(qty) || "0") * Number(parseMoneyInput(unitPrice) || "0");
}
export default function PurchaseReturnsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [returnLines, setReturnLines] = useState<ReturnLine[]>([]);
const returnsQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", "purchase", "return"],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, "purchase", "return"),
enabled: !!tenantId,
});
const invoicesQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", "purchase", "invoice"],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, "purchase", "invoice"),
enabled: !!tenantId,
});
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
useQuery({
queryKey: ["accounting", tenantId, "purchase-profiles-ensure"],
queryFn: () => accountingApi.purchaseInventory.ensureDefaultProfile(tenantId!),
enabled: !!tenantId,
staleTime: 60_000,
});
const form = useForm<FormValues>({
defaultValues: {
number: "",
doc_date: new Date().toISOString().slice(0, 10),
source_invoice_id: "",
description: "",
},
});
const invoiceId = form.watch("source_invoice_id");
const selectedInvoice = useMemo(
() => (invoicesQ.data ?? []).find((i) => i.id === invoiceId),
[invoicesQ.data, invoiceId]
);
const returnTotal = useMemo(
() =>
returnLines
.filter((l) => l.selected)
.reduce((sum, l) => sum + lineAmount(l.return_qty, l.unit_price), 0),
[returnLines]
);
const autoReturn = Boolean(policyQ.data?.auto_post?.purchase_return ?? true);
const onInvoiceChange = (id: string) => {
form.setValue("source_invoice_id", id);
const inv = (invoicesQ.data ?? []).find((i) => i.id === id);
if (!inv) {
setReturnLines([]);
return;
}
const meta = parseMeta(inv.meta_json);
const lines = meta.lines ?? [];
if (!lines.length) {
// Invoice without line items: treat whole amount as one returnable row
setReturnLines([
{
title: inv.description || `فاکتور ${inv.number}`,
qty: "1",
unit_price: String(inv.amount || "0"),
return_qty: "1",
selected: true,
invoice_line_index: 0,
},
]);
return;
}
setReturnLines(
lines.map((ln, idx) => ({
title: ln.title || "کالا",
qty: ln.qty || "1",
unit_price: ln.unit_price || "0",
note: ln.note,
item_id: ln.item_id,
return_qty: ln.qty || "1",
selected: true,
invoice_line_index: idx,
}))
);
};
const createM = useMutation({
mutationFn: async (d: FormValues) => {
if (!selectedInvoice) throw new Error("فاکتور خرید را انتخاب کنید");
const selected = returnLines.filter((l) => l.selected && Number(parseMoneyInput(l.return_qty) || "0") > 0);
if (!selected.length) throw new Error("حداقل یک کالا با تعداد مرجوعی مشخص کنید");
for (const ln of selected) {
const maxQty = Number(parseMoneyInput(ln.qty) || "0");
const retQty = Number(parseMoneyInput(ln.return_qty) || "0");
if (retQty > maxQty) {
throw new Error(`تعداد مرجوعی «${ln.title}» بیشتر از فاکتور است`);
}
}
const amount = String(returnTotal);
if (Number(amount) <= 0) throw new Error("مبلغ مرجوعی باید بزرگ‌تر از صفر باشد");
const linesPayload = selected.map((l) => ({
title: l.title,
qty: parseMoneyInput(l.return_qty) || "0",
unit_price: parseMoneyInput(l.unit_price) || "0",
note: l.note,
item_id: l.item_id,
invoice_line_index: l.invoice_line_index,
invoice_qty: l.qty,
}));
const doc = await accountingApi.ops.createDocument(tenantId!, {
module: "purchase",
doc_type: "return",
number: d.number?.trim() || undefined,
doc_date: d.doc_date,
party_name: selectedInvoice.party_name || undefined,
party_id: selectedInvoice.party_id || undefined,
amount,
description:
d.description ||
`مرجوعی از فاکتور ${selectedInvoice.number}` +
(selectedInvoice.party_name ? `${selectedInvoice.party_name}` : ""),
status: "draft",
meta_json: JSON.stringify({
source_invoice_id: selectedInvoice.id,
source_invoice_number: selectedInvoice.number,
party_name: selectedInvoice.party_name,
party_id: selectedInvoice.party_id,
lines: linesPayload,
}),
});
if (autoReturn) {
// Confirm triggers auto GL (Dr AP / Cr inventory) + supplier balance decrease
const confirmed = await accountingApi.ops.confirmDocument(tenantId!, String(doc.id));
return confirmed;
}
return doc;
},
onSuccess: async (res) => {
const meta =
res && typeof res === "object" && "meta_json" in res
? parseMeta((res as { meta_json?: string | null }).meta_json)
: {};
const vn = meta.accounting_voucher?.voucher_number || "";
toast.success(
vn
? `مرجوعی ثبت شد — سند ${vn} (کاهش بدهی تأمین‌کننده)`
: "مرجوعی ثبت شد"
);
setOpen(false);
form.reset();
setReturnLines([]);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "purchase", "return"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "suppliers"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || returnsQ.isLoading) return <LoadingState />;
if (returnsQ.error) return <ErrorState message={returnsQ.error.message} onRetry={() => returnsQ.refetch()} />;
const invoices = (invoicesQ.data ?? []).filter((i) => i.status !== "cancelled");
return (
<div>
<PageHeader
title="مرجوعی خرید"
description="فاکتور خرید را انتخاب کنید، کالاهای مرجوعی را مشخص کنید — سند اتومات بدهی تأمین‌کننده را کاهش می‌دهد (Dr حساب پرداختنی / Cr موجودی)."
actions={
<Button type="button" onClick={() => setOpen(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: "invoice",
header: "فاکتور مبدأ",
render: (r) => parseMeta(r.meta_json).source_invoice_number || "—",
},
{ key: "party_name", header: "تأمین‌کننده" },
{
key: "items",
header: "کالاها",
render: (r) => {
const lines = parseMeta(r.meta_json).lines ?? [];
if (!lines.length) return "—";
return lines.map((l) => `${l.title}×${l.qty}`).join("، ");
},
},
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount ?? "0")),
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={r.status === "posted" || r.status === "confirmed" ? "success" : "default"}>
{String(r.status)}
</Badge>
),
},
{
key: "voucher",
header: "سند GL",
render: (r) => parseMeta(r.meta_json).accounting_voucher?.voucher_number || "—",
},
]}
rows={(returnsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="مرجوعی ثبت نشده" description="از فاکتور خرید، مرجوعی ثبت کنید." />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="مرجوعی خرید از فاکتور" size="xl">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<FormField label="فاکتور خرید مبدأ" hint="مرجوعی باید به یک فاکتور خرید وصل باشد">
<Select
value={invoiceId}
onChange={(e) => onInvoiceChange(e.target.value)}
required
>
<option value="">انتخاب فاکتور</option>
{invoices.map((inv) => (
<option key={inv.id} value={inv.id}>
{inv.number} {inv.party_name || "بدون تأمین‌کننده"} {formatMoney(String(inv.amount))}
</option>
))}
</Select>
</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>
{selectedInvoice ? (
<div className="sm:col-span-2 rounded-xl border border-[var(--border)] bg-[var(--surface-2)] p-3 text-sm">
<div>
تأمینکننده: <strong>{selectedInvoice.party_name || "—"}</strong>
</div>
<div className="mt-1 text-[var(--muted)]">
فاکتور {selectedInvoice.number} مبلغ فاکتور {formatMoney(String(selectedInvoice.amount))}
</div>
</div>
) : null}
{returnLines.length > 0 ? (
<div className="sm:col-span-2 overflow-x-auto">
<table className="w-full min-w-[520px] text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-right text-[var(--muted)]">
<th className="p-2 font-medium">انتخاب</th>
<th className="p-2 font-medium">کالا (از فاکتور)</th>
<th className="p-2 font-medium">تعداد فاکتور</th>
<th className="p-2 font-medium">تعداد مرجوعی</th>
<th className="p-2 font-medium">فی</th>
<th className="p-2 font-medium">جمع</th>
</tr>
</thead>
<tbody>
{returnLines.map((ln, idx) => (
<tr key={idx} className="border-b border-[var(--border)]">
<td className="p-2">
<input
type="checkbox"
checked={ln.selected}
onChange={(e) => {
const next = [...returnLines];
next[idx] = { ...ln, selected: e.target.checked };
setReturnLines(next);
}}
/>
</td>
<td className="p-2">{ln.title}</td>
<td className="p-2" dir="ltr">
{ln.qty}
</td>
<td className="p-2">
<Input
value={ln.return_qty}
disabled={!ln.selected}
onChange={(e) => {
const next = [...returnLines];
next[idx] = { ...ln, return_qty: e.target.value };
setReturnLines(next);
}}
/>
</td>
<td className="p-2" dir="ltr">
{formatMoney(parseMoneyInput(ln.unit_price) || "0")}
</td>
<td className="p-2" dir="ltr">
{ln.selected
? formatMoney(String(lineAmount(ln.return_qty, ln.unit_price)))
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<FormField label="جمع مرجوعی">
<Input value={formatMoney(String(returnTotal))} readOnly dir="ltr" />
</FormField>
<FormField label="شماره مرجوعی (اختیاری)">
<Input {...form.register("number")} placeholder="خالی = خودکار" />
</FormField>
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input {...form.register("description")} placeholder="اختیاری" />
</FormField>
</div>
<div className="sm:col-span-2 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" loading={createM.isPending} disabled={!invoiceId || returnTotal <= 0}>
ثبت مرجوعی{autoReturn ? " + سند اتومات" : ""}
</Button>
</div>
</form>
</Dialog>
</div>
);
}
export { PurchaseReturnsPage as default } from "@/src/modules/accounting/pages/purchase/returns";

View File

@ -1,7 +1,3 @@
"use client";
import { SettlementsPage } from "@/components/accounting/DomainScreens";
export default function Page() {
return <SettlementsPage partyType="supplier" />;
}
export { PurchaseSettlementsPage as default } from "@/src/modules/accounting/pages/purchase/settlements";

View File

@ -1,663 +1,3 @@
"use client";
import { useMemo, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
LoadingState,
ErrorState,
Card,
CardContent,
CardHeader,
CardTitle,
Badge,
Select,
FormField,
DataTable,
EmptyState,
StatCard,
} from "@/components/ds";
type EngineKind = "trial-balance" | "balance-sheet" | "income-statement";
type LedgerReportKind =
| "subsidiary"
| "detail"
| "analytical"
| "cash-flow"
| "equity";
const ENGINE_LABELS: Record<EngineKind, string> = {
"trial-balance": "تراز آزمایشی",
"balance-sheet": "ترازنامه",
"income-statement": "سود و زیان",
};
const LEDGER_REPORT_META: Record<
LedgerReportKind,
{ title: string; description: string; needsAccount?: boolean }
> = {
subsidiary: {
title: "گزارش معین",
description: "مانده و گردش حساب‌های سطح معین بر اساس دفتر و اسناد ثبت‌شده.",
needsAccount: true,
},
detail: {
title: "گزارش تفصیلی",
description: "مانده و گردش حساب‌های سطح تفصیلی بر اساس دفتر و اسناد ثبت‌شده.",
needsAccount: true,
},
analytical: {
title: "گزارش تحلیلی",
description: "تجمیع مانده‌ها بر اساس نوع و دسته حساب از داده واقعی دفتر.",
},
"cash-flow": {
title: "جریان وجوه نقد",
description: "گردش حساب‌های نقدی/بانکی از مانده و اسناد ثبت‌شده.",
},
equity: {
title: "تغییرات حقوق صاحبان سهام",
description: "مانده و گردش حساب‌های حقوق صاحبان سهام در دوره انتخابی.",
},
};
const FIELD_LABELS: Record<string, string> = {
total_debit: "جمع بدهکار",
total_credit: "جمع بستانکار",
difference: "اختلاف",
is_balanced: "متوازن",
total_assets: "جمع دارایی‌ها",
account_count: "تعداد حساب",
total_revenue: "جمع درآمد",
total_expense: "جمع هزینه",
net: "خالص",
net_cash_change: "تغییر خالص نقد",
inflows: "ورودی‌ها",
outflows: "خروجی‌ها",
};
type BalanceRow = {
account_id: string;
account_code: string;
account_name: string;
account_type?: string;
account_category?: string;
level?: number;
opening_balance: string;
debit_total: string;
credit_total: string;
closing_balance: string;
};
type StatementLine = {
id: string;
entry_date: string;
description?: string | null;
debit: string;
credit: string;
running_balance: string;
entry_number?: string;
};
type LedgerReportData = {
mode?: string;
level_mode?: string;
account?: { id: string; code: string; name: string; level: number };
rows?: BalanceRow[];
lines?: StatementLine[];
totals?: Record<string, string | number>;
by_type?: Array<Record<string, string | number>>;
by_category?: Array<Record<string, string | number>>;
net_cash_change?: string;
inflows?: string;
outflows?: string;
};
function parseReportData(data: unknown): Record<string, unknown> | null {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data) as Record<string, unknown>;
} catch {
return { raw: data };
}
}
if (typeof data === "object" && !Array.isArray(data)) {
return data as Record<string, unknown>;
}
return { value: data };
}
function formatCellValue(key: string, value: unknown): React.ReactNode {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "بله" : "خیر";
if (
key.includes("total") ||
key.includes("difference") ||
key.includes("balance") ||
key === "net" ||
key.includes("amount") ||
key.includes("inflow") ||
key.includes("outflow") ||
key.includes("change")
) {
return formatMoney(String(value));
}
return String(value);
}
const MONEY_COLS = [
{
key: "opening_balance",
header: "مانده اول دوره",
render: (r: Record<string, unknown>) => formatMoney(String(r.opening_balance ?? "0")),
},
{
key: "debit_total",
header: "بدهکار",
render: (r: Record<string, unknown>) => formatMoney(String(r.debit_total ?? "0")),
},
{
key: "credit_total",
header: "بستانکار",
render: (r: Record<string, unknown>) => formatMoney(String(r.credit_total ?? "0")),
},
{
key: "closing_balance",
header: "مانده پایان",
render: (r: Record<string, unknown>) => formatMoney(String(r.closing_balance ?? "0")),
},
];
function EngineReports({ initialKind }: { initialKind?: string }) {
const { tenantId } = useTenantId();
const [periodId, setPeriodId] = useState("");
const [result, setResult] = useState<{
kind: EngineKind;
payload: { id: string; data: unknown };
} | null>(null);
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const runM = useMutation({
mutationFn: async (kind: EngineKind) => {
if (!periodId) throw new Error("دوره مالی را انتخاب کنید");
if (kind === "trial-balance") {
const payload = await accountingApi.reporting.trialBalance(tenantId!, periodId);
return { kind, payload };
}
if (kind === "balance-sheet") {
const payload = await accountingApi.reporting.balanceSheet(tenantId!, periodId);
return { kind, payload };
}
const payload = await accountingApi.reporting.incomeStatement(tenantId!, periodId);
return { kind, payload };
},
onSuccess: (data) => {
setResult(data);
toast.success("گزارش تولید شد");
},
onError: (e: Error) => toast.error(e.message),
});
const exportM = useMutation({
mutationFn: (format: "json" | "csv") =>
accountingApi.reporting.export(tenantId!, result!.payload.id, format),
onSuccess: (data) => {
toast.success(`خروجی ${data.format} ثبت شد`);
if (data.file_path) toast.message(data.file_path);
if (result) {
const blob = new Blob([JSON.stringify(result.payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `report-${result.payload.id}.json`;
a.click();
URL.revokeObjectURL(url);
}
},
onError: (e: Error) => toast.error(e.message),
});
const parsed = useMemo(
() => (result ? parseReportData(result.payload.data) : null),
[result]
);
const tableRows = useMemo(() => {
if (!parsed) return [];
return Object.entries(parsed).map(([key, value]) => ({
id: key,
field: FIELD_LABELS[key] ?? key,
value,
rawKey: key,
}));
}, [parsed]);
if (!tenantId || periodsQ.isLoading) return <LoadingState />;
if (periodsQ.error) {
return <ErrorState message={periodsQ.error.message} onRetry={() => periodsQ.refetch()} />;
}
const highlight =
initialKind === "trial-balance" ||
initialKind === "balance-sheet" ||
initialKind === "income-statement"
? (initialKind as EngineKind)
: null;
return (
<div>
<PageHeader
title="گزارش‌های مالی"
description="تراز آزمایشی، ترازنامه و سود و زیان — از Report Engine."
/>
<Card className="mb-6">
<CardContent className="grid gap-4 pt-6 sm:grid-cols-[1fr_auto] sm:items-end">
<FormField label="دوره مالی">
<Select value={periodId} onChange={(e) => setPeriodId(e.target.value)}>
<option value="">انتخاب دوره</option>
{(periodsQ.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Select>
</FormField>
<div className="flex flex-wrap gap-2">
{(Object.keys(ENGINE_LABELS) as EngineKind[]).map((kind) => (
<Button
key={kind}
type="button"
variant={highlight === kind ? "default" : "outline"}
disabled={runM.isPending || !periodId}
onClick={() => runM.mutate(kind)}
>
{ENGINE_LABELS[kind]}
</Button>
))}
</div>
</CardContent>
</Card>
{result ? (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2">
{ENGINE_LABELS[result.kind]}
<Badge tone="primary">زنده</Badge>
<span className="text-xs font-normal text-[var(--muted)]" dir="ltr">
ID: {result.payload.id}
</span>
<Button
type="button"
size="sm"
variant="outline"
className="ms-auto"
disabled={exportM.isPending}
onClick={() => exportM.mutate("json")}
>
خروجی / Export
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{parsed && tableRows.length > 0 ? (
<DataTable
columns={[
{ key: "field", header: "عنوان" },
{
key: "value",
header: "مقدار",
render: (r) => formatCellValue(String(r.rawKey), r.value),
},
]}
rows={tableRows as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState title="داده‌ای برای نمایش نیست" />
)}
<details className="rounded-xl border border-[var(--border)] bg-[var(--surface-muted)] p-3">
<summary className="cursor-pointer text-sm font-medium text-secondary">JSON خام</summary>
<pre
className="mt-3 max-h-[320px] overflow-auto text-xs leading-relaxed text-secondary"
dir="ltr"
>
{JSON.stringify(result.payload, null, 2)}
</pre>
</details>
</CardContent>
</Card>
) : (
<p className="text-sm text-[var(--muted)]">یک دوره انتخاب کنید و گزارش را اجرا کنید.</p>
)}
</div>
);
}
function LedgerReports({ kind }: { kind: LedgerReportKind }) {
const meta = LEDGER_REPORT_META[kind];
const { tenantId } = useTenantId();
const [periodId, setPeriodId] = useState("");
const [accountId, setAccountId] = useState("");
const [result, setResult] = useState<{ id: string; data: LedgerReportData } | null>(null);
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts", "report-picker", kind],
queryFn: () => accountingApi.accounts.list(tenantId!, 1, 500),
enabled: !!tenantId && !!meta.needsAccount,
});
const accountOptions = useMemo(() => {
const accounts = accountsQ.data ?? [];
if (kind === "subsidiary") {
const level3 = accounts.filter((a) => a.level === 3);
if (level3.length) return level3;
return accounts.filter((a) => a.is_postable && !a.is_group && a.level <= 3);
}
if (kind === "detail") {
const detail = accounts.filter((a) => a.level >= 4);
if (detail.length) return detail;
return accounts.filter((a) => a.is_postable && !a.is_group);
}
return accounts.filter((a) => a.is_postable && !a.is_group);
}, [accountsQ.data, kind]);
const runM = useMutation({
mutationFn: async () => {
if (!periodId) throw new Error("دوره مالی را انتخاب کنید");
const account = accountId || undefined;
if (kind === "subsidiary") {
return accountingApi.reporting.subsidiaryLedger(tenantId!, periodId, account);
}
if (kind === "detail") {
return accountingApi.reporting.detailLedger(tenantId!, periodId, account);
}
if (kind === "analytical") {
return accountingApi.reporting.analytical(tenantId!, periodId);
}
if (kind === "cash-flow") {
return accountingApi.reporting.cashFlow(tenantId!, periodId);
}
return accountingApi.reporting.equityStatement(tenantId!, periodId);
},
onSuccess: (payload) => {
const data = (parseReportData(payload.data) ?? {}) as LedgerReportData;
setResult({ id: payload.id, data });
toast.success("گزارش از داده دفتر تولید شد");
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || periodsQ.isLoading) return <LoadingState />;
if (periodsQ.error) {
return <ErrorState message={periodsQ.error.message} onRetry={() => periodsQ.refetch()} />;
}
const rows = result?.data.rows ?? [];
const lines = result?.data.lines ?? [];
const totals = result?.data.totals ?? {};
const byType = result?.data.by_type ?? [];
const byCategory = result?.data.by_category ?? [];
return (
<div>
<PageHeader title={meta.title} description={meta.description} />
<Card className="mb-6">
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_auto] lg:items-end">
<FormField label="دوره مالی">
<Select value={periodId} onChange={(e) => setPeriodId(e.target.value)}>
<option value="">انتخاب دوره</option>
{(periodsQ.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.is_current ? "(جاری)" : ""}
</option>
))}
</Select>
</FormField>
{meta.needsAccount ? (
<FormField label="حساب (اختیاری — برای گردش جزئیات)">
<Select value={accountId} onChange={(e) => setAccountId(e.target.value)}>
<option value="">همه حسابهای این سطح</option>
{accountOptions.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
) : (
<div />
)}
<Button
type="button"
disabled={runM.isPending || !periodId}
onClick={() => runM.mutate()}
>
{runM.isPending ? "در حال تولید…" : "اجرای گزارش"}
</Button>
</CardContent>
</Card>
{result ? (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="مانده اول دوره"
value={formatMoney(String(totals.opening_balance ?? "0"))}
/>
<StatCard label="جمع بدهکار" value={formatMoney(String(totals.debit_total ?? "0"))} />
<StatCard label="جمع بستانکار" value={formatMoney(String(totals.credit_total ?? "0"))} />
<StatCard
label="مانده پایان"
value={formatMoney(String(totals.closing_balance ?? "0"))}
/>
</div>
{kind === "cash-flow" ? (
<div className="grid gap-4 sm:grid-cols-3">
<StatCard
label="ورودی‌ها"
value={formatMoney(String(result.data.inflows ?? totals.debit_total ?? "0"))}
/>
<StatCard
label="خروجی‌ها"
value={formatMoney(String(result.data.outflows ?? totals.credit_total ?? "0"))}
/>
<StatCard
label="تغییر خالص نقد"
value={formatMoney(String(result.data.net_cash_change ?? "0"))}
/>
</div>
) : null}
{result.data.account ? (
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
گردش حساب
<Badge tone="primary">
{result.data.account.code} {result.data.account.name}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{lines.length ? (
<DataTable
columns={[
{ key: "entry_date", header: "تاریخ" },
{
key: "entry_number",
header: "شماره",
render: (r) => String(r.entry_number ?? "—"),
},
{
key: "description",
header: "شرح",
render: (r) => String(r.description ?? "—"),
},
{
key: "debit",
header: "بدهکار",
render: (r) => formatMoney(String(r.debit ?? "0")),
},
{
key: "credit",
header: "بستانکار",
render: (r) => formatMoney(String(r.credit ?? "0")),
},
{
key: "running_balance",
header: "مانده جاری",
render: (r) => formatMoney(String(r.running_balance ?? "0")),
},
]}
rows={lines as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState
title="گردشی برای این حساب در دوره نیست"
description="اسناد ثبت‌شده (Posted) روی این حساب در این دوره یافت نشد."
/>
)}
</CardContent>
</Card>
) : null}
{kind === "analytical" ? (
<>
<Card>
<CardHeader>
<CardTitle>بر اساس نوع حساب</CardTitle>
</CardHeader>
<CardContent>
{byType.length ? (
<DataTable
columns={[
{ key: "key", header: "نوع" },
{
key: "account_count",
header: "تعداد",
render: (r) => String(r.account_count ?? 0),
},
...MONEY_COLS,
]}
rows={byType as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState title="داده‌ای نیست" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>بر اساس دسته حساب</CardTitle>
</CardHeader>
<CardContent>
{byCategory.length ? (
<DataTable
columns={[
{ key: "key", header: "دسته" },
{
key: "account_count",
header: "تعداد",
render: (r) => String(r.account_count ?? 0),
},
...MONEY_COLS,
]}
rows={byCategory as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState title="داده‌ای نیست" />
)}
</CardContent>
</Card>
</>
) : null}
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2">
{result.data.mode === "statement" ? "مانده حساب انتخاب‌شده" : "خلاصه مانده حساب‌ها"}
<Badge tone="primary">زنده از دفتر</Badge>
<span className="text-xs font-normal text-[var(--muted)]" dir="ltr">
ID: {result.id}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{rows.length ? (
<DataTable
columns={[
{
key: "account_code",
header: "کد",
render: (r) => (
<span dir="ltr">{String(r.account_code ?? "")}</span>
),
},
{ key: "account_name", header: "نام حساب" },
{
key: "level",
header: "سطح",
render: (r) => String(r.level ?? "—"),
},
...MONEY_COLS,
]}
rows={rows as unknown as Record<string, unknown>[]}
/>
) : (
<EmptyState
title="حسابی برای نمایش نیست"
description="حساب‌های این سطح را در درخت حساب‌ها تعریف کنید و اسناد را ثبت (Post) کنید."
/>
)}
</CardContent>
</Card>
</div>
) : (
<p className="text-sm text-[var(--muted)]">
دوره مالی را انتخاب کنید و گزارش را اجرا کنید. داده از مانده دفتر و اسناد ثبتشده خوانده میشود نه فرم ساختگی.
</p>
)}
</div>
);
}
function ReportsRouter() {
const sp = useSearchParams();
const type = sp.get("type") || "";
if (type in LEDGER_REPORT_META) {
return <LedgerReports kind={type as LedgerReportKind} />;
}
return <EngineReports initialKind={type || undefined} />;
}
export default function ReportsPage() {
return (
<Suspense fallback={<LoadingState />}>
<ReportsRouter />
</Suspense>
);
}
export { ReportsPage as default } from "@/src/modules/accounting/pages/reports";

View File

@ -1,17 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="فاکتور فروش"
createLabel="فاکتور فروش جدید"
module="sales"
docType="invoice"
partyLabel="مشتری"
kind="invoice"
enableAccountingPost="sales"
/>
);
}
export { SalesInvoicesPage as default } from "@/src/modules/accounting/pages/sales/invoices";

View File

@ -1,25 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="فرصت‌های فروش"
description="ثبت فرصت فروش با احتمال و مبلغ برآوردی."
module="sales"
docType="opportunity"
createLabel="فرصت جدید"
fields={[
{ key: "party", label: "مشتری بالقوه", kind: "party", module: "sales" },
{ key: "amount", label: "مبلغ برآوردی", kind: "money" },
{ key: "probability", label: "احتمال (%)", kind: "text" },
{ key: "stage", label: "مرحله", kind: "select", options: [
{ value: "lead", label: "سرنخ" },
{ value: "qualified", label: "واجد شرایط" },
{ value: "proposal", label: "پیشنهاد" },
{ value: "won", label: "برنده" },
{ value: "lost", label: "از دست‌رفته" },
]},
]}
/>
);
}
export { SalesOpportunitiesPage as default } from "@/src/modules/accounting/pages/sales/opportunities";

View File

@ -1,16 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="سفارش فروش"
createLabel="سفارش فروش جدید"
module="sales"
docType="order"
partyLabel="مشتری"
kind="order"
/>
);
}
export { SalesOrdersPage as default } from "@/src/modules/accounting/pages/sales/orders";

View File

@ -1,47 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="پیش‌دریافت‌ها"
description="پیش‌دریافت از مشتری — روش دریافت و صندوق/حساب بانکی را مشخص کنید."
module="sales"
docType="pre_receipt"
createLabel="پیش‌دریافت جدید"
fields={[
{ key: "party", label: "مشتری", kind: "party", module: "sales" },
{ key: "amount", label: "مبلغ", kind: "money" },
{ key: "due_date", label: "سررسید", kind: "date" },
{
key: "method",
label: "روش دریافت",
kind: "select",
options: [
{ value: "cash", label: "نقد / صندوق" },
{ value: "bank", label: "حواله بانکی" },
{ value: "cheque", label: "چک" },
],
},
{
key: "cash_box_id",
label: "صندوق",
kind: "cash_box",
showWhen: { key: "method", value: "cash" },
},
{
key: "bank_account_id",
label: "حساب بانکی",
kind: "bank_account",
showWhen: { key: "method", value: "bank" },
},
{
key: "cheque_number",
label: "شماره چک",
kind: "text",
showWhen: { key: "method", value: "cheque" },
required: false,
},
]}
/>
);
}
export { SalesPreReceiptsPage as default } from "@/src/modules/accounting/pages/sales/pre-receipts";

View File

@ -1,16 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="پیش‌فاکتور"
createLabel="پیش‌فاکتور جدید"
module="sales"
docType="proforma"
partyLabel="مشتری"
kind="invoice"
/>
);
}
export { SalesProformasPage as default } from "@/src/modules/accounting/pages/sales/proformas";

View File

@ -1,17 +1,3 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
export default function Page() {
return (
<OperationalDocumentPage
title="مرجوعی فروش"
createLabel="مرجوعی فروش جدید"
module="sales"
docType="return"
partyLabel="مشتری"
kind="invoice"
enableAccountingPost="sales"
/>
);
}
export { SalesReturnsPage as default } from "@/src/modules/accounting/pages/sales/returns";

View File

@ -1,7 +1,3 @@
"use client";
import { SettlementsPage } from "@/components/accounting/DomainScreens";
export default function Page() {
return <SettlementsPage partyType="customer" />;
}
export { SalesSettlementsPage as default } from "@/src/modules/accounting/pages/sales/settlements";

View File

@ -1,187 +1,3 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
LoadingState,
ErrorState,
Card,
CardContent,
CardHeader,
CardTitle,
Button,
Checkbox,
Select,
Badge,
} from "@/components/ds";
const AUTO_KEYS: { key: string; label: string; hint: string }[] = [
{ key: "sales_invoice", label: "فاکتور فروش", hint: "با تأیید فاکتور فروش، سند GL خودکار صادر شود" },
{ key: "sales_return", label: "مرجوعی فروش", hint: "ثبت خودکار برگشت فروش در دفتر" },
{ key: "purchase_goods_receipt", label: "رسید کالا", hint: "Dr موجودی / Cr GRNI هنگام تأیید رسید" },
{ key: "purchase_invoice", label: "فاکتور خرید", hint: "ثبت بدهی تأمین‌کننده هنگام تأیید فاکتور" },
{ key: "purchase_return", label: "مرجوعی خرید", hint: "وصل به فاکتور خرید — کاهش بدهی تأمین‌کننده (Dr AP / Cr موجودی)" },
{ key: "treasury_receipt", label: "دریافت خزانه", hint: "دریافت نقدی/بانکی همیشه به Posting Engine" },
{ key: "treasury_payment", label: "پرداخت خزانه", hint: "پرداخت نقدی/بانکی همیشه به Posting Engine" },
{ key: "treasury_transfer", label: "انتقال بین حساب‌ها", hint: "از هر حساب به هر حساب — سند اتومات Dr مقصد / Cr مبدأ" },
{ key: "inventory_issue", label: "خروج / هزینه‌کرد کالا", hint: "خیرات، مصرف، هزینه، تهاتر با سند اتومات" },
{ key: "payroll", label: "حقوق و دستمزد", hint: "پس از محاسبه/تأیید حقوق" },
{ key: "assets_depreciation", label: "استهلاک دارایی", hint: "ثبت خودکار استهلاک دوره‌ای" },
];
const POLICY_KEYS: { key: string; label: string; hint: string }[] = [
{
key: "require_balanced",
label: "اجبار تراز بدهکار/بستانکار",
hint: "سند نامتوازن هرگز ذخیره یا قطعی نشود (استاندارد دوبل‌انتری)",
},
{
key: "require_source_reference",
label: "الزام مرجع سند مبدأ",
hint: "اسناد اتومات باید source_module و reference داشته باشند",
},
{
key: "lock_after_post",
label: "قفل پس از ثبت قطعی",
hint: "ویرایش مستقیم ممنوع؛ فقط برگشت از طریق Posting Engine",
},
{
key: "sequential_numbers",
label: "شماره‌گذاری متوالی",
hint: "استفاده از Document Number Sequence به‌جای شناسه تصادفی",
},
{
key: "idempotent_source",
label: "جلوگیری از ثبت تکراری",
hint: "یک سند مبدأ بیش از یک بار به GL نرود",
},
];
export default function AutoPostingSettingsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const saveM = useMutation({
mutationFn: (body: Parameters<typeof accountingApi.setup.updatePostingPolicy>[1]) =>
accountingApi.setup.updatePostingPolicy(tenantId!, body),
onSuccess: async () => {
toast.success("سیاست اسناد اتومات ذخیره شد");
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "posting-policy"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || policyQ.isLoading) return <LoadingState />;
if (policyQ.error) {
return <ErrorState message={policyQ.error.message} onRetry={() => policyQ.refetch()} />;
}
const policy = policyQ.data!;
const toggleAuto = (key: string, value: boolean) => {
saveM.mutate({
auto_post: { ...policy.auto_post, [key]: value },
});
};
const togglePolicy = (key: string, value: boolean) => {
saveM.mutate({ [key]: value } as Parameters<typeof accountingApi.setup.updatePostingPolicy>[1]);
};
return (
<div>
<PageHeader
title="اسناد اتوماتیک حسابداری"
description="کنترل ثبت خودکار در دفتر کل مطابق استانداردهای بین‌المللی دوبل‌انتری: تراز، قفل پس از ثبت، شماره متوالی، مرجع مبدأ و جلوگیری از ثبت تکراری."
/>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
حالت ثبت
<Badge tone="primary">{policy.posting_mode === "direct_post" ? "ثبت مستقیم" : "پیش‌نویس سپس قطعی"}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="max-w-md space-y-2">
<Select
value={policy.posting_mode}
disabled={saveM.isPending}
onChange={(e) => saveM.mutate({ posting_mode: e.target.value })}
>
<option value="direct_post">ثبت مستقیم در دفتر (پس از تأیید سند عملیاتی)</option>
<option value="draft_then_post">ایجاد پیشنویس و قطعیسازی جداگانه</option>
</Select>
<p className="text-xs text-[var(--muted)]">
در حالت مستقیم، با تأیید سند عملیاتی (اگر ماژول روشن باشد) سند GL بلافاصله از طریق Posting Engine صادر میشود.
</p>
</CardContent>
</Card>
<div className="mb-6 grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>قواعد الزامی (Compliance)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{POLICY_KEYS.map((item) => (
<div key={item.key} className="flex items-start gap-3 rounded-xl border border-[var(--border)] p-3">
<Checkbox
checked={Boolean((policy as Record<string, unknown>)[item.key])}
disabled={saveM.isPending}
onChange={(e) => togglePolicy(item.key, e.target.checked)}
aria-label={item.label}
/>
<span>
<span className="block text-sm font-medium text-secondary">{item.label}</span>
<span className="mt-0.5 block text-xs text-[var(--muted)]">{item.hint}</span>
</span>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>ثبت خودکار به تفکیک ماژول</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{AUTO_KEYS.map((item) => (
<div key={item.key} className="flex items-start gap-3 rounded-xl border border-[var(--border)] p-3">
<Checkbox
checked={Boolean(policy.auto_post?.[item.key])}
disabled={saveM.isPending}
onChange={(e) => toggleAuto(item.key, e.target.checked)}
aria-label={item.label}
/>
<span>
<span className="block text-sm font-medium text-secondary">{item.label}</span>
<span className="mt-0.5 block text-xs text-[var(--muted)]">{item.hint}</span>
</span>
</div>
))}
</CardContent>
</Card>
</div>
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6">
<p className="text-sm text-[var(--muted)]">
قلب حسابداری فقط از مسیر Posting Engine میگذرد (ADR-010). تنظیمات بالا فقط زمان و شرط صدور را کنترل میکنند.
</p>
<Button type="button" variant="outline" disabled={saveM.isPending} onClick={() => policyQ.refetch()}>
بازخوانی
</Button>
</CardContent>
</Card>
</div>
);
}
export { AutoPostingSettingsPage as default } from "@/src/modules/accounting/pages/settings/auto-posting";

View File

@ -1,18 +1,3 @@
"use client";
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
export default function Page() {
return (
<SpecializedOpsPage
title="پشتیبان‌گیری"
description="پشتیبان‌گیری — پارامترهای مستأجر در لایه عملیاتی حسابداری."
module="settings"
docType="backup"
createLabel="ثبت پشتیبان‌گیری"
fields={[
{ key: "setting_key", label: "کلید تنظیم", kind: "text" },
{ key: "setting_value", label: "مقدار", kind: "text" },
{ key: "amount", label: "مقدار عددی (اختیاری)", kind: "money", required: false },
]}
/>
);
}
export { SettingsBackupPage as default } from "@/src/modules/accounting/pages/settings/backup";

Some files were not shown because too many files have changed in this diff Show More