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>
272 lines
9.1 KiB
TypeScript
272 lines
9.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|