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