TorbatYar/frontend/modules/crm/pages/shared.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

125 lines
4.1 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import {
PageHeader,
LoadingState,
ErrorState,
Badge,
Card,
CardContent,
EmptyState,
} from "@/components/ds";
import { crmApi, CrmApiError } from "@/modules/crm/services/crm-api";
import { useTenantId } from "@/hooks/useTenantId";
import { CrmStatGrid } from "@/modules/crm/design-system";
export function CrmPageLoader({ label = "در حال بارگذاری…" }: { label?: string }) {
return <LoadingState label={label} />;
}
export function CrmPageError({
error,
onRetry,
}: {
error: unknown;
onRetry?: () => void;
}) {
if (error instanceof CrmApiError && error.status === 403) {
return (
<EmptyState
title="دسترسی محدود"
description={error.message || "مجوز لازم برای این عملیات وجود ندارد."}
/>
);
}
const message = error instanceof Error ? error.message : "خطا در دریافت داده‌ها";
return <ErrorState message={message} onRetry={onRetry} />;
}
export function useCrmHealth() {
return useQuery({
queryKey: ["crm", "health"],
queryFn: () => crmApi.health(),
});
}
export function CrmServiceBadge() {
const healthQ = useCrmHealth();
if (!healthQ.data) return null;
return (
<Badge tone={healthQ.data.status === "ok" ? "success" : "danger"}>
CRM v{healthQ.data.version}
</Badge>
);
}
export function useCrmCounts(tenantId: string | null) {
return useQuery({
queryKey: ["crm", tenantId, "counts"],
queryFn: async () => {
const [leads, contacts, orgs, opps, activities, tasks, meetings, calls] =
await Promise.all([
crmApi.leads.list(tenantId!, { page: 1, page_size: 500 }),
crmApi.contacts.list(tenantId!, { page: 1, page_size: 500 }),
crmApi.organizations.list(tenantId!, { page: 1, page_size: 500 }),
crmApi.opportunities.list(tenantId!, { page: 1, page_size: 500 }),
crmApi.activities.list(tenantId!, { page: 1, page_size: 500 }),
crmApi.tasks.list(tenantId!, { page: 1, page_size: 500 }),
crmApi.meetings.list(tenantId!, { page: 1, page_size: 500 }),
crmApi.calls.list(tenantId!, { page: 1, page_size: 500 }),
]);
return {
leads: leads.length,
contacts: contacts.length,
organizations: orgs.length,
opportunities: opps.length,
activities: activities.length,
tasks: tasks.length,
meetings: meetings.length,
calls: calls.length,
openDeals: opps.filter((o) => o.status === "open").length,
wonDeals: opps.filter((o) => o.status === "won").length,
};
},
enabled: !!tenantId,
});
}
export function CrmCountsGrid({ tenantId }: { tenantId: string }) {
const q = useCrmCounts(tenantId);
if (q.isLoading) return <CrmPageLoader />;
if (q.error) return <CrmPageError error={q.error} onRetry={() => q.refetch()} />;
const c = q.data!;
return (
<CrmStatGrid
stats={[
{ label: "سرنخ‌ها", value: c.leads },
{ label: "مخاطبین", value: c.contacts },
{ label: "شرکت‌ها", value: c.organizations },
{ label: "معاملات باز", value: c.openDeals },
{ label: "معاملات برنده", value: c.wonDeals },
{ label: "فعالیت‌ها", value: c.activities },
{ label: "وظایف", value: c.tasks },
{ label: "جلسات", value: c.meetings },
]}
/>
);
}
export function exportToCsv(filename: string, rows: Record<string, unknown>[], columns: string[]) {
const header = columns.join(",");
const lines = rows.map((r) =>
columns.map((c) => JSON.stringify(r[c] ?? "")).join(",")
);
const blob = new Blob(["\uFEFF" + [header, ...lines].join("\n")], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}