Wire nested nav pages for phases 5.1-5.11 to real list/create endpoints, fix COA import connectivity via same-origin proxy, and add operational migration. Co-authored-by: Cursor <cursoragent@cursor.com>
1048 lines
36 KiB
TypeScript
1048 lines
36 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { Controller, 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 } from "lucide-react";
|
||
import { accountingApi } from "@/lib/accounting-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { formatMoney } from "@/lib/utils";
|
||
import {
|
||
PageHeader,
|
||
Button,
|
||
Dialog,
|
||
Input,
|
||
DataTable,
|
||
EmptyState,
|
||
LoadingState,
|
||
ErrorState,
|
||
Badge,
|
||
FormField,
|
||
MoneyInput,
|
||
DatePicker,
|
||
JalaliDateText,
|
||
ConfirmDialog,
|
||
Tabs,
|
||
Select,
|
||
} from "@/components/ds";
|
||
|
||
const schema = z.object({
|
||
number: z.string().min(1, "شماره الزامی است"),
|
||
doc_date: z.string().min(1, "تاریخ الزامی است"),
|
||
party_name: z.string().optional(),
|
||
amount: z.string().min(1, "مبلغ الزامی است"),
|
||
description: z.string().optional(),
|
||
});
|
||
|
||
type FormValues = z.infer<typeof schema>;
|
||
|
||
export function BusinessDocsPage({
|
||
title,
|
||
description,
|
||
module,
|
||
docType,
|
||
}: {
|
||
title: string;
|
||
description?: string;
|
||
module: string;
|
||
docType: string;
|
||
}) {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "ops-docs", module, docType],
|
||
queryFn: () => accountingApi.ops.listDocuments(tenantId!, module, docType),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const form = useForm<FormValues>({
|
||
resolver: zodResolver(schema),
|
||
defaultValues: {
|
||
number: "",
|
||
doc_date: new Date().toISOString().slice(0, 10),
|
||
party_name: "",
|
||
amount: "0",
|
||
description: "",
|
||
},
|
||
});
|
||
|
||
const createM = useMutation({
|
||
mutationFn: (d: FormValues) =>
|
||
accountingApi.ops.createDocument(tenantId!, {
|
||
module,
|
||
doc_type: docType,
|
||
number: d.number,
|
||
doc_date: d.doc_date,
|
||
party_name: d.party_name || undefined,
|
||
amount: d.amount,
|
||
description: d.description || undefined,
|
||
status: "draft",
|
||
}),
|
||
onSuccess: async () => {
|
||
toast.success("ثبت شد");
|
||
setOpen(false);
|
||
form.reset({
|
||
number: "",
|
||
doc_date: new Date().toISOString().slice(0, 10),
|
||
party_name: "",
|
||
amount: "0",
|
||
description: "",
|
||
});
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const confirmM = useMutation({
|
||
mutationFn: (id: string) => accountingApi.ops.confirmDocument(tenantId!, id),
|
||
onSuccess: async () => {
|
||
toast.success("تأیید شد");
|
||
setConfirmId(null);
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
|
||
},
|
||
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={title}
|
||
description={description || "لیست و ثبت — متصل به API واقعی حسابداری."}
|
||
actions={
|
||
<Button type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
ثبت جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<DataTable
|
||
columns={[
|
||
{ key: "number", header: "شماره" },
|
||
{
|
||
key: "doc_date",
|
||
header: "تاریخ",
|
||
render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} />,
|
||
},
|
||
{ key: "party_name", header: "طرف حساب" },
|
||
{
|
||
key: "amount",
|
||
header: "مبلغ",
|
||
render: (r) => formatMoney(String(r.amount ?? "0")),
|
||
},
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => (
|
||
<Badge tone={r.status === "confirmed" ? "success" : "default"}>{String(r.status)}</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: "id",
|
||
header: "عملیات",
|
||
render: (r) =>
|
||
r.status === "draft" ? (
|
||
<Button type="button" size="sm" variant="outline" onClick={() => setConfirmId(String(r.id))}>
|
||
تأیید
|
||
</Button>
|
||
) : (
|
||
"—"
|
||
),
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="موردی ثبت نشده" description="با دکمه ثبت جدید شروع کنید." />}
|
||
/>
|
||
|
||
<Dialog open={open} onClose={() => setOpen(false)} title={`ثبت ${title}`} size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||
<FormField label="شماره" error={form.formState.errors.number?.message}>
|
||
<Input {...form.register("number")} />
|
||
</FormField>
|
||
<FormField label="تاریخ" error={form.formState.errors.doc_date?.message}>
|
||
<Controller
|
||
control={form.control}
|
||
name="doc_date"
|
||
render={({ field }) => (
|
||
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
|
||
)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="طرف حساب">
|
||
<Input {...form.register("party_name")} />
|
||
</FormField>
|
||
<FormField label="مبلغ" error={form.formState.errors.amount?.message}>
|
||
<MoneyInput {...form.register("amount")} />
|
||
</FormField>
|
||
<div className="sm:col-span-2">
|
||
<FormField label="توضیح">
|
||
<Input {...form.register("description")} />
|
||
</FormField>
|
||
</div>
|
||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
|
||
<ConfirmDialog
|
||
open={!!confirmId}
|
||
onClose={() => setConfirmId(null)}
|
||
onConfirm={() => confirmId && confirmM.mutate(confirmId)}
|
||
title="تأیید سند؟"
|
||
description="پس از تأیید، وضعیت به confirmed تغییر میکند."
|
||
confirmLabel="تأیید"
|
||
loading={confirmM.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function BlockedModulePage({ title }: { title: string }) {
|
||
return (
|
||
<div>
|
||
<PageHeader title={title} description="این بخش تا Active شدن AI Provider مسدود است." />
|
||
<EmptyState
|
||
title="مسدود"
|
||
description="طبق تصمیم پروژه، ماژول هوش مصنوعی حسابداری تا فعالسازی provider نمایش داده نمیشود."
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function InventoryItemsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const form = useForm({
|
||
defaultValues: { code: "", name: "", unit: "عدد", quantity_on_hand: "0", unit_cost: "0" },
|
||
});
|
||
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "ops-items"],
|
||
queryFn: () => accountingApi.ops.listItems(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const createM = useMutation({
|
||
mutationFn: (d: { code: string; name: string; unit: string; quantity_on_hand: string; unit_cost: string }) =>
|
||
accountingApi.ops.createItem(tenantId!, d),
|
||
onSuccess: async () => {
|
||
toast.success("کالا ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-items"] });
|
||
},
|
||
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="فهرست اقلام انبار — CRUD واقعی."
|
||
actions={
|
||
<Button type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
کالای جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" },
|
||
{ key: "name", header: "نام" },
|
||
{ key: "unit", header: "واحد" },
|
||
{
|
||
key: "quantity_on_hand",
|
||
header: "موجودی",
|
||
render: (r) => String(r.quantity_on_hand ?? "0"),
|
||
},
|
||
{
|
||
key: "unit_cost",
|
||
header: "بهای واحد",
|
||
render: (r) => formatMoney(String(r.unit_cost ?? "0")),
|
||
},
|
||
{
|
||
key: "is_active",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge tone={r.is_active ? "success" : "default"}>{r.is_active ? "فعال" : "غیرفعال"}</Badge>,
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="کالایی ثبت نشده" />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت کالا">
|
||
<form
|
||
className="grid gap-3 sm:grid-cols-2"
|
||
onSubmit={form.handleSubmit((d) => createM.mutate(d))}
|
||
>
|
||
<FormField label="کد">
|
||
<Input {...form.register("code", { required: true })} />
|
||
</FormField>
|
||
<FormField label="نام">
|
||
<Input {...form.register("name", { required: true })} />
|
||
</FormField>
|
||
<FormField label="واحد">
|
||
<Input {...form.register("unit")} />
|
||
</FormField>
|
||
<FormField label="موجودی">
|
||
<Input {...form.register("quantity_on_hand")} />
|
||
</FormField>
|
||
<FormField label="بهای واحد">
|
||
<MoneyInput {...form.register("unit_cost")} />
|
||
</FormField>
|
||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function WarehousesPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const form = useForm({ defaultValues: { code: "", name: "" } });
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "ops-warehouses"],
|
||
queryFn: () => accountingApi.ops.listWarehouses(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (d: { code: string; name: string }) => accountingApi.ops.createWarehouse(tenantId!, d),
|
||
onSuccess: async () => {
|
||
toast.success("انبار ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-warehouses"] });
|
||
},
|
||
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 type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
انبار جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{ key: "code", header: "کد" },
|
||
{ key: "name", header: "نام" },
|
||
{
|
||
key: "is_active",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge tone={r.is_active ? "success" : "default"}>{r.is_active ? "فعال" : "غیرفعال"}</Badge>,
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="انباری ثبت نشده" />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت انبار">
|
||
<form className="grid gap-3" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||
<FormField label="کد">
|
||
<Input {...form.register("code", { required: true })} />
|
||
</FormField>
|
||
<FormField label="نام">
|
||
<Input {...form.register("name", { required: true })} />
|
||
</FormField>
|
||
<div className="flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ChequesPage({
|
||
defaultIncoming,
|
||
initialOpen = false,
|
||
}: {
|
||
defaultIncoming?: boolean;
|
||
initialOpen?: boolean;
|
||
}) {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [tab, setTab] = useState<"all" | "in" | "out" | "bounced">("all");
|
||
const [open, setOpen] = useState(initialOpen);
|
||
const form = useForm({
|
||
defaultValues: {
|
||
cheque_number: "",
|
||
amount: "0",
|
||
issue_date: new Date().toISOString().slice(0, 10),
|
||
due_date: "",
|
||
is_incoming: defaultIncoming !== false,
|
||
payee: "",
|
||
},
|
||
});
|
||
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "cheques"],
|
||
queryFn: () => accountingApi.treasury.listCheques(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const createM = useMutation({
|
||
mutationFn: (d: {
|
||
cheque_number: string;
|
||
amount: string;
|
||
issue_date: string;
|
||
due_date?: string;
|
||
is_incoming: boolean;
|
||
payee?: string;
|
||
}) =>
|
||
accountingApi.treasury.createCheque(tenantId!, {
|
||
...d,
|
||
due_date: d.due_date || undefined,
|
||
status: d.is_incoming ? "received" : "issued",
|
||
}),
|
||
onSuccess: async () => {
|
||
toast.success("چک ثبت شد");
|
||
setOpen(false);
|
||
form.reset({
|
||
cheque_number: "",
|
||
amount: "0",
|
||
issue_date: new Date().toISOString().slice(0, 10),
|
||
due_date: "",
|
||
is_incoming: true,
|
||
payee: "",
|
||
});
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "cheques"] });
|
||
},
|
||
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 rows = (listQ.data ?? []).filter((c) => {
|
||
if (tab === "in") return c.is_incoming;
|
||
if (tab === "out") return !c.is_incoming;
|
||
if (tab === "bounced") return c.status === "bounced";
|
||
return true;
|
||
});
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="چکها"
|
||
description="لیست و ثبت چکهای دریافتی و پرداختی."
|
||
actions={
|
||
<Button type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
ثبت چک
|
||
</Button>
|
||
}
|
||
/>
|
||
<Tabs
|
||
value={tab}
|
||
onChange={(v) => setTab(v as typeof tab)}
|
||
items={[
|
||
{ id: "all", label: "همه" },
|
||
{ id: "in", label: "دریافتی" },
|
||
{ id: "out", label: "پرداختی" },
|
||
{ id: "bounced", label: "برگشتی" },
|
||
]}
|
||
/>
|
||
<div className="mt-4">
|
||
<DataTable
|
||
columns={[
|
||
{ key: "cheque_number", header: "شماره چک" },
|
||
{
|
||
key: "due_date",
|
||
header: "سررسید",
|
||
render: (r) => (r.due_date ? <JalaliDateText value={String(r.due_date)} /> : "—"),
|
||
},
|
||
{
|
||
key: "issue_date",
|
||
header: "تاریخ",
|
||
render: (r) => <JalaliDateText value={String(r.issue_date)} />,
|
||
},
|
||
{ key: "payee", header: "ذینفع" },
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge>{String(r.status)}</Badge>,
|
||
},
|
||
{
|
||
key: "amount",
|
||
header: "مبلغ",
|
||
render: (r) => formatMoney(String(r.amount)),
|
||
},
|
||
]}
|
||
rows={rows as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="چکی ثبت نشده" />}
|
||
/>
|
||
</div>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت چک جدید" size="lg">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||
<div className="sm:col-span-2">
|
||
<FormField label="نوع">
|
||
<Select
|
||
value={form.watch("is_incoming") ? "in" : "out"}
|
||
onChange={(e) => form.setValue("is_incoming", e.target.value === "in")}
|
||
>
|
||
<option value="in">دریافتی</option>
|
||
<option value="out">پرداختی</option>
|
||
</Select>
|
||
</FormField>
|
||
</div>
|
||
<FormField label="شماره چک">
|
||
<Input {...form.register("cheque_number", { required: true })} />
|
||
</FormField>
|
||
<FormField label="مبلغ">
|
||
<MoneyInput {...form.register("amount", { required: true })} />
|
||
</FormField>
|
||
<FormField label="تاریخ صدور">
|
||
<Controller
|
||
control={form.control}
|
||
name="issue_date"
|
||
render={({ field }) => (
|
||
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
|
||
)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="سررسید">
|
||
<Controller
|
||
control={form.control}
|
||
name="due_date"
|
||
render={({ field }) => (
|
||
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
|
||
)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="ذینفع / طرف حساب">
|
||
<Input {...form.register("payee")} />
|
||
</FormField>
|
||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ReceiptsPaymentsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [mode, setMode] = useState<"receipt" | "payment">("receipt");
|
||
const [open, setOpen] = useState(false);
|
||
const form = useForm({
|
||
defaultValues: {
|
||
number: "",
|
||
date: new Date().toISOString().slice(0, 10),
|
||
amount: "0",
|
||
payment_method: "cash",
|
||
description: "",
|
||
},
|
||
});
|
||
|
||
const receiptsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "receipts"],
|
||
queryFn: () => accountingApi.treasury.listReceipts(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const paymentsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "payments"],
|
||
queryFn: () => accountingApi.treasury.listPayments(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const createM = useMutation({
|
||
mutationFn: async (d: {
|
||
number: string;
|
||
date: string;
|
||
amount: string;
|
||
payment_method: string;
|
||
description?: string;
|
||
}) => {
|
||
if (mode === "receipt") {
|
||
return accountingApi.treasury.createReceipt(tenantId!, {
|
||
receipt_number: d.number,
|
||
receipt_date: d.date,
|
||
amount: d.amount,
|
||
payment_method: d.payment_method,
|
||
description: d.description,
|
||
});
|
||
}
|
||
return accountingApi.treasury.createPayment(tenantId!, {
|
||
payment_number: d.number,
|
||
payment_date: d.date,
|
||
amount: d.amount,
|
||
payment_method: d.payment_method,
|
||
description: d.description,
|
||
});
|
||
},
|
||
onSuccess: async () => {
|
||
toast.success("ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "receipts"] });
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payments"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
if (!tenantId || receiptsQ.isLoading || paymentsQ.isLoading) return <LoadingState />;
|
||
if (receiptsQ.error || paymentsQ.error) {
|
||
return (
|
||
<ErrorState
|
||
message={(receiptsQ.error || paymentsQ.error)!.message}
|
||
onRetry={() => {
|
||
receiptsQ.refetch();
|
||
paymentsQ.refetch();
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const rows =
|
||
mode === "receipt"
|
||
? (receiptsQ.data ?? []).map((r) => ({
|
||
id: r.id,
|
||
number: r.receipt_number,
|
||
date: r.receipt_date,
|
||
amount: r.amount,
|
||
payment_method: r.payment_method,
|
||
description: r.description,
|
||
}))
|
||
: (paymentsQ.data ?? []).map((p) => ({
|
||
id: p.id,
|
||
number: p.payment_number,
|
||
date: p.payment_date,
|
||
amount: p.amount,
|
||
payment_method: p.payment_method,
|
||
description: p.description,
|
||
}));
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="دریافت و پرداخت"
|
||
description="ثبت دریافت/پرداخت خزانه."
|
||
actions={
|
||
<Button type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
ثبت جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<Tabs
|
||
value={mode}
|
||
onChange={(v) => setMode(v as typeof mode)}
|
||
items={[
|
||
{ id: "receipt", label: "دریافتها" },
|
||
{ id: "payment", label: "پرداختها" },
|
||
]}
|
||
/>
|
||
<div className="mt-4">
|
||
<DataTable
|
||
columns={[
|
||
{ key: "number", header: "شماره" },
|
||
{
|
||
key: "date",
|
||
header: "تاریخ",
|
||
render: (r) => <JalaliDateText value={String(r.date)} />,
|
||
},
|
||
{ key: "payment_method", header: "روش" },
|
||
{
|
||
key: "amount",
|
||
header: "مبلغ",
|
||
render: (r) => formatMoney(String(r.amount)),
|
||
},
|
||
{ key: "description", header: "توضیح" },
|
||
]}
|
||
rows={rows as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="موردی نیست" />}
|
||
/>
|
||
</div>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title={mode === "receipt" ? "ثبت دریافت" : "ثبت پرداخت"}>
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||
<div className="sm:col-span-2">
|
||
<FormField label="نوع عملیات">
|
||
<Select
|
||
value={mode}
|
||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
||
>
|
||
<option value="receipt">دریافت</option>
|
||
<option value="payment">پرداخت</option>
|
||
</Select>
|
||
</FormField>
|
||
</div>
|
||
<FormField label="شماره">
|
||
<Input {...form.register("number", { required: true })} />
|
||
</FormField>
|
||
<FormField label="مبلغ">
|
||
<MoneyInput {...form.register("amount", { required: true })} />
|
||
</FormField>
|
||
<FormField label="تاریخ">
|
||
<Controller
|
||
control={form.control}
|
||
name="date"
|
||
render={({ field }) => (
|
||
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
|
||
)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="روش">
|
||
<Select
|
||
value={form.watch("payment_method")}
|
||
onChange={(e) => form.setValue("payment_method", e.target.value)}
|
||
>
|
||
<option value="cash">نقد</option>
|
||
<option value="bank">حواله بانکی</option>
|
||
<option value="cheque">چک</option>
|
||
</Select>
|
||
</FormField>
|
||
<div className="sm:col-span-2">
|
||
<FormField label="توضیح">
|
||
<Input {...form.register("description")} />
|
||
</FormField>
|
||
</div>
|
||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TransfersPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const cashBoxesQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "cash-boxes"],
|
||
queryFn: () => accountingApi.treasury.listCashBoxes(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const bankAccountsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "bank-accounts"],
|
||
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "transfers"],
|
||
queryFn: () => accountingApi.treasury.listTransfers(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const form = useForm({
|
||
defaultValues: {
|
||
transfer_date: new Date().toISOString().slice(0, 10),
|
||
amount: "0",
|
||
from_key: "",
|
||
to_key: "",
|
||
description: "",
|
||
},
|
||
});
|
||
|
||
const createM = useMutation({
|
||
mutationFn: (d: {
|
||
transfer_date: string;
|
||
amount: string;
|
||
from_key: string;
|
||
to_key: string;
|
||
description?: string;
|
||
}) => {
|
||
const [from_type, from_id] = d.from_key.split(":");
|
||
const [to_type, to_id] = d.to_key.split(":");
|
||
return accountingApi.treasury.createTransfer(tenantId!, {
|
||
transfer_date: d.transfer_date,
|
||
amount: d.amount,
|
||
from_type,
|
||
from_id,
|
||
to_type,
|
||
to_id,
|
||
description: d.description,
|
||
});
|
||
},
|
||
onSuccess: async () => {
|
||
toast.success("انتقال ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "transfers"] });
|
||
},
|
||
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 accountOptions = [
|
||
...(cashBoxesQ.data ?? []).map((c) => ({ value: `cash:${c.id}`, label: `صندوق: ${c.name}` })),
|
||
...(bankAccountsQ.data ?? []).map((b) => ({
|
||
value: `bank:${b.id}`,
|
||
label: `بانک: ${b.account_number}`,
|
||
})),
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="انتقال وجه"
|
||
description="انتقال بین صندوقها و حسابهای بانکی."
|
||
actions={
|
||
<Button type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
انتقال جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{
|
||
key: "transfer_date",
|
||
header: "تاریخ",
|
||
render: (r) => <JalaliDateText value={String(r.transfer_date)} />,
|
||
},
|
||
{
|
||
key: "amount",
|
||
header: "مبلغ",
|
||
render: (r) => formatMoney(String(r.amount)),
|
||
},
|
||
{ key: "from_type", header: "از" },
|
||
{ key: "to_type", header: "به" },
|
||
{ key: "description", header: "توضیح" },
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="انتقالی ثبت نشده" />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="انتقال بین حسابها">
|
||
<form className="grid gap-3" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||
<FormField label="تاریخ">
|
||
<Controller
|
||
control={form.control}
|
||
name="transfer_date"
|
||
render={({ field }) => (
|
||
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
|
||
)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="مبلغ">
|
||
<MoneyInput {...form.register("amount", { required: true })} />
|
||
</FormField>
|
||
<FormField label="از حساب">
|
||
<Select
|
||
value={form.watch("from_key")}
|
||
onChange={(e) => form.setValue("from_key", e.target.value)}
|
||
>
|
||
<option value="">انتخاب…</option>
|
||
{accountOptions.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="به حساب">
|
||
<Select
|
||
value={form.watch("to_key")}
|
||
onChange={(e) => form.setValue("to_key", e.target.value)}
|
||
>
|
||
<option value="">انتخاب…</option>
|
||
{accountOptions.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="توضیح">
|
||
<Input {...form.register("description")} />
|
||
</FormField>
|
||
<div className="flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ReconciliationPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const banksQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "bank-accounts"],
|
||
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "reconciliations"],
|
||
queryFn: () => accountingApi.treasury.listReconciliations(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const form = useForm({
|
||
defaultValues: {
|
||
bank_account_id: "",
|
||
statement_date: new Date().toISOString().slice(0, 10),
|
||
statement_balance: "0",
|
||
book_balance: "0",
|
||
},
|
||
});
|
||
const createM = useMutation({
|
||
mutationFn: (d: {
|
||
bank_account_id: string;
|
||
statement_date: string;
|
||
statement_balance: string;
|
||
book_balance: string;
|
||
}) => accountingApi.treasury.createReconciliation(tenantId!, d),
|
||
onSuccess: async () => {
|
||
toast.success("تطبیق ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "reconciliations"] });
|
||
},
|
||
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 type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
تطبیق جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
<DataTable
|
||
columns={[
|
||
{
|
||
key: "statement_date",
|
||
header: "تاریخ صورتحساب",
|
||
render: (r) => <JalaliDateText value={String(r.statement_date)} />,
|
||
},
|
||
{
|
||
key: "statement_balance",
|
||
header: "مانده صورتحساب",
|
||
render: (r) => formatMoney(String(r.statement_balance)),
|
||
},
|
||
{
|
||
key: "book_balance",
|
||
header: "مانده دفتر",
|
||
render: (r) => formatMoney(String(r.book_balance)),
|
||
},
|
||
{
|
||
key: "difference",
|
||
header: "اختلاف",
|
||
render: (r) => formatMoney(String(r.difference)),
|
||
},
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge>{String(r.status)}</Badge>,
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="تطبیقی ثبت نشده" />}
|
||
/>
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="تطبیق بانکی">
|
||
<form className="grid gap-3" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||
<FormField label="حساب بانکی">
|
||
<Select
|
||
value={form.watch("bank_account_id")}
|
||
onChange={(e) => form.setValue("bank_account_id", e.target.value)}
|
||
>
|
||
<option value="">انتخاب…</option>
|
||
{(banksQ.data ?? []).map((b) => (
|
||
<option key={b.id} value={b.id}>
|
||
{b.account_number}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="تاریخ">
|
||
<Controller
|
||
control={form.control}
|
||
name="statement_date"
|
||
render={({ field }) => (
|
||
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
|
||
)}
|
||
/>
|
||
</FormField>
|
||
<FormField label="مانده صورتحساب">
|
||
<MoneyInput {...form.register("statement_balance")} />
|
||
</FormField>
|
||
<FormField label="مانده دفتر">
|
||
<MoneyInput {...form.register("book_balance")} />
|
||
</FormField>
|
||
<div className="flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|