TorbatYar/frontend/modules/crm/features/sales/kanban.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

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