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

256 lines
8.9 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 { 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, 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>
);
}