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>
296 lines
10 KiB
TypeScript
296 lines
10 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, 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>
|
||
);
|
||
}
|