Connect all CRM pages to the BFF and CRM service, add module docs, and mark CRM available in the SuperApp catalog. Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
11 KiB
TypeScript
312 lines
11 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, Trophy, XCircle } from "lucide-react";
|
||
import {
|
||
Button,
|
||
Dialog,
|
||
Drawer,
|
||
Input,
|
||
FormField,
|
||
Select,
|
||
EmptyState,
|
||
ConfirmDialog,
|
||
} from "@/components/ds";
|
||
import { crmApi } from "@/modules/crm/services/crm-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { CrmTablePage, CrmStatusChip } from "@/modules/crm/design-system";
|
||
import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
|
||
import { CrmPageLoader, CrmPageError } from "@/modules/crm/pages/shared";
|
||
import type { Opportunity, Pipeline, PipelineStage } from "@/modules/crm/types";
|
||
|
||
const createSchema = z.object({
|
||
title: z.string().min(1, "عنوان الزامی است"),
|
||
pipeline_id: z.string().uuid(),
|
||
stage_id: z.string().uuid(),
|
||
amount: z.string().default("0"),
|
||
probability: z.number().min(0).max(100).default(0),
|
||
priority: z.enum(["low", "medium", "high", "urgent"]).default("medium"),
|
||
expected_close_date: z.string().optional(),
|
||
description: z.string().optional(),
|
||
});
|
||
|
||
export default function DealsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editRow, setEditRow] = useState<Opportunity | null>(null);
|
||
const [winId, setWinId] = useState<string | null>(null);
|
||
const [loseId, setLoseId] = useState<string | null>(null);
|
||
const [page, setPage] = useState(1);
|
||
|
||
const pipelinesQ = useQuery({
|
||
queryKey: ["crm", tenantId, "pipelines"],
|
||
queryFn: () => crmApi.pipelines.list(tenantId!, { page: 1, page_size: 100 }),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const listQ = useQuery({
|
||
queryKey: ["crm", tenantId, "opportunities"],
|
||
queryFn: () => crmApi.opportunities.list(tenantId!, { page: 1, page_size: 500 }),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const stagesQ = useQuery({
|
||
queryKey: ["crm", tenantId, "all-stages"],
|
||
queryFn: async () => {
|
||
const pipelines = pipelinesQ.data ?? [];
|
||
const stages: PipelineStage[] = [];
|
||
for (const p of pipelines) {
|
||
const s = await crmApi.pipelines.stages(tenantId!, p.id, { page: 1, page_size: 100 });
|
||
stages.push(...s);
|
||
}
|
||
return stages;
|
||
},
|
||
enabled: !!tenantId && !!pipelinesQ.data?.length,
|
||
});
|
||
|
||
const form = useForm({
|
||
resolver: zodResolver(createSchema),
|
||
defaultValues: {
|
||
title: "",
|
||
pipeline_id: "",
|
||
stage_id: "",
|
||
amount: "0",
|
||
probability: 0,
|
||
priority: "medium" as const,
|
||
description: "",
|
||
},
|
||
});
|
||
|
||
const editForm = useForm({
|
||
resolver: zodResolver(createSchema.partial()),
|
||
});
|
||
|
||
const pipelineId = form.watch("pipeline_id");
|
||
const stageOptions =
|
||
stagesQ.data?.filter((s) => s.pipeline_id === pipelineId) ?? [];
|
||
|
||
const invalidate = () => {
|
||
qc.invalidateQueries({ queryKey: ["crm", tenantId, "opportunities"] });
|
||
};
|
||
|
||
const createM = useMutation({
|
||
mutationFn: (d: Record<string, unknown>) =>
|
||
crmApi.opportunities.create(tenantId!, { ...d, name: d.title }),
|
||
onSuccess: async () => {
|
||
toast.success("معامله ایجاد شد");
|
||
setCreateOpen(false);
|
||
form.reset();
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const updateM = useMutation({
|
||
mutationFn: (d: Record<string, unknown>) =>
|
||
crmApi.opportunities.update(tenantId!, editRow!.id, d),
|
||
onSuccess: async () => {
|
||
toast.success("معامله بهروز شد");
|
||
setEditRow(null);
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const winM = useMutation({
|
||
mutationFn: (id: string) => crmApi.opportunities.win(tenantId!, id),
|
||
onSuccess: async () => {
|
||
toast.success("معامله برنده شد");
|
||
setWinId(null);
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const loseM = useMutation({
|
||
mutationFn: (id: string) =>
|
||
crmApi.opportunities.lose(tenantId!, id, { reason_id: null }),
|
||
onSuccess: async () => {
|
||
toast.success("معامله باخته شد");
|
||
setLoseId(null);
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const stageName = (id: string) =>
|
||
stagesQ.data?.find((s) => s.id === id)?.name ?? id.slice(0, 8);
|
||
|
||
if (!tenantId || listQ.isLoading || pipelinesQ.isLoading) return <CrmPageLoader />;
|
||
if (listQ.error) return <CrmPageError error={listQ.error} onRetry={() => listQ.refetch()} />;
|
||
|
||
const pipelines = pipelinesQ.data ?? [];
|
||
|
||
return (
|
||
<div>
|
||
<CrmTablePage
|
||
breadcrumbs={<CrmBreadcrumbs items={[{ label: "معاملات" }]} />}
|
||
title="معاملات"
|
||
description="فرصتهای فروش (Opportunities) — قیف، مبلغ، احتمال و بستن معامله."
|
||
columns={[
|
||
{ key: "opportunity_number", header: "شماره" },
|
||
{ key: "title", header: "عنوان" },
|
||
{
|
||
key: "stage_id",
|
||
header: "مرحله",
|
||
render: (r) => stageName(String(r.stage_id)),
|
||
},
|
||
{ key: "amount", header: "مبلغ" },
|
||
{ key: "probability", header: "احتمال %" },
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => (
|
||
<CrmStatusChip status={String(r.status)} domain="opportunity" />
|
||
),
|
||
},
|
||
{
|
||
key: "actions",
|
||
header: "عملیات",
|
||
searchable: false,
|
||
render: (r) => (
|
||
<div className="flex gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => {
|
||
setEditRow(r as unknown as Opportunity);
|
||
editForm.reset({
|
||
title: String(r.title ?? r.name ?? ""),
|
||
amount: String(r.amount),
|
||
probability: Number(r.probability),
|
||
priority: String(r.priority) as "medium",
|
||
});
|
||
}}
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
{r.status === "open" ? (
|
||
<>
|
||
<Button variant="ghost" size="icon" onClick={() => setWinId(String(r.id))}>
|
||
<Trophy className="h-4 w-4 text-emerald-600" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" onClick={() => setLoseId(String(r.id))}>
|
||
<XCircle className="h-4 w-4 text-red-600" />
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
page={page}
|
||
pageSize={20}
|
||
onPageChange={setPage}
|
||
actions={
|
||
<Button onClick={() => setCreateOpen(true)} disabled={!pipelines.length}>
|
||
<Plus className="h-4 w-4" />
|
||
معامله جدید
|
||
</Button>
|
||
}
|
||
empty={
|
||
<EmptyState
|
||
title="معاملهای نیست"
|
||
description={pipelines.length ? undefined : "ابتدا یک قیف فروش ایجاد کنید."}
|
||
action={
|
||
pipelines.length ? (
|
||
<Button onClick={() => setCreateOpen(true)}>ایجاد معامله</Button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
}
|
||
/>
|
||
|
||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="معامله جدید">
|
||
<form className="space-y-3" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||
<FormField label="عنوان">
|
||
<Input {...form.register("title")} />
|
||
</FormField>
|
||
<FormField label="قیف">
|
||
<Select {...form.register("pipeline_id")}>
|
||
<option value="">انتخاب</option>
|
||
{pipelines.map((p: Pipeline) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="مرحله">
|
||
<Select {...form.register("stage_id")}>
|
||
<option value="">انتخاب</option>
|
||
{stageOptions.map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="مبلغ">
|
||
<Input {...form.register("amount")} />
|
||
</FormField>
|
||
<FormField label="احتمال (%)">
|
||
<Input type="number" {...form.register("probability", { valueAsNumber: true })} />
|
||
</FormField>
|
||
<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={!!editRow} onClose={() => setEditRow(null)} title="ویرایش معامله">
|
||
<form className="space-y-3" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
|
||
<FormField label="عنوان">
|
||
<Input {...editForm.register("title")} />
|
||
</FormField>
|
||
<FormField label="مبلغ">
|
||
<Input {...editForm.register("amount")} />
|
||
</FormField>
|
||
<FormField label="احتمال">
|
||
<Input type="number" {...editForm.register("probability", { valueAsNumber: true })} />
|
||
</FormField>
|
||
<Button type="submit" disabled={updateM.isPending}>
|
||
بهروزرسانی
|
||
</Button>
|
||
</form>
|
||
</Drawer>
|
||
|
||
<ConfirmDialog
|
||
open={!!winId}
|
||
onClose={() => setWinId(null)}
|
||
onConfirm={() => winId && winM.mutate(winId)}
|
||
title="برنده شدن معامله"
|
||
description="وضعیت معامله به برنده تغییر میکند."
|
||
confirmLabel="تأیید برنده"
|
||
loading={winM.isPending}
|
||
/>
|
||
<ConfirmDialog
|
||
open={!!loseId}
|
||
onClose={() => setLoseId(null)}
|
||
onConfirm={() => loseId && loseM.mutate(loseId)}
|
||
title="باخت معامله"
|
||
description="وضعیت معامله به باخته تغییر میکند."
|
||
confirmLabel="تأیید باخت"
|
||
loading={loseM.isPending}
|
||
danger
|
||
/>
|
||
</div>
|
||
);
|
||
}
|