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>
132 lines
5.3 KiB
TypeScript
132 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { Button, Select, EmptyState } from "@/components/ds";
|
|
import { crmApi } from "@/modules/crm/services/crm-api";
|
|
import { useTenantId } from "@/hooks/useTenantId";
|
|
import { CrmPageLoader, CrmPageError } from "@/modules/crm/pages/shared";
|
|
import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
|
|
import { PageHeader } from "@/components/ds";
|
|
import type { Opportunity, PipelineStage } from "@/modules/crm/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function KanbanPage() {
|
|
const { tenantId } = useTenantId();
|
|
const qc = useQueryClient();
|
|
const [pipelineId, setPipelineId] = useState<string>("");
|
|
|
|
const pipelinesQ = useQuery({
|
|
queryKey: ["crm", tenantId, "pipelines"],
|
|
queryFn: () => crmApi.pipelines.list(tenantId!, { page: 1, page_size: 100 }),
|
|
enabled: !!tenantId,
|
|
});
|
|
|
|
const activePipeline = pipelineId || pipelinesQ.data?.[0]?.id || "";
|
|
|
|
const stagesQ = useQuery({
|
|
queryKey: ["crm", tenantId, "stages", activePipeline],
|
|
queryFn: () =>
|
|
crmApi.pipelines.stages(tenantId!, activePipeline, { page: 1, page_size: 100 }),
|
|
enabled: !!tenantId && !!activePipeline,
|
|
});
|
|
|
|
const oppsQ = useQuery({
|
|
queryKey: ["crm", tenantId, "opportunities"],
|
|
queryFn: () => crmApi.opportunities.list(tenantId!, { page: 1, page_size: 500 }),
|
|
enabled: !!tenantId,
|
|
});
|
|
|
|
const moveM = useMutation({
|
|
mutationFn: ({ id, stageId }: { id: string; stageId: string }) =>
|
|
crmApi.opportunities.changeStage(tenantId!, id, { stage_id: stageId }),
|
|
onSuccess: async () => {
|
|
toast.success("مرحله بهروز شد");
|
|
await qc.invalidateQueries({ queryKey: ["crm", tenantId, "opportunities"] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
if (!tenantId || pipelinesQ.isLoading) return <CrmPageLoader />;
|
|
if (pipelinesQ.error) return <CrmPageError error={pipelinesQ.error} onRetry={() => pipelinesQ.refetch()} />;
|
|
|
|
const stages = (stagesQ.data ?? []).sort((a, b) => a.position - b.position);
|
|
const opps = (oppsQ.data ?? []).filter(
|
|
(o) => !activePipeline || o.pipeline_id === activePipeline
|
|
);
|
|
|
|
const byStage = (stageId: string) => opps.filter((o) => o.stage_id === stageId);
|
|
|
|
return (
|
|
<div>
|
|
<CrmBreadcrumbs items={[{ label: "کانبان" }]} />
|
|
<PageHeader
|
|
title="کانبان فروش"
|
|
description="نمای بردی معاملات — جابجایی مرحله از API stage change."
|
|
actions={
|
|
<Select
|
|
value={activePipeline}
|
|
onChange={(e) => setPipelineId(e.target.value)}
|
|
className="min-w-[180px]"
|
|
>
|
|
{(pipelinesQ.data ?? []).map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
}
|
|
/>
|
|
{!stages.length ? (
|
|
<EmptyState title="مرحلهای تعریف نشده" description="ابتدا قیف و مراحل را در بخش قیفها بسازید." />
|
|
) : (
|
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
{stages.map((stage: PipelineStage) => (
|
|
<div
|
|
key={stage.id}
|
|
className="min-w-[280px] flex-1 rounded-2xl border border-[var(--border)] bg-[var(--surface-muted)]/50 p-3"
|
|
>
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-secondary">{stage.name}</h3>
|
|
<span className="rounded-full bg-[var(--crm-accent-soft)] px-2 py-0.5 text-xs text-[var(--crm-accent)]">
|
|
{byStage(stage.id).length}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{byStage(stage.id).map((deal: Opportunity) => (
|
|
<div
|
|
key={deal.id}
|
|
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-3 shadow-[var(--shadow-sm)]"
|
|
>
|
|
<p className="font-medium text-sm">{deal.title ?? deal.name}</p>
|
|
<p className="mt-1 text-xs text-[var(--muted)]">{deal.amount} {deal.currency_code}</p>
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{stages
|
|
.filter((s) => s.id !== stage.id)
|
|
.slice(0, 3)
|
|
.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
className={cn(
|
|
"rounded-lg px-2 py-0.5 text-[10px]",
|
|
"bg-[var(--surface-muted)] hover:bg-[var(--crm-accent-soft)]"
|
|
)}
|
|
onClick={() => moveM.mutate({ id: deal.id, stageId: s.id })}
|
|
>
|
|
→ {s.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|