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

296 lines
10 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 { 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, Pencil, Archive } from "lucide-react";
import { accountingApi, type Project } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
ConfirmDialog,
Input,
Label,
FieldError,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Drawer,
DatePicker,
JalaliDateText,
} from "@/components/ds";
const createSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
start_date: z.string().optional(),
end_date: z.string().optional(),
});
const editSchema = z.object({
name: z.string().min(1, "نام الزامی است"),
start_date: z.string().optional(),
end_date: z.string().optional(),
});
export default function ProjectsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [selected, setSelected] = useState<Project | null>(null);
const [archiveId, setArchiveId] = useState<string | null>(null);
const createForm = useForm<z.infer<typeof createSchema>>({
resolver: zodResolver(createSchema),
defaultValues: { code: "", name: "", start_date: "", end_date: "" },
});
const editForm = useForm<z.infer<typeof editSchema>>({
resolver: zodResolver(editSchema),
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "projects"],
queryFn: () => accountingApi.projects.list(tenantId!),
enabled: !!tenantId,
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "projects"] });
};
const createM = useMutation({
mutationFn: (d: z.infer<typeof createSchema>) =>
accountingApi.projects.create(tenantId!, {
code: d.code,
name: d.name,
start_date: d.start_date || undefined,
end_date: d.end_date || undefined,
}),
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.projects.update(tenantId!, selected!.id, {
name: d.name,
start_date: d.start_date || null,
end_date: d.end_date || null,
}),
onSuccess: async () => {
toast.success("پروژه به‌روز شد");
setSelected(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveM = useMutation({
mutationFn: (id: string) => accountingApi.projects.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()} />;
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: "start_date",
header: "شروع",
render: (r) =>
r.start_date ? <JalaliDateText value={String(r.start_date)} /> : "—",
},
{
key: "end_date",
header: "پایان",
render: (r) => (r.end_date ? <JalaliDateText value={String(r.end_date)} /> : "—"),
},
{
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 p = listQ.data?.find((x) => x.id === id);
if (p) {
setSelected(p);
editForm.reset({
name: p.name,
start_date: p.start_date ?? "",
end_date: p.end_date ?? "",
});
}
}}
>
<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 className="grid grid-cols-2 gap-3">
<div>
<Label>تاریخ شروع</Label>
<Controller
control={createForm.control}
name="start_date"
render={({ field }) => (
<DatePicker value={field.value ?? ""} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
<div>
<Label>تاریخ پایان</Label>
<Controller
control={createForm.control}
name="end_date"
render={({ field }) => (
<DatePicker value={field.value ?? ""} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
</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 className="grid grid-cols-2 gap-3">
<div>
<Label>تاریخ شروع</Label>
<Controller
control={editForm.control}
name="start_date"
render={({ field }) => (
<DatePicker value={field.value ?? ""} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
<div>
<Label>تاریخ پایان</Label>
<Controller
control={editForm.control}
name="end_date"
render={({ field }) => (
<DatePicker value={field.value ?? ""} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
</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>
);
}