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:
parent
071c484530
commit
6f4a484051
@ -2,7 +2,7 @@
|
|||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["app/accounting/**/*", "components/accounting/**/*"],
|
"files": ["app/accounting/**/*", "src/modules/accounting/**/*"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
@ -13,7 +13,9 @@
|
|||||||
"@/components/beauty/**",
|
"@/components/beauty/**",
|
||||||
"@/components/healthcare/**",
|
"@/components/healthcare/**",
|
||||||
"@/modules/beauty/**",
|
"@/modules/beauty/**",
|
||||||
"@/modules/healthcare/**"
|
"@/modules/healthcare/**",
|
||||||
|
"@/src/modules/beauty/**",
|
||||||
|
"@/src/modules/healthcare/**"
|
||||||
],
|
],
|
||||||
"message": "Accounting must not import beauty or healthcare modules."
|
"message": "Accounting must not import beauty or healthcare modules."
|
||||||
}
|
}
|
||||||
@ -34,7 +36,9 @@
|
|||||||
"@/components/accounting/**",
|
"@/components/accounting/**",
|
||||||
"@/components/healthcare/**",
|
"@/components/healthcare/**",
|
||||||
"@/modules/accounting/**",
|
"@/modules/accounting/**",
|
||||||
"@/modules/healthcare/**"
|
"@/modules/healthcare/**",
|
||||||
|
"@/src/modules/accounting/**",
|
||||||
|
"@/src/modules/healthcare/**"
|
||||||
],
|
],
|
||||||
"message": "Beauty must not import accounting or healthcare modules."
|
"message": "Beauty must not import accounting or healthcare modules."
|
||||||
}
|
}
|
||||||
@ -56,7 +60,8 @@
|
|||||||
"@/components/beauty/**",
|
"@/components/beauty/**",
|
||||||
"@/modules/beauty/**",
|
"@/modules/beauty/**",
|
||||||
"@/modules/accounting/**",
|
"@/modules/accounting/**",
|
||||||
"@/src/modules/beauty/**"
|
"@/src/modules/beauty/**",
|
||||||
|
"@/src/modules/accounting/**"
|
||||||
],
|
],
|
||||||
"message": "Healthcare must not import accounting or beauty modules."
|
"message": "Healthcare must not import accounting or beauty modules."
|
||||||
}
|
}
|
||||||
@ -79,7 +84,9 @@
|
|||||||
"@/components/healthcare/**",
|
"@/components/healthcare/**",
|
||||||
"@/modules/beauty/**",
|
"@/modules/beauty/**",
|
||||||
"@/modules/healthcare/**",
|
"@/modules/healthcare/**",
|
||||||
"@/modules/accounting/**"
|
"@/modules/accounting/**",
|
||||||
|
"@/src/modules/accounting/**",
|
||||||
|
"@/src/modules/healthcare/**"
|
||||||
],
|
],
|
||||||
"message": "CRM must not import accounting, beauty, or healthcare modules."
|
"message": "CRM must not import accounting, beauty, or healthcare modules."
|
||||||
}
|
}
|
||||||
@ -93,6 +100,7 @@
|
|||||||
"components/ds/**/*",
|
"components/ds/**/*",
|
||||||
"shared/**/*",
|
"shared/**/*",
|
||||||
"components/providers/**/*",
|
"components/providers/**/*",
|
||||||
|
"src/shared/**/*",
|
||||||
"components/AuthGuard.tsx",
|
"components/AuthGuard.tsx",
|
||||||
"components/AdminAuthGuard.tsx",
|
"components/AdminAuthGuard.tsx",
|
||||||
"components/ThemeProvider.tsx",
|
"components/ThemeProvider.tsx",
|
||||||
@ -111,6 +119,9 @@
|
|||||||
"@/components/healthcare/**",
|
"@/components/healthcare/**",
|
||||||
"@/modules/beauty/**",
|
"@/modules/beauty/**",
|
||||||
"@/modules/healthcare/**",
|
"@/modules/healthcare/**",
|
||||||
|
"@/modules/hospitality/**",
|
||||||
|
"@/src/modules/healthcare/**",
|
||||||
|
"@/src/modules/accounting/**",
|
||||||
"@/modules/accounting/**",
|
"@/modules/accounting/**",
|
||||||
"@/modules/crm/**"
|
"@/modules/crm/**"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
|
export { AiAnalyticsPage as default } from "@/src/modules/accounting/pages/ai/analytics";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <BlockedModulePage title="تحلیل هوشمند" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
|
export { AiAnomaliesPage as default } from "@/src/modules/accounting/pages/ai/anomalies";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <BlockedModulePage title="کشف ناهنجاری" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
|
export { AiDocumentsPage as default } from "@/src/modules/accounting/pages/ai/documents";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <BlockedModulePage title="تحلیل اسناد" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
|
export { AiForecastsPage as default } from "@/src/modules/accounting/pages/ai/forecasts";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <BlockedModulePage title="پیشبینیها" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
|
export { AiPage as default } from "@/src/modules/accounting/pages/ai";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <BlockedModulePage title="دستیار حسابداری" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
|
export { AiQaPage as default } from "@/src/modules/accounting/pages/ai/qa";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <BlockedModulePage title="پرسش و پاسخ" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
|
export { AiRecommendationsPage as default } from "@/src/modules/accounting/pages/ai/recommendations";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <BlockedModulePage title="پیشنهادهای هوشمند" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BlockedModulePage } from "@/components/accounting/BusinessDocsPage";
|
export { AiReconciliationPage as default } from "@/src/modules/accounting/pages/ai/reconciliation";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <BlockedModulePage title="تطبیق هوشمند" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
"use client";
|
||||||
|
|
||||||
export default function Page() {
|
export { AssetsDashboardPage as default } from "@/src/modules/accounting/pages/assets/dashboard";
|
||||||
redirect("/accounting/assets");
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
"use client";
|
||||||
|
|
||||||
export default function Page() {
|
export { AssetsDepreciatePage as default } from "@/src/modules/accounting/pages/assets/depreciate";
|
||||||
redirect("/accounting/assets");
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,422 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
export { AssetsPage as default } from "@/src/modules/accounting/pages/assets";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { AssetSchedulePage } from "@/components/accounting/DomainScreens";
|
"use client";
|
||||||
|
|
||||||
export default function Page() {
|
export { AssetsSchedulePage as default } from "@/src/modules/accounting/pages/assets/schedule";
|
||||||
return <AssetSchedulePage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,136 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
export { AuditPage as default } from "@/src/modules/accounting/pages/audit";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { BudgetControlPage as default } from "@/src/modules/accounting/pages/budget/control";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { BudgetDefinitionsPage as default } from "@/src/modules/accounting/pages/budget/definitions";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { BudgetForecastPage as default } from "@/src/modules/accounting/pages/budget/forecast";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { BudgetOperationalPage as default } from "@/src/modules/accounting/pages/budget/operational";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { BudgetPerformancePage as default } from "@/src/modules/accounting/pages/budget/performance";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { BudgetReportsPage as default } from "@/src/modules/accounting/pages/budget/reports";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { BudgetScenariosPage as default } from "@/src/modules/accounting/pages/budget/scenarios";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { BudgetVariancePage as default } from "@/src/modules/accounting/pages/budget/variance";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,644 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
export { ChartOfAccountsPage as default } from "@/src/modules/accounting/pages/chart-of-accounts";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { CompliancePoliciesPage } from "@/components/accounting/DomainScreens";
|
"use client";
|
||||||
|
|
||||||
export default function Page() {
|
export { CompliancePoliciesPage as default } from "@/src/modules/accounting/pages/compliance/policies";
|
||||||
return <CompliancePoliciesPage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { ComplianceRisksPage } from "@/components/accounting/DomainScreens";
|
"use client";
|
||||||
|
|
||||||
export default function Page() {
|
export { ComplianceRisksPage as default } from "@/src/modules/accounting/pages/compliance/risks";
|
||||||
return <ComplianceRisksPage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,271 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
export { CostCentersPage as default } from "@/src/modules/accounting/pages/cost-centers";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,255 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
export { CurrenciesPage as default } from "@/src/modules/accounting/pages/currencies";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,409 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
export { CustomersPage as default } from "@/src/modules/accounting/pages/customers";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { DocumentsCategoriesPage as default } from "@/src/modules/accounting/pages/documents/categories";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { DocumentsFinancialPage as default } from "@/src/modules/accounting/pages/documents/financial";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { DocumentsHrPage as default } from "@/src/modules/accounting/pages/documents/hr";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { DocumentsPurchasePage as default } from "@/src/modules/accounting/pages/documents/purchase";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { DocumentsRetentionPage as default } from "@/src/modules/accounting/pages/documents/retention";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { DocumentsSalesPage as default } from "@/src/modules/accounting/pages/documents/sales";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { DocumentsSearchPage as default } from "@/src/modules/accounting/pages/documents/search";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { DocumentsVersionsPage as default } from "@/src/modules/accounting/pages/documents/versions";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,579 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
export { FiscalPage as default } from "@/src/modules/accounting/pages/fiscal";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { IntegrationBankPage as default } from "@/src/modules/accounting/pages/integration/bank";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { IntegrationCrmPage as default } from "@/src/modules/accounting/pages/integration/crm";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { IntegrationGatewayPage as default } from "@/src/modules/accounting/pages/integration/gateway";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { IntegrationImportExportPage as default } from "@/src/modules/accounting/pages/integration/import-export";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { IntegrationInventoryPage as default } from "@/src/modules/accounting/pages/integration/inventory";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { IntegrationPayrollPage as default } from "@/src/modules/accounting/pages/integration/payroll";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { IntegrationReportsPage as default } from "@/src/modules/accounting/pages/integration/reports";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { IntegrationWebhooksPage as default } from "@/src/modules/accounting/pages/integration/webhooks";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { InventoryAdjustmentsPage as default } from "@/src/modules/accounting/pages/inventory/adjustments";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="تعدیل موجودی"
|
|
||||||
createLabel="تعدیل جدید"
|
|
||||||
module="inventory"
|
|
||||||
docType="adjustment"
|
|
||||||
partyLabel="انبار"
|
|
||||||
kind="adjustment"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,342 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
export { InventoryIssuesPage as default } from "@/src/modules/accounting/pages/inventory/issues";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { InventoryItemsPage } from "@/components/accounting/BusinessDocsPage";
|
export { InventoryItemsPage as default } from "@/src/modules/accounting/pages/inventory/items";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <InventoryItemsPage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,23 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { InventoryMovementsPage as default } from "@/src/modules/accounting/pages/inventory/movements";
|
||||||
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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,18 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { InventoryReceiptsIssuesPage as default } from "@/src/modules/accounting/pages/inventory/receipts-issues";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="رسید ورود و خروج"
|
|
||||||
description="طرف حساب و انبار جداگانه مشخص میشوند — ورود یا خروج کالا به/از انبار."
|
|
||||||
createLabel="رسید جدید"
|
|
||||||
module="inventory"
|
|
||||||
docType="receipt_issue"
|
|
||||||
partyLabel="طرف حساب"
|
|
||||||
kind="receipt"
|
|
||||||
enableWarehouseContext
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,8 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { InventoryValuationPage } from "@/components/accounting/DomainScreens";
|
export { InventoryReportsPage as default } from "@/src/modules/accounting/pages/inventory/reports";
|
||||||
|
|
||||||
/** گزارشهای انبار — از همان موتور ارزشگذاری و لیست موجودی استفاده میکند. */
|
|
||||||
export default function Page() {
|
|
||||||
return <InventoryValuationPage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { WarehousesPage } from "@/components/accounting/BusinessDocsPage";
|
export { InventorySettingsPage as default } from "@/src/modules/accounting/pages/inventory/settings";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <WarehousesPage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { InventoryTransfersPage as default } from "@/src/modules/accounting/pages/inventory/transfers";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="انتقال انبار"
|
|
||||||
createLabel="انتقال جدید"
|
|
||||||
module="inventory"
|
|
||||||
docType="transfer"
|
|
||||||
partyLabel="انبار مبدأ/مقصد"
|
|
||||||
kind="transfer"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { InventoryValuationPage } from "@/components/accounting/DomainScreens";
|
"use client";
|
||||||
|
|
||||||
export default function Page() {
|
export { InventoryValuationPage as default } from "@/src/modules/accounting/pages/inventory/valuation";
|
||||||
return <InventoryValuationPage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,72 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AuthGuard } from "@/components/AuthGuard";
|
import { AuthGuard } from "@/components/AuthGuard";
|
||||||
import { AccountingShell } from "@/components/accounting/AccountingShell";
|
import { AccountingLayoutGate } from "@/src/modules/accounting/layout/AccountingLayoutGate";
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AccountingLayout({ children }: { children: React.ReactNode }) {
|
export default function AccountingLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<AccountingGate>{children}</AccountingGate>
|
<AccountingLayoutGate>{children}</AccountingLayoutGate>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,219 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
export { LedgerPage as default } from "@/src/modules/accounting/pages/ledger";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,22 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { MonitoringAlertsPage as default } from "@/src/modules/accounting/pages/monitoring/alerts";
|
||||||
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: "بحرانی" },
|
|
||||||
]},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { MonitoringHealthPage } from "@/components/accounting/DomainScreens";
|
"use client";
|
||||||
|
|
||||||
export default function Page() {
|
export { MonitoringHealthPage as default } from "@/src/modules/accounting/pages/monitoring/health";
|
||||||
return <MonitoringHealthPage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,22 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { MonitoringKpisPage as default } from "@/src/modules/accounting/pages/monitoring/kpis";
|
||||||
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: "بحرانی" },
|
|
||||||
]},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { MonitoringHealthPage } from "@/components/accounting/DomainScreens";
|
|
||||||
export default function Page() { return <MonitoringHealthPage />; }
|
export { MonitoringPage as default } from "@/src/modules/accounting/pages/monitoring";
|
||||||
|
|||||||
@ -1,22 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { MonitoringPerformancePage as default } from "@/src/modules/accounting/pages/monitoring/performance";
|
||||||
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: "بحرانی" },
|
|
||||||
]},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,178 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
export { AccountingDashboardPage as default } from "@/src/modules/accounting/pages/dashboard";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { PayrollAllocationPage as default } from "@/src/modules/accounting/pages/payroll/allocation";
|
||||||
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" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { PayrollEmployeesPage } from "@/components/accounting/DomainScreens";
|
"use client";
|
||||||
|
|
||||||
export default function Page() {
|
export { PayrollEmployeesPage as default } from "@/src/modules/accounting/pages/payroll/employees";
|
||||||
return <PayrollEmployeesPage />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,457 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
export { PayrollPage as default } from "@/src/modules/accounting/pages/payroll";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"use client";
|
"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";
|
||||||
|
|||||||
@ -1,295 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
export { ProjectsPage as default } from "@/src/modules/accounting/pages/projects";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,19 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { PurchaseGoodsReceiptsPage as default } from "@/src/modules/accounting/pages/purchase/goods-receipts";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="رسید کالا"
|
|
||||||
description="ورود کالا به انبار مشخص — تأمینکننده بستانکار یا GRNI؛ پس از تأیید در صورت روشن بودن اسناد اتومات، سند دفتر کل صادر میشود."
|
|
||||||
createLabel="رسید کالای جدید"
|
|
||||||
module="purchase"
|
|
||||||
docType="goods_receipt"
|
|
||||||
partyLabel="تأمینکننده (مبدأ کالا)"
|
|
||||||
kind="receipt"
|
|
||||||
enableAccountingPost="purchase_gr"
|
|
||||||
enableWarehouseContext
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { PurchaseInvoicesPage as default } from "@/src/modules/accounting/pages/purchase/invoices";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="فاکتور خرید"
|
|
||||||
createLabel="فاکتور خرید جدید"
|
|
||||||
module="purchase"
|
|
||||||
docType="invoice"
|
|
||||||
partyLabel="تأمینکننده"
|
|
||||||
kind="invoice"
|
|
||||||
enableAccountingPost="purchase_invoice"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { PurchaseOrdersPage as default } from "@/src/modules/accounting/pages/purchase/orders";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="سفارش خرید"
|
|
||||||
createLabel="سفارش خرید جدید"
|
|
||||||
module="purchase"
|
|
||||||
docType="order"
|
|
||||||
partyLabel="تأمینکننده"
|
|
||||||
kind="order"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,47 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { PurchasePrepaymentsPage as default } from "@/src/modules/accounting/pages/purchase/prepayments";
|
||||||
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,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { PurchaseRequestsPage as default } from "@/src/modules/accounting/pages/purchase/requests";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="درخواست خرید"
|
|
||||||
createLabel="درخواست خرید جدید"
|
|
||||||
module="purchase"
|
|
||||||
docType="request"
|
|
||||||
partyLabel="تأمینکننده"
|
|
||||||
kind="request"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,417 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
export { PurchaseReturnsPage as default } from "@/src/modules/accounting/pages/purchase/returns";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SettlementsPage } from "@/components/accounting/DomainScreens";
|
export { PurchaseSettlementsPage as default } from "@/src/modules/accounting/pages/purchase/settlements";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <SettlementsPage partyType="supplier" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,663 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, Suspense } from "react";
|
export { ReportsPage as default } from "@/src/modules/accounting/pages/reports";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { SalesInvoicesPage as default } from "@/src/modules/accounting/pages/sales/invoices";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="فاکتور فروش"
|
|
||||||
createLabel="فاکتور فروش جدید"
|
|
||||||
module="sales"
|
|
||||||
docType="invoice"
|
|
||||||
partyLabel="مشتری"
|
|
||||||
kind="invoice"
|
|
||||||
enableAccountingPost="sales"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,25 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { SalesOpportunitiesPage as default } from "@/src/modules/accounting/pages/sales/opportunities";
|
||||||
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: "از دسترفته" },
|
|
||||||
]},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { SalesOrdersPage as default } from "@/src/modules/accounting/pages/sales/orders";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="سفارش فروش"
|
|
||||||
createLabel="سفارش فروش جدید"
|
|
||||||
module="sales"
|
|
||||||
docType="order"
|
|
||||||
partyLabel="مشتری"
|
|
||||||
kind="order"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,47 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { SalesPreReceiptsPage as default } from "@/src/modules/accounting/pages/sales/pre-receipts";
|
||||||
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,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { SalesProformasPage as default } from "@/src/modules/accounting/pages/sales/proformas";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="پیشفاکتور"
|
|
||||||
createLabel="پیشفاکتور جدید"
|
|
||||||
module="sales"
|
|
||||||
docType="proforma"
|
|
||||||
partyLabel="مشتری"
|
|
||||||
kind="invoice"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
|
export { SalesReturnsPage as default } from "@/src/modules/accounting/pages/sales/returns";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<OperationalDocumentPage
|
|
||||||
title="مرجوعی فروش"
|
|
||||||
createLabel="مرجوعی فروش جدید"
|
|
||||||
module="sales"
|
|
||||||
docType="return"
|
|
||||||
partyLabel="مشتری"
|
|
||||||
kind="invoice"
|
|
||||||
enableAccountingPost="sales"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SettlementsPage } from "@/components/accounting/DomainScreens";
|
export { SalesSettlementsPage as default } from "@/src/modules/accounting/pages/sales/settlements";
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <SettlementsPage partyType="customer" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,187 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
export { AutoPostingSettingsPage as default } from "@/src/modules/accounting/pages/settings/auto-posting";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,18 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage";
|
|
||||||
export default function Page() {
|
export { SettingsBackupPage as default } from "@/src/modules/accounting/pages/settings/backup";
|
||||||
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 },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user