TorbatYar/frontend/app/accounting/assets/page.tsx
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 15:26:43 +03:30

423 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { 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>
);
}