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>
423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|