TorbatYar/frontend/app/accounting/cost-centers/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

272 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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