TorbatYar/frontend/modules/crm/features/sales/deals.tsx
Mortezakoohjani e140908034 Ship enterprise CRM frontend with real API wiring and thin app routes.
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>
2026-07-26 20:58:17 +03:30

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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>
);
}