diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json
new file mode 100644
index 0000000..07d588b
--- /dev/null
+++ b/frontend/.eslintrc.json
@@ -0,0 +1,125 @@
+{
+ "extends": "next/core-web-vitals",
+ "overrides": [
+ {
+ "files": ["app/accounting/**/*", "components/accounting/**/*"],
+ "rules": {
+ "no-restricted-imports": [
+ "error",
+ {
+ "patterns": [
+ {
+ "group": [
+ "@/components/beauty/**",
+ "@/components/healthcare/**",
+ "@/modules/beauty/**",
+ "@/modules/healthcare/**"
+ ],
+ "message": "Accounting must not import beauty or healthcare modules."
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "files": ["app/beauty/**/*", "modules/beauty/**/*"],
+ "rules": {
+ "no-restricted-imports": [
+ "error",
+ {
+ "patterns": [
+ {
+ "group": [
+ "@/components/accounting/**",
+ "@/components/healthcare/**",
+ "@/modules/accounting/**",
+ "@/modules/healthcare/**"
+ ],
+ "message": "Beauty must not import accounting or healthcare modules."
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "files": ["app/healthcare/**/*", "src/modules/healthcare/**/*"],
+ "rules": {
+ "no-restricted-imports": [
+ "error",
+ {
+ "patterns": [
+ {
+ "group": [
+ "@/components/accounting/**",
+ "@/components/beauty/**",
+ "@/modules/beauty/**",
+ "@/modules/accounting/**",
+ "@/src/modules/beauty/**"
+ ],
+ "message": "Healthcare must not import accounting or beauty modules."
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "files": ["app/crm/**/*", "modules/crm/**/*"],
+ "rules": {
+ "no-restricted-imports": [
+ "error",
+ {
+ "patterns": [
+ {
+ "group": [
+ "@/components/accounting/**",
+ "@/components/beauty/**",
+ "@/components/healthcare/**",
+ "@/modules/beauty/**",
+ "@/modules/healthcare/**",
+ "@/modules/accounting/**"
+ ],
+ "message": "CRM must not import accounting, beauty, or healthcare modules."
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "files": [
+ "components/ds/**/*",
+ "shared/**/*",
+ "components/providers/**/*",
+ "components/AuthGuard.tsx",
+ "components/AdminAuthGuard.tsx",
+ "components/ThemeProvider.tsx",
+ "components/SiteHeader.tsx",
+ "hooks/**/*"
+ ],
+ "rules": {
+ "no-restricted-imports": [
+ "error",
+ {
+ "patterns": [
+ {
+ "group": [
+ "@/components/accounting/**",
+ "@/components/beauty/**",
+ "@/components/healthcare/**",
+ "@/modules/beauty/**",
+ "@/modules/healthcare/**",
+ "@/modules/accounting/**",
+ "@/modules/crm/**"
+ ],
+ "message": "Shared/platform code must not import business module code."
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/frontend/app/api/crm/[...path]/route.ts b/frontend/app/api/crm/[...path]/route.ts
new file mode 100644
index 0000000..7b237c5
--- /dev/null
+++ b/frontend/app/api/crm/[...path]/route.ts
@@ -0,0 +1,96 @@
+import { NextRequest, NextResponse } from "next/server";
+
+/**
+ * Same-origin BFF proxy → CRM service.
+ * Browser calls /api/crm/* so TLS/CORS/DNS issues
+ * do not block CRUD from the SuperApp origin.
+ */
+const UPSTREAM =
+ process.env.CRM_SERVICE_URL ||
+ process.env.NEXT_PUBLIC_CRM_API_URL ||
+ "http://localhost:8003";
+
+async function proxy(req: NextRequest, pathSegments: string[]) {
+ const path = pathSegments.join("/");
+ const url = new URL(req.url);
+ const target = `${UPSTREAM.replace(/\/$/, "")}/${path}${url.search}`;
+
+ const headers = new Headers();
+ const pass = ["authorization", "content-type", "x-tenant-id", "accept"];
+ for (const h of pass) {
+ const v = req.headers.get(h);
+ if (v) headers.set(h, v);
+ }
+
+ let body: ArrayBuffer | undefined;
+ if (req.method !== "GET" && req.method !== "HEAD") {
+ body = await req.arrayBuffer();
+ }
+
+ try {
+ const res = await fetch(target, {
+ method: req.method,
+ headers,
+ body,
+ cache: "no-store",
+ });
+ const outHeaders = new Headers();
+ const ct = res.headers.get("content-type");
+ if (ct) outHeaders.set("content-type", ct);
+ return new NextResponse(await res.arrayBuffer(), {
+ status: res.status,
+ headers: outHeaders,
+ });
+ } catch (err) {
+ const detail = err instanceof Error ? err.message : "upstream_unreachable";
+ return NextResponse.json(
+ {
+ error: {
+ code: "crm_proxy_error",
+ message: `پروکسی CRM به ${UPSTREAM} وصل نشد — ${detail}`,
+ },
+ },
+ { status: 502 }
+ );
+ }
+}
+
+export async function GET(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function POST(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function PATCH(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function PUT(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
+
+export async function DELETE(
+ req: NextRequest,
+ ctx: { params: Promise<{ path: string[] }> }
+) {
+ const { path } = await ctx.params;
+ return proxy(req, path);
+}
diff --git a/frontend/app/crm/hub/error.tsx b/frontend/app/crm/hub/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/hub/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/hub/loading.tsx b/frontend/app/crm/hub/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/hub/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/hub/page.tsx b/frontend/app/crm/hub/page.tsx
new file mode 100644
index 0000000..7625fa6
--- /dev/null
+++ b/frontend/app/crm/hub/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { CrmHub as default } from "@/modules/crm/pages/hub";
diff --git a/frontend/app/crm/layout.tsx b/frontend/app/crm/layout.tsx
new file mode 100644
index 0000000..88a8ba4
--- /dev/null
+++ b/frontend/app/crm/layout.tsx
@@ -0,0 +1,4 @@
+/** CRM root — sub-routes supply portal shells. */
+export default function CrmRootLayout({ children }: { children: React.ReactNode }) {
+ return children;
+}
diff --git a/frontend/app/crm/not-found.tsx b/frontend/app/crm/not-found.tsx
new file mode 100644
index 0000000..c902e23
--- /dev/null
+++ b/frontend/app/crm/not-found.tsx
@@ -0,0 +1,13 @@
+import Link from "next/link";
+import { Button, EmptyState } from "@/components/ds";
+
+export default function CrmNotFound() {
+ return (
+
+ }
+ />
+
+ );
+}
diff --git a/frontend/app/crm/sales/activities/error.tsx b/frontend/app/crm/sales/activities/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/activities/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/activities/loading.tsx b/frontend/app/crm/sales/activities/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/activities/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/activities/page.tsx b/frontend/app/crm/sales/activities/page.tsx
new file mode 100644
index 0000000..e1e3f83
--- /dev/null
+++ b/frontend/app/crm/sales/activities/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/activities";
diff --git a/frontend/app/crm/sales/analytics/error.tsx b/frontend/app/crm/sales/analytics/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/analytics/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/analytics/loading.tsx b/frontend/app/crm/sales/analytics/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/analytics/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/analytics/page.tsx b/frontend/app/crm/sales/analytics/page.tsx
new file mode 100644
index 0000000..0ed9829
--- /dev/null
+++ b/frontend/app/crm/sales/analytics/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/analytics";
diff --git a/frontend/app/crm/sales/api-keys/error.tsx b/frontend/app/crm/sales/api-keys/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/api-keys/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/api-keys/loading.tsx b/frontend/app/crm/sales/api-keys/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/api-keys/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/api-keys/page.tsx b/frontend/app/crm/sales/api-keys/page.tsx
new file mode 100644
index 0000000..7f6eb80
--- /dev/null
+++ b/frontend/app/crm/sales/api-keys/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { ApiKeysPage as default } from "@/modules/crm/features/sales/scoped";
diff --git a/frontend/app/crm/sales/audit-logs/error.tsx b/frontend/app/crm/sales/audit-logs/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/audit-logs/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/audit-logs/loading.tsx b/frontend/app/crm/sales/audit-logs/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/audit-logs/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/audit-logs/page.tsx b/frontend/app/crm/sales/audit-logs/page.tsx
new file mode 100644
index 0000000..7a5487f
--- /dev/null
+++ b/frontend/app/crm/sales/audit-logs/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { AuditLogsPage as default } from "@/modules/crm/features/sales/admin";
diff --git a/frontend/app/crm/sales/automation/error.tsx b/frontend/app/crm/sales/automation/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/automation/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/automation/loading.tsx b/frontend/app/crm/sales/automation/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/automation/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/automation/page.tsx b/frontend/app/crm/sales/automation/page.tsx
new file mode 100644
index 0000000..53984be
--- /dev/null
+++ b/frontend/app/crm/sales/automation/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { AutomationPage as default } from "@/modules/crm/features/sales/scoped";
diff --git a/frontend/app/crm/sales/calls/error.tsx b/frontend/app/crm/sales/calls/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/calls/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/calls/loading.tsx b/frontend/app/crm/sales/calls/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/calls/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/calls/page.tsx b/frontend/app/crm/sales/calls/page.tsx
new file mode 100644
index 0000000..71514d8
--- /dev/null
+++ b/frontend/app/crm/sales/calls/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/calls";
diff --git a/frontend/app/crm/sales/campaigns/error.tsx b/frontend/app/crm/sales/campaigns/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/campaigns/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/campaigns/loading.tsx b/frontend/app/crm/sales/campaigns/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/campaigns/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/campaigns/page.tsx b/frontend/app/crm/sales/campaigns/page.tsx
new file mode 100644
index 0000000..a05ef09
--- /dev/null
+++ b/frontend/app/crm/sales/campaigns/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { CampaignsPage as default } from "@/modules/crm/features/sales/scoped";
diff --git a/frontend/app/crm/sales/capabilities/error.tsx b/frontend/app/crm/sales/capabilities/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/capabilities/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/capabilities/loading.tsx b/frontend/app/crm/sales/capabilities/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/capabilities/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/capabilities/page.tsx b/frontend/app/crm/sales/capabilities/page.tsx
new file mode 100644
index 0000000..dd04ee2
--- /dev/null
+++ b/frontend/app/crm/sales/capabilities/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { CapabilitiesPage as default } from "@/modules/crm/features/sales/admin";
diff --git a/frontend/app/crm/sales/companies/error.tsx b/frontend/app/crm/sales/companies/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/companies/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/companies/loading.tsx b/frontend/app/crm/sales/companies/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/companies/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/companies/page.tsx b/frontend/app/crm/sales/companies/page.tsx
new file mode 100644
index 0000000..8c85e04
--- /dev/null
+++ b/frontend/app/crm/sales/companies/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/companies";
diff --git a/frontend/app/crm/sales/contacts/error.tsx b/frontend/app/crm/sales/contacts/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/contacts/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/contacts/loading.tsx b/frontend/app/crm/sales/contacts/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/contacts/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/contacts/page.tsx b/frontend/app/crm/sales/contacts/page.tsx
new file mode 100644
index 0000000..16e55a6
--- /dev/null
+++ b/frontend/app/crm/sales/contacts/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/contacts";
diff --git a/frontend/app/crm/sales/custom-fields/error.tsx b/frontend/app/crm/sales/custom-fields/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/custom-fields/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/custom-fields/loading.tsx b/frontend/app/crm/sales/custom-fields/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/custom-fields/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/custom-fields/page.tsx b/frontend/app/crm/sales/custom-fields/page.tsx
new file mode 100644
index 0000000..7c0706b
--- /dev/null
+++ b/frontend/app/crm/sales/custom-fields/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/custom-fields";
diff --git a/frontend/app/crm/sales/customers/error.tsx b/frontend/app/crm/sales/customers/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/customers/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/customers/loading.tsx b/frontend/app/crm/sales/customers/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/customers/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/customers/page.tsx b/frontend/app/crm/sales/customers/page.tsx
new file mode 100644
index 0000000..aec818b
--- /dev/null
+++ b/frontend/app/crm/sales/customers/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/customers";
diff --git a/frontend/app/crm/sales/dashboard/activities/error.tsx b/frontend/app/crm/sales/dashboard/activities/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/activities/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/activities/loading.tsx b/frontend/app/crm/sales/dashboard/activities/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/activities/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/activities/page.tsx b/frontend/app/crm/sales/dashboard/activities/page.tsx
new file mode 100644
index 0000000..bc9f811
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/activities/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { ActivityDashboard as default } from "@/modules/crm/features/sales/dashboards";
diff --git a/frontend/app/crm/sales/dashboard/calendar/error.tsx b/frontend/app/crm/sales/dashboard/calendar/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/calendar/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/calendar/loading.tsx b/frontend/app/crm/sales/dashboard/calendar/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/calendar/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/calendar/page.tsx b/frontend/app/crm/sales/dashboard/calendar/page.tsx
new file mode 100644
index 0000000..0085ede
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/calendar/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { CalendarDashboard as default } from "@/modules/crm/features/sales/dashboards";
diff --git a/frontend/app/crm/sales/dashboard/leads/error.tsx b/frontend/app/crm/sales/dashboard/leads/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/leads/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/leads/loading.tsx b/frontend/app/crm/sales/dashboard/leads/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/leads/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/leads/page.tsx b/frontend/app/crm/sales/dashboard/leads/page.tsx
new file mode 100644
index 0000000..b162b95
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/leads/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { LeadDashboard as default } from "@/modules/crm/features/sales/dashboards";
diff --git a/frontend/app/crm/sales/dashboard/overview/error.tsx b/frontend/app/crm/sales/dashboard/overview/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/overview/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/overview/loading.tsx b/frontend/app/crm/sales/dashboard/overview/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/overview/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/overview/page.tsx b/frontend/app/crm/sales/dashboard/overview/page.tsx
new file mode 100644
index 0000000..490b097
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/overview/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { OverviewDashboard as default } from "@/modules/crm/features/sales/dashboards";
diff --git a/frontend/app/crm/sales/dashboard/pipeline/error.tsx b/frontend/app/crm/sales/dashboard/pipeline/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/pipeline/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/pipeline/loading.tsx b/frontend/app/crm/sales/dashboard/pipeline/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/pipeline/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/pipeline/page.tsx b/frontend/app/crm/sales/dashboard/pipeline/page.tsx
new file mode 100644
index 0000000..c6821cf
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/pipeline/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { PipelineDashboard as default } from "@/modules/crm/features/sales/dashboards";
diff --git a/frontend/app/crm/sales/dashboard/sales/error.tsx b/frontend/app/crm/sales/dashboard/sales/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/sales/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/sales/loading.tsx b/frontend/app/crm/sales/dashboard/sales/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/sales/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/dashboard/sales/page.tsx b/frontend/app/crm/sales/dashboard/sales/page.tsx
new file mode 100644
index 0000000..68132c7
--- /dev/null
+++ b/frontend/app/crm/sales/dashboard/sales/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { SalesDashboard as default } from "@/modules/crm/features/sales/dashboards";
diff --git a/frontend/app/crm/sales/deals/error.tsx b/frontend/app/crm/sales/deals/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/deals/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/deals/loading.tsx b/frontend/app/crm/sales/deals/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/deals/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/deals/page.tsx b/frontend/app/crm/sales/deals/page.tsx
new file mode 100644
index 0000000..650372d
--- /dev/null
+++ b/frontend/app/crm/sales/deals/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/deals";
diff --git a/frontend/app/crm/sales/documents/error.tsx b/frontend/app/crm/sales/documents/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/documents/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/documents/loading.tsx b/frontend/app/crm/sales/documents/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/documents/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/documents/page.tsx b/frontend/app/crm/sales/documents/page.tsx
new file mode 100644
index 0000000..e97ec1d
--- /dev/null
+++ b/frontend/app/crm/sales/documents/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/documents";
diff --git a/frontend/app/crm/sales/emails/error.tsx b/frontend/app/crm/sales/emails/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/emails/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/emails/loading.tsx b/frontend/app/crm/sales/emails/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/emails/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/emails/page.tsx b/frontend/app/crm/sales/emails/page.tsx
new file mode 100644
index 0000000..b9fed74
--- /dev/null
+++ b/frontend/app/crm/sales/emails/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/emails";
diff --git a/frontend/app/crm/sales/export/error.tsx b/frontend/app/crm/sales/export/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/export/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/export/loading.tsx b/frontend/app/crm/sales/export/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/export/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/export/page.tsx b/frontend/app/crm/sales/export/page.tsx
new file mode 100644
index 0000000..1a6e599
--- /dev/null
+++ b/frontend/app/crm/sales/export/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/export";
diff --git a/frontend/app/crm/sales/health/error.tsx b/frontend/app/crm/sales/health/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/health/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/health/loading.tsx b/frontend/app/crm/sales/health/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/health/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/health/page.tsx b/frontend/app/crm/sales/health/page.tsx
new file mode 100644
index 0000000..a3da2a2
--- /dev/null
+++ b/frontend/app/crm/sales/health/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { HealthPage as default } from "@/modules/crm/features/sales/admin";
diff --git a/frontend/app/crm/sales/import/error.tsx b/frontend/app/crm/sales/import/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/import/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/import/loading.tsx b/frontend/app/crm/sales/import/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/import/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/import/page.tsx b/frontend/app/crm/sales/import/page.tsx
new file mode 100644
index 0000000..775cafd
--- /dev/null
+++ b/frontend/app/crm/sales/import/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { ImportPage as default } from "@/modules/crm/features/sales/scoped";
diff --git a/frontend/app/crm/sales/integrations/error.tsx b/frontend/app/crm/sales/integrations/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/integrations/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/integrations/loading.tsx b/frontend/app/crm/sales/integrations/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/integrations/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/integrations/page.tsx b/frontend/app/crm/sales/integrations/page.tsx
new file mode 100644
index 0000000..24a7c01
--- /dev/null
+++ b/frontend/app/crm/sales/integrations/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { IntegrationsPage as default } from "@/modules/crm/features/sales/scoped";
diff --git a/frontend/app/crm/sales/kanban/error.tsx b/frontend/app/crm/sales/kanban/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/kanban/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/kanban/loading.tsx b/frontend/app/crm/sales/kanban/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/kanban/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/kanban/page.tsx b/frontend/app/crm/sales/kanban/page.tsx
new file mode 100644
index 0000000..6d8a7d4
--- /dev/null
+++ b/frontend/app/crm/sales/kanban/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/kanban";
diff --git a/frontend/app/crm/sales/layout.tsx b/frontend/app/crm/sales/layout.tsx
new file mode 100644
index 0000000..6804672
--- /dev/null
+++ b/frontend/app/crm/sales/layout.tsx
@@ -0,0 +1,5 @@
+"use client";
+
+import { createPortalLayout } from "@/modules/crm/components/createPortalLayout";
+
+export default createPortalLayout("sales");
diff --git a/frontend/app/crm/sales/leads/error.tsx b/frontend/app/crm/sales/leads/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/leads/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/leads/loading.tsx b/frontend/app/crm/sales/leads/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/leads/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/leads/page.tsx b/frontend/app/crm/sales/leads/page.tsx
new file mode 100644
index 0000000..d217738
--- /dev/null
+++ b/frontend/app/crm/sales/leads/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/leads";
diff --git a/frontend/app/crm/sales/meetings/error.tsx b/frontend/app/crm/sales/meetings/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/meetings/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/meetings/loading.tsx b/frontend/app/crm/sales/meetings/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/meetings/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/meetings/page.tsx b/frontend/app/crm/sales/meetings/page.tsx
new file mode 100644
index 0000000..5f064da
--- /dev/null
+++ b/frontend/app/crm/sales/meetings/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/meetings";
diff --git a/frontend/app/crm/sales/notes/error.tsx b/frontend/app/crm/sales/notes/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/notes/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/notes/loading.tsx b/frontend/app/crm/sales/notes/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/notes/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/notes/page.tsx b/frontend/app/crm/sales/notes/page.tsx
new file mode 100644
index 0000000..b1c5a64
--- /dev/null
+++ b/frontend/app/crm/sales/notes/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/notes";
diff --git a/frontend/app/crm/sales/notifications/error.tsx b/frontend/app/crm/sales/notifications/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/notifications/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/notifications/loading.tsx b/frontend/app/crm/sales/notifications/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/notifications/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/notifications/page.tsx b/frontend/app/crm/sales/notifications/page.tsx
new file mode 100644
index 0000000..775410e
--- /dev/null
+++ b/frontend/app/crm/sales/notifications/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { NotificationsPage as default } from "@/modules/crm/features/sales/admin";
diff --git a/frontend/app/crm/sales/permissions/error.tsx b/frontend/app/crm/sales/permissions/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/permissions/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/permissions/loading.tsx b/frontend/app/crm/sales/permissions/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/permissions/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/permissions/page.tsx b/frontend/app/crm/sales/permissions/page.tsx
new file mode 100644
index 0000000..c4cf943
--- /dev/null
+++ b/frontend/app/crm/sales/permissions/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { PermissionsPage as default } from "@/modules/crm/features/sales/admin";
diff --git a/frontend/app/crm/sales/pipelines/error.tsx b/frontend/app/crm/sales/pipelines/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/pipelines/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/pipelines/loading.tsx b/frontend/app/crm/sales/pipelines/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/pipelines/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/pipelines/page.tsx b/frontend/app/crm/sales/pipelines/page.tsx
new file mode 100644
index 0000000..379dbb8
--- /dev/null
+++ b/frontend/app/crm/sales/pipelines/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/pipelines";
diff --git a/frontend/app/crm/sales/quotes/error.tsx b/frontend/app/crm/sales/quotes/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/quotes/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/quotes/loading.tsx b/frontend/app/crm/sales/quotes/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/quotes/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/quotes/page.tsx b/frontend/app/crm/sales/quotes/page.tsx
new file mode 100644
index 0000000..9ff6c0d
--- /dev/null
+++ b/frontend/app/crm/sales/quotes/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/quotes";
diff --git a/frontend/app/crm/sales/reports/error.tsx b/frontend/app/crm/sales/reports/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/reports/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/reports/loading.tsx b/frontend/app/crm/sales/reports/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/reports/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/reports/page.tsx b/frontend/app/crm/sales/reports/page.tsx
new file mode 100644
index 0000000..997967c
--- /dev/null
+++ b/frontend/app/crm/sales/reports/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/reports";
diff --git a/frontend/app/crm/sales/roles/error.tsx b/frontend/app/crm/sales/roles/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/roles/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/roles/loading.tsx b/frontend/app/crm/sales/roles/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/roles/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/roles/page.tsx b/frontend/app/crm/sales/roles/page.tsx
new file mode 100644
index 0000000..ca0b8ce
--- /dev/null
+++ b/frontend/app/crm/sales/roles/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { RolesPage as default } from "@/modules/crm/features/sales/admin";
diff --git a/frontend/app/crm/sales/segments/error.tsx b/frontend/app/crm/sales/segments/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/segments/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/segments/loading.tsx b/frontend/app/crm/sales/segments/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/segments/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/segments/page.tsx b/frontend/app/crm/sales/segments/page.tsx
new file mode 100644
index 0000000..ab3ddf4
--- /dev/null
+++ b/frontend/app/crm/sales/segments/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { SegmentsPage as default } from "@/modules/crm/features/sales/scoped";
diff --git a/frontend/app/crm/sales/settings/error.tsx b/frontend/app/crm/sales/settings/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/settings/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/settings/loading.tsx b/frontend/app/crm/sales/settings/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/settings/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/settings/page.tsx b/frontend/app/crm/sales/settings/page.tsx
new file mode 100644
index 0000000..e20d996
--- /dev/null
+++ b/frontend/app/crm/sales/settings/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { SettingsPage as default } from "@/modules/crm/features/sales/admin";
diff --git a/frontend/app/crm/sales/tags/error.tsx b/frontend/app/crm/sales/tags/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/tags/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/tags/loading.tsx b/frontend/app/crm/sales/tags/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/tags/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/tags/page.tsx b/frontend/app/crm/sales/tags/page.tsx
new file mode 100644
index 0000000..8b2f1fb
--- /dev/null
+++ b/frontend/app/crm/sales/tags/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/tags";
diff --git a/frontend/app/crm/sales/tasks/error.tsx b/frontend/app/crm/sales/tasks/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/tasks/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/tasks/loading.tsx b/frontend/app/crm/sales/tasks/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/tasks/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/tasks/page.tsx b/frontend/app/crm/sales/tasks/page.tsx
new file mode 100644
index 0000000..8fd9126
--- /dev/null
+++ b/frontend/app/crm/sales/tasks/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/tasks";
diff --git a/frontend/app/crm/sales/timeline/error.tsx b/frontend/app/crm/sales/timeline/error.tsx
new file mode 100644
index 0000000..b8dfa5e
--- /dev/null
+++ b/frontend/app/crm/sales/timeline/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { ErrorState } from "@/components/ds";
+
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
diff --git a/frontend/app/crm/sales/timeline/loading.tsx b/frontend/app/crm/sales/timeline/loading.tsx
new file mode 100644
index 0000000..141fa78
--- /dev/null
+++ b/frontend/app/crm/sales/timeline/loading.tsx
@@ -0,0 +1,5 @@
+import { LoadingState } from "@/components/ds";
+
+export default function Loading() {
+ return ;
+}
diff --git a/frontend/app/crm/sales/timeline/page.tsx b/frontend/app/crm/sales/timeline/page.tsx
new file mode 100644
index 0000000..28ae7b9
--- /dev/null
+++ b/frontend/app/crm/sales/timeline/page.tsx
@@ -0,0 +1,3 @@
+"use client";
+
+export { default } from "@/modules/crm/features/sales/timeline";
diff --git a/frontend/docs/crm-component-map.md b/frontend/docs/crm-component-map.md
new file mode 100644
index 0000000..196c9cc
--- /dev/null
+++ b/frontend/docs/crm-component-map.md
@@ -0,0 +1,68 @@
+# CRM Component Map
+
+**Date:** 2026-07-26
+
+## Module components (`modules/crm/`)
+
+| Component | Path | Purpose |
+|-----------|------|---------|
+| `CrmPortalShell` | `components/CrmPortalShell.tsx` | Sidebar nav, mobile drawer, theme toggle |
+| `createPortalLayout` | `components/createPortalLayout.tsx` | AuthGuard + tenant gate + shell |
+| `CrmBreadcrumbs` | `components/CrmBreadcrumbs.tsx` | CRM-root breadcrumbs |
+| `CrmScopeNotice` | `components/CrmScopeNotice.tsx` | Out-of-scope feature messaging |
+| `CrmListCrudPage` / `createCrmListPage` | `components/CrmListCrudPage.tsx` | Generic list CRUD factory |
+| `CrmTablePage` | `design-system/CrmTablePage.tsx` | Search, sort, pagination, bulk bar |
+| `CrmStatGrid` | `design-system/CrmTablePage.tsx` | Dashboard stat cards |
+| `CrmStatusChip` | `design-system/CrmStatusChip.tsx` | Domain status badges |
+
+## Shared platform (`@/components/ds`)
+
+Used across all CRM pages:
+
+- `PageHeader`, `DataTable`, `EmptyState`, `LoadingState`, `ErrorState`
+- `Button`, `Input`, `Textarea`, `Select`, `FormField`, `Checkbox`
+- `Dialog`, `Drawer`, `ConfirmDialog`
+- `Card`, `Badge`, `Pagination`, `Skeleton`, `Breadcrumbs`
+
+## Feature pages (`modules/crm/features/sales/`)
+
+| File | Default export / named | Entities |
+|------|------------------------|----------|
+| `dashboards.tsx` | 6 dashboard exports | Aggregates |
+| `leads.tsx` | default | Lead CRUD |
+| `contacts.tsx` | default | Contact CRUD |
+| `companies.tsx` | default | Organization CRUD |
+| `customers.tsx` | default | Combined view |
+| `deals.tsx` | default | Opportunity CRUD + win/lose |
+| `kanban.tsx` | default | Pipeline board |
+| `pipelines.tsx` | default | Pipeline CRUD |
+| `quotes.tsx` | default | Quote CRUD |
+| `activities.tsx` | default | SalesActivity CRUD |
+| `tasks.tsx` | default | Task create + complete |
+| `meetings.tsx` | default | Meeting create |
+| `calls.tsx` | default | CallLog create |
+| `emails.tsx` | default | Email metadata create |
+| `notes.tsx` | default | Note per entity |
+| `timeline.tsx` | default | Timeline list |
+| `documents.tsx` | default | Attachment refs |
+| `tags.tsx` | default | Tag CRUD |
+| `custom-fields.tsx` | default | CustomField CRUD |
+| `export.tsx` | default | CSV export |
+| `reports.tsx` | default | Client reports |
+| `analytics.tsx` | default | Analytics dashboard |
+| `scoped.tsx` | Campaigns, Segments, Import, Automation, Integrations, ApiKeys | Scope notices |
+| `admin.tsx` | Notifications, Roles, Permissions, Audit, Settings, Health, Capabilities | Admin |
+
+## Services & hooks
+
+| Symbol | Path |
+|--------|------|
+| `crmApi` | `services/crm-api.ts` |
+| `useCrmKeyboardShortcuts` | `hooks/useCrmKeyboardShortcuts.ts` |
+| `useCrmHealth`, `useCrmCounts` | `pages/shared.tsx` |
+
+## Query key namespace
+
+Prefix: `["crm", tenantId, ...]`
+
+Examples: `leads`, `opportunities`, `mentions-unread`, `counts`
diff --git a/frontend/docs/crm-responsive-report.md b/frontend/docs/crm-responsive-report.md
new file mode 100644
index 0000000..dfb02e6
--- /dev/null
+++ b/frontend/docs/crm-responsive-report.md
@@ -0,0 +1,60 @@
+# CRM Responsive Report
+
+**Date:** 2026-07-26
+
+## Breakpoints tested (design intent)
+
+| Breakpoint | Layout behavior |
+|------------|-----------------|
+| Desktop (≥1024px) | Fixed sidebar 256px, full table, multi-column dashboards |
+| Tablet (768–1023px) | Collapsible nav groups, horizontal scroll tables, 2-col stat grid |
+| Mobile (<768px) | Hamburger → drawer sidebar, stacked forms, single-col stats |
+
+## Component responsiveness
+
+| Component | Desktop | Tablet | Mobile |
+|-----------|---------|--------|--------|
+| `CrmPortalShell` | Sidebar visible | Sidebar visible | Drawer overlay |
+| `CrmTablePage` | Full table | `overflow-x-auto` min-width 640px | Horizontal scroll + compact toolbar |
+| `CrmStatGrid` | 4 columns (`xl:grid-cols-4`) | 2 columns | 1–2 columns |
+| Kanban board | Multi-column row | Horizontal scroll columns | Swipe scroll columns |
+| Dialog / Drawer | Center dialog / side drawer | Full-width dialog | Full-screen friendly padding |
+| Customers split view | 2-column grid | 2-column | Stacked sections |
+
+## RTL
+
+- All layouts use logical RTL flow (Persian copy, right-aligned nav)
+- Search icon positioned `right-3` for RTL input padding
+- Breadcrumbs and sidebar labels right-aligned
+
+## Dark mode
+
+- Uses global `ColorModeProvider` toggle in shell
+- CRM tokens defined in `:root` and `.dark` in `globals.css`
+- Surfaces: `--surface`, `--canvas`, `--border` from platform DS
+
+## Accessibility
+
+- Dialog: `role="dialog"`, `aria-modal`, Escape to close
+- Loading: `LoadingState` with visible label
+- Tables: semantic `` via `DataTable`
+- Icon buttons: `aria-label` on edit/delete/complete actions
+- Sort select: `aria-label="مرتبسازی"`
+
+## Touch targets
+
+- Mobile menu button 44px (`size="icon"`)
+- Kanban stage move buttons padded for touch
+- Pagination buttons min height ~36px
+
+## Performance notes
+
+- Lists fetch up to 500 rows per resource (backend max page_size)
+- Dashboard parallel `Promise.all` for counts
+- TanStack Query caching per `["crm", tenantId, resource]`
+
+## Gaps / follow-ups
+
+1. Kanban drag-and-drop not implemented — uses button stage move (touch-friendly alternative)
+2. Calendar dashboard is list view, not full calendar grid (no calendar API)
+3. Virtualized tables not added — acceptable for ≤500 row client cap
diff --git a/frontend/docs/crm-routing-map.md b/frontend/docs/crm-routing-map.md
new file mode 100644
index 0000000..eab7926
--- /dev/null
+++ b/frontend/docs/crm-routing-map.md
@@ -0,0 +1,80 @@
+# CRM Routing Map
+
+**Date:** 2026-07-26
+**Pattern:** Thin `app/crm/**/page.tsx` → `modules/crm/features/**`
+
+## Root
+
+| Route | File | Module export |
+|-------|------|---------------|
+| `/crm` | — | Redirect via hub link |
+| `/crm/hub` | `app/crm/hub/page.tsx` | `CrmHub` from `pages/hub` |
+
+## Sales portal layout
+
+`app/crm/sales/layout.tsx` → `createPortalLayout("sales")`
+
+## Sales routes
+
+| URL | Feature module |
+|-----|----------------|
+| `/crm/sales/dashboard/overview` | `features/sales/dashboards` → `OverviewDashboard` |
+| `/crm/sales/dashboard/sales` | `SalesDashboard` |
+| `/crm/sales/dashboard/pipeline` | `PipelineDashboard` |
+| `/crm/sales/dashboard/leads` | `LeadDashboard` |
+| `/crm/sales/dashboard/activities` | `ActivityDashboard` |
+| `/crm/sales/dashboard/calendar` | `CalendarDashboard` |
+| `/crm/sales/customers` | `features/sales/customers` |
+| `/crm/sales/companies` | `features/sales/companies` |
+| `/crm/sales/contacts` | `features/sales/contacts` |
+| `/crm/sales/leads` | `features/sales/leads` |
+| `/crm/sales/deals` | `features/sales/deals` |
+| `/crm/sales/kanban` | `features/sales/kanban` |
+| `/crm/sales/pipelines` | `features/sales/pipelines` |
+| `/crm/sales/quotes` | `features/sales/quotes` |
+| `/crm/sales/activities` | `features/sales/activities` |
+| `/crm/sales/tasks` | `features/sales/tasks` |
+| `/crm/sales/meetings` | `features/sales/meetings` |
+| `/crm/sales/calls` | `features/sales/calls` |
+| `/crm/sales/emails` | `features/sales/emails` |
+| `/crm/sales/notes` | `features/sales/notes` |
+| `/crm/sales/timeline` | `features/sales/timeline` |
+| `/crm/sales/documents` | `features/sales/documents` |
+| `/crm/sales/campaigns` | `features/sales/scoped` → `CampaignsPage` |
+| `/crm/sales/segments` | `SegmentsPage` |
+| `/crm/sales/tags` | `features/sales/tags` |
+| `/crm/sales/custom-fields` | `features/sales/custom-fields` |
+| `/crm/sales/import` | `ImportPage` |
+| `/crm/sales/export` | `features/sales/export` |
+| `/crm/sales/reports` | `features/sales/reports` |
+| `/crm/sales/analytics` | `features/sales/analytics` |
+| `/crm/sales/automation` | `AutomationPage` |
+| `/crm/sales/notifications` | `features/sales/admin` → `NotificationsPage` |
+| `/crm/sales/roles` | `RolesPage` |
+| `/crm/sales/permissions` | `PermissionsPage` |
+| `/crm/sales/integrations` | `IntegrationsPage` |
+| `/crm/sales/api-keys` | `ApiKeysPage` |
+| `/crm/sales/audit-logs` | `AuditLogsPage` |
+| `/crm/sales/settings` | `SettingsPage` |
+| `/crm/sales/health` | `HealthPage` |
+| `/crm/sales/capabilities` | `CapabilitiesPage` |
+
+## Segment files (each route)
+
+Every route directory includes:
+
+- `page.tsx` — re-export only
+- `loading.tsx` — `LoadingState`
+- `error.tsx` — `ErrorState` + reset
+
+Root: `app/crm/layout.tsx`, `app/crm/not-found.tsx`
+
+## BFF
+
+| Browser path | Upstream |
+|--------------|----------|
+| `/api/crm/*` | `CRM_SERVICE_URL` / `localhost:8003` |
+
+## Generator
+
+Regenerate routes: `node scripts/generate-crm-routes.mjs`
diff --git a/frontend/docs/crm-ui-specification.md b/frontend/docs/crm-ui-specification.md
new file mode 100644
index 0000000..1de2a2f
--- /dev/null
+++ b/frontend/docs/crm-ui-specification.md
@@ -0,0 +1,91 @@
+# CRM UI Specification
+
+**Product:** Torbat CRM (Enterprise Sales CRM)
+**Version:** Phase 6.3 frontend integration
+**Date:** 2026-07-26
+
+## Product identity
+
+| Aspect | Choice |
+|--------|--------|
+| Accent | `--crm-accent` indigo (`#6366f1` light / `#818cf8` dark) |
+| Tone | Professional, minimal, low noise, desktop-first |
+| Language | RTL-first (Persian labels) |
+| Shell | Collapsible sidebar groups, mobile drawer, dark mode toggle |
+
+Distinct from Accounting (sky/operational), Beauty (rose/warm), Healthcare (teal/clinical).
+
+## Architecture
+
+```
+app/crm/ # Thin routes only (page, layout, loading, error)
+modules/crm/ # All business UI
+ services/crm-api.ts # Typed client → /api/crm BFF → :8003
+ components/ # Shell, CRUD factory, breadcrumbs
+ design-system/ # CrmTablePage, CrmStatGrid, CrmStatusChip
+ features/sales/ # Page implementations
+ constants/portals.ts # Navigation
+```
+
+## Page inventory (41 routes)
+
+All pages under `/crm/sales/*` unless noted.
+
+| Area | Pages | Backend |
+|------|-------|---------|
+| Hub | `/crm/hub` | `/health` |
+| Dashboards | overview, sales, pipeline, leads, activities, calendar | List aggregates |
+| Entities | customers, companies, contacts, leads | Real CRUD APIs |
+| Sales | deals, kanban, pipelines, quotes | opportunities, pipelines, quotes |
+| Collaboration | activities, tasks, meetings, calls, emails, notes, timeline, documents | Phase 6.3 APIs |
+| Marketing | tags | tags API; campaigns/segments scoped notice |
+| Data | custom-fields, import, export | custom-fields; import N/A; export client CSV |
+| Insights | reports, analytics, automation | computed / scoped |
+| Admin | notifications, roles, permissions, integrations, api-keys, audit-logs, settings, health, capabilities | partial + static |
+
+## Standard page contract
+
+Every list page includes:
+
+- `CrmPageLoader` / skeleton via `CrmTablePage`
+- `CrmPageError` with 403 permission state
+- `EmptyState` with create action
+- Search (client-side filter)
+- Sort dropdown (client-side)
+- Pagination (client-side slice; server uses page/page_size fetch)
+- Bulk selection + bulk delete where API supports
+- Dialog create + Drawer edit + ConfirmDialog delete
+- Toast via Sonner
+- Breadcrumb via `CrmBreadcrumbs`
+
+## Keyboard shortcuts
+
+| Chord | Target |
+|-------|--------|
+| `g` then `o` | Overview dashboard |
+| `g` then `l` | Leads |
+| `g` then `c` | Contacts |
+| `g` then `d` | Deals |
+| `g` then `k` | Kanban |
+
+## Out of scope (honest UI)
+
+Pages show `CrmScopeNotice` instead of fake data:
+
+- Campaigns, Segments, Import, Automation, Integrations, API Keys
+- Global audit (per-entity audit only)
+- `/capabilities` (health version only)
+
+## API conventions
+
+- Header: `Authorization: Bearer`, `X-Tenant-ID`
+- Soft delete: `POST /{id}/delete` (leads, contacts, organizations)
+- List: `page`, `page_size` — no server search/sort/filter
+- Deals = `opportunities`; Customers = contacts + organizations
+
+## Related
+
+- [crm-component-map.md](./crm-component-map.md)
+- [crm-routing-map.md](./crm-routing-map.md)
+- [crm-validation-report.md](./crm-validation-report.md)
+- [crm-responsive-report.md](./crm-responsive-report.md)
diff --git a/frontend/docs/crm-validation-report.md b/frontend/docs/crm-validation-report.md
new file mode 100644
index 0000000..75fcb5f
--- /dev/null
+++ b/frontend/docs/crm-validation-report.md
@@ -0,0 +1,86 @@
+# CRM Validation Report
+
+**Date:** 2026-07-26
+**Scope:** CRM frontend (`modules/crm/`, `app/crm/`)
+
+## Quality gates
+
+| Gate | Result | Notes |
+|------|--------|-------|
+| `npm run typecheck` | ✅ Pass | All CRM modules type-clean |
+| `npm run lint` | ✅ Pass | No CRM-specific warnings |
+| `npm run validate:architecture` | ✅ Pass | No cross-domain imports |
+| `npm run build` | ⚠️ Compile OK | Typecheck in build passed; final step hit transient Next font-manifest env error |
+
+## Route validation
+
+| Check | Count | Status |
+|-------|-------|--------|
+| Required pages (spec) | 41 | ✅ Generated |
+| Thin `page.tsx` (re-export only) | 41 | ✅ |
+| `loading.tsx` per route | 41 | ✅ |
+| `error.tsx` per route | 41 | ✅ |
+| Sales layout + portal gate | 1 | ✅ |
+| Root layout + not-found | 2 | ✅ |
+
+## API validation
+
+| Domain | Connected | Mock/placeholder removed |
+|--------|-----------|---------------------------|
+| Leads | ✅ CRUD + assign + restore | ✅ |
+| Contacts | ✅ CRUD + delete | ✅ |
+| Organizations | ✅ CRUD + delete | ✅ |
+| Opportunities (deals) | ✅ CRUD + stage + win/lose | ✅ |
+| Pipelines | ✅ CRUD | ✅ |
+| Activities | ✅ CRUD + complete | ✅ |
+| Tasks | ✅ create + complete | ✅ |
+| Meetings, Calls | ✅ create + list | ✅ |
+| Quotes | ✅ CRUD | ✅ |
+| Tags, Notes, Attachments, Custom fields | ✅ | ✅ |
+| Timeline, Mentions | ✅ list / read | ✅ |
+| Team roles/members | ✅ partial (no role list API) | ✅ honest UI |
+| Campaigns, Segments, Import, Automation, Integrations, API keys | N/A backend | ✅ scope notice (no fake data) |
+
+## CRUD validation
+
+| Operation | Supported pages |
+|-----------|-----------------|
+| Create | All entity list pages via Dialog |
+| Read | All list + dashboard aggregates |
+| Update | leads, contacts, companies, deals, activities, quotes, pipelines |
+| Delete | leads, contacts, companies (+ bulk) |
+| Restore | leads only (API) |
+| Archive | via soft-delete flags in list filter |
+| Export | client CSV on export page + per-entity export button |
+| Import | scope notice — no API |
+
+## Permission validation
+
+- `CrmPageError` maps `CrmApiError` 403 → permission empty state
+- Static permission tree on `/crm/sales/permissions`
+- No client-side RBAC gate (matches platform pattern); server enforces JWT roles
+
+## Navigation validation
+
+- All sidebar links in `constants/portals.ts` resolve to generated routes
+- Hub → sales portal → all sub-pages
+- Catalog tile: `apps-catalog.ts` → `/crm/hub` status `available`
+
+## Design system validation
+
+- CRM uses `@/components/ds` only (no duplicate atoms)
+- Module accent via `--crm-accent` tokens
+- Consistent `CrmTablePage` / `PageHeader` / card radius `rounded-2xl`
+
+## Known limitations (by design)
+
+1. No server-side search/filter/sort — client-side only
+2. No list total count from API — pagination is approximate
+3. Tasks/meetings: no update/delete API in backend 6.3
+4. `/capabilities` endpoint missing — health version shown instead
+
+## Recommendations (future)
+
+1. Add `GET /capabilities` to CRM backend for discovery UI
+2. Add server-side filter params when backend supports them
+3. Shared `useCrmPermission()` hook when Identity exposes user permissions to FE
diff --git a/frontend/lib/apps-catalog.ts b/frontend/lib/apps-catalog.ts
index 8ecc214..d6e7d52 100644
--- a/frontend/lib/apps-catalog.ts
+++ b/frontend/lib/apps-catalog.ts
@@ -22,13 +22,41 @@ export const PLATFORM_APPS: PlatformApp[] = [
status: "available",
href: "/accounting",
},
+ {
+ id: "healthcare",
+ name: "تربت هلث",
+ description: "کلینیک، پزشک، بیمار، نوبتدهی",
+ icon: "🏥",
+ gradient: "from-rose-500 to-red-600",
+ status: "available",
+ href: "/healthcare/hub",
+ },
+ {
+ id: "beauty",
+ name: "تربت بیوتی",
+ description: "سالن، نوبت، خدمات زیبایی",
+ icon: "💅",
+ gradient: "from-pink-500 to-fuchsia-600",
+ status: "available",
+ href: "/beauty/hub",
+ },
{
id: "crm",
name: "سیستم CRM",
description: "مدیریت مشتری و فروش",
icon: "🤝",
gradient: "from-violet-500 to-purple-600",
- status: "coming_soon",
+ status: "available",
+ href: "/crm/hub",
+ },
+ {
+ id: "sports_center",
+ name: "مرکز ورزشی",
+ description: "عضویت، تمرین، مسابقات",
+ icon: "🏋️",
+ gradient: "from-blue-500 to-indigo-600",
+ status: "available",
+ href: "/sports-center/hub",
},
{
id: "ecommerce",
diff --git a/frontend/modules/crm/components/CrmBreadcrumbs.tsx b/frontend/modules/crm/components/CrmBreadcrumbs.tsx
new file mode 100644
index 0000000..5f705ee
--- /dev/null
+++ b/frontend/modules/crm/components/CrmBreadcrumbs.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { Breadcrumbs } from "@/components/ds";
+
+export function CrmBreadcrumbs({
+ items,
+}: {
+ items: { label: string; href?: string }[];
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/modules/crm/components/CrmListCrudPage.tsx b/frontend/modules/crm/components/CrmListCrudPage.tsx
new file mode 100644
index 0000000..a3d44a8
--- /dev/null
+++ b/frontend/modules/crm/components/CrmListCrudPage.tsx
@@ -0,0 +1,331 @@
+"use client";
+
+import { useState } from "react";
+import { useForm, type FieldValues, type DefaultValues, type Resolver } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Plus, Pencil, Trash2, RotateCcw } from "lucide-react";
+import type { z } from "zod";
+import {
+ Button,
+ Dialog,
+ Drawer,
+ Input,
+ EmptyState,
+ FormField,
+ ConfirmDialog,
+ Textarea,
+ Select,
+} from "@/components/ds";
+import { CrmTablePage } from "@/modules/crm/design-system";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+import { CrmPageLoader, CrmPageError } from "@/modules/crm/pages/shared";
+import { useTenantId } from "@/hooks/useTenantId";
+import { exportToCsv } from "@/modules/crm/pages/shared";
+
+export type CrmFieldConfig = {
+ name: string;
+ label: string;
+ type?: "text" | "email" | "tel" | "number" | "textarea" | "select";
+ options?: { value: string; label: string }[];
+ required?: boolean;
+ editOnly?: boolean;
+ createOnly?: boolean;
+};
+
+export type CrmListCrudConfig = {
+ title: string;
+ description?: string;
+ breadcrumb?: string;
+ resourceKey: string;
+ createSchema: z.ZodTypeAny;
+ editSchema: z.ZodTypeAny;
+ createDefaults: DefaultValues;
+ fields: CrmFieldConfig[];
+ columns: {
+ key: string;
+ header: string;
+ render?: (row: T) => React.ReactNode;
+ }[];
+ list: (tenantId: string) => Promise;
+ create: (tenantId: string, data: FieldValues) => Promise;
+ update?: (tenantId: string, id: string, data: FieldValues) => Promise;
+ remove?: (tenantId: string, id: string) => Promise;
+ restore?: (tenantId: string, id: string) => Promise;
+ exportColumns?: string[];
+ filterDeleted?: boolean;
+};
+
+function renderField(
+ field: CrmFieldConfig,
+ register: ReturnType["register"],
+ mode: "create" | "edit"
+) {
+ if (mode === "create" && field.editOnly) return null;
+ if (mode === "edit" && field.createOnly) return null;
+
+ if (field.type === "textarea") {
+ return (
+
+
+
+ );
+ }
+ if (field.type === "select" && field.options) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+export function createCrmListPage(
+ config: CrmListCrudConfig
+) {
+ return function CrmListPage() {
+ const { tenantId } = useTenantId();
+ const qc = useQueryClient();
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editRow, setEditRow] = useState(null);
+ const [deleteId, setDeleteId] = useState(null);
+ const [page, setPage] = useState(1);
+ const [selected, setSelected] = useState>(new Set());
+ const pageSize = 20;
+
+ const listQ = useQuery({
+ queryKey: ["crm", tenantId, config.resourceKey],
+ queryFn: () => config.list(tenantId!),
+ enabled: !!tenantId,
+ });
+
+ const createForm = useForm({
+ resolver: zodResolver(config.createSchema as never) as Resolver,
+ defaultValues: config.createDefaults,
+ });
+ const editForm = useForm({
+ resolver: zodResolver(config.editSchema as never) as Resolver,
+ });
+
+ const invalidate = () =>
+ qc.invalidateQueries({ queryKey: ["crm", tenantId, config.resourceKey] });
+
+ const createM = useMutation({
+ mutationFn: (d: FieldValues) => config.create(tenantId!, d),
+ onSuccess: async () => {
+ toast.success("رکورد ایجاد شد");
+ setCreateOpen(false);
+ createForm.reset(config.createDefaults);
+ await invalidate();
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const updateM = useMutation({
+ mutationFn: (d: FieldValues) => config.update!(tenantId!, editRow!.id, d),
+ onSuccess: async () => {
+ toast.success("رکورد بهروز شد");
+ setEditRow(null);
+ await invalidate();
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const deleteM = useMutation({
+ mutationFn: (id: string) => config.remove!(tenantId!, id),
+ onSuccess: async () => {
+ toast.success("رکورد حذف شد");
+ setDeleteId(null);
+ await invalidate();
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const restoreM = useMutation({
+ mutationFn: (id: string) => config.restore!(tenantId!, id),
+ onSuccess: async () => {
+ toast.success("رکورد بازیابی شد");
+ await invalidate();
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const openEdit = (row: T) => {
+ setEditRow(row);
+ const values: FieldValues = {};
+ for (const f of config.fields) {
+ if (f.createOnly) continue;
+ values[f.name] = (row as Record)[f.name] ?? "";
+ }
+ editForm.reset(values);
+ };
+
+ if (!tenantId || listQ.isLoading) return ;
+ if (listQ.error) return listQ.refetch()} />;
+
+ let rows = listQ.data ?? [];
+ if (config.filterDeleted) rows = rows.filter((r) => !r.is_deleted);
+
+ const tableColumns = [
+ ...config.columns.map((c) => ({
+ key: c.key,
+ header: c.header,
+ render: c.render
+ ? (row: Record) => c.render!(row as T)
+ : undefined,
+ })),
+ {
+ key: "actions",
+ header: "عملیات",
+ searchable: false as const,
+ render: (row: Record) => {
+ const r = row as T;
+ return (
+
+ {config.update ? (
+
+ ) : null}
+ {config.remove && !r.is_deleted ? (
+
+ ) : null}
+ {config.restore && r.is_deleted ? (
+
+ ) : null}
+
+ );
+ },
+ },
+ ];
+
+ return (
+
+
+ ) : undefined
+ }
+ title={config.title}
+ description={config.description}
+ columns={tableColumns}
+ rows={rows as unknown as Record
[]}
+ page={page}
+ pageSize={pageSize}
+ onPageChange={setPage}
+ selectedIds={selected}
+ onSelectionChange={setSelected}
+ bulkActions={
+ config.remove && selected.size > 0 ? (
+
+ ) : undefined
+ }
+ actions={
+
+ {config.exportColumns ? (
+
+ ) : null}
+
+
+ }
+ empty={ setCreateOpen(true)}>ایجاد} />}
+ />
+
+
+
+ {config.update ? (
+ setEditRow(null)} title="ویرایش">
+
+
+ ) : null}
+
+ {config.remove ? (
+ setDeleteId(null)}
+ title="حذف رکورد"
+ description="آیا از حذف این مورد مطمئن هستید؟"
+ confirmLabel="حذف"
+ onConfirm={() => deleteId && deleteM.mutate(deleteId)}
+ loading={deleteM.isPending}
+ danger
+ />
+ ) : null}
+
+ );
+ };
+}
diff --git a/frontend/modules/crm/components/CrmPortalShell.tsx b/frontend/modules/crm/components/CrmPortalShell.tsx
new file mode 100644
index 0000000..f87cd75
--- /dev/null
+++ b/frontend/modules/crm/components/CrmPortalShell.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { ChevronDown, Moon, Sun, Menu, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useColorMode } from "@/components/providers/ColorModeProvider";
+import { Button } from "@/components/ds";
+import {
+ CRM_PORTALS,
+ navForPortal,
+ type CrmPortalId,
+} from "@/modules/crm/constants/portals";
+
+function isActive(pathname: string, href: string) {
+ const base = href.split("?")[0];
+ return pathname === base || pathname.startsWith(`${base}/`);
+}
+
+export function CrmPortalShell({
+ children,
+ portalId,
+ title,
+}: {
+ children: React.ReactNode;
+ portalId: CrmPortalId;
+ title?: string;
+}) {
+ const pathname = usePathname();
+ const { mode, toggle } = useColorMode();
+ const [openGroups, setOpenGroups] = useState>({});
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const nav = navForPortal(portalId);
+ const portalMeta = CRM_PORTALS.find((p) => p.id === portalId);
+
+ const autoOpen = useMemo(() => {
+ const map: Record = {};
+ for (const g of nav) {
+ if (g.href && isActive(pathname, g.href)) map[g.id] = true;
+ if (g.items?.some((i) => isActive(pathname, i.href))) map[g.id] = true;
+ }
+ return map;
+ }, [pathname, nav]);
+
+ const expanded = { ...autoOpen, ...openGroups };
+
+ const sidebar = (
+ <>
+
+
+ تربت CRM
+
+
{title ?? portalMeta?.label}
+
+
+ >
+ );
+
+ return (
+
+
+
+ {mobileOpen ? (
+
+
+ ) : null}
+
+
+
+
+ {portalMeta?.label}
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/modules/crm/components/CrmScopeNotice.tsx b/frontend/modules/crm/components/CrmScopeNotice.tsx
new file mode 100644
index 0000000..e20f89d
--- /dev/null
+++ b/frontend/modules/crm/components/CrmScopeNotice.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { Card, CardContent, EmptyState } from "@/components/ds";
+import { AlertTriangle } from "lucide-react";
+
+export function CrmScopeNotice({
+ title,
+ description,
+ alternatives,
+}: {
+ title: string;
+ description: string;
+ alternatives?: { label: string; href: string }[];
+}) {
+ return (
+
+
+
+
+
{title}
+
{description}
+ {alternatives?.length ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+export function CrmPermissionDenied({ message }: { message?: string }) {
+ return (
+
+ );
+}
diff --git a/frontend/modules/crm/components/createPortalLayout.tsx b/frontend/modules/crm/components/createPortalLayout.tsx
new file mode 100644
index 0000000..9e97822
--- /dev/null
+++ b/frontend/modules/crm/components/createPortalLayout.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { AuthGuard } from "@/components/AuthGuard";
+import { useMe } from "@/hooks/useMe";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { LoadingState, ErrorState } from "@/components/ds";
+import { CrmPortalShell } from "@/modules/crm/components/CrmPortalShell";
+import type { CrmPortalId } from "@/modules/crm/constants/portals";
+import { useCrmKeyboardShortcuts } from "@/modules/crm/hooks/useCrmKeyboardShortcuts";
+
+function PortalGate({
+ children,
+ portalId,
+}: {
+ children: React.ReactNode;
+ portalId: CrmPortalId;
+}) {
+ const { me, loading, error, reload } = useMe();
+ const router = useRouter();
+ useCrmKeyboardShortcuts();
+
+ useEffect(() => {
+ if (loading) return;
+ if (me?.onboarding_required) router.replace("/onboarding");
+ }, [me, loading, router]);
+
+ if (loading) return ;
+ if (error) return ;
+ if (!me?.current_tenant_id) {
+ return ;
+ }
+
+ return {children};
+}
+
+export function createPortalLayout(portalId: CrmPortalId) {
+ return function PortalLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
+}
diff --git a/frontend/modules/crm/constants/permissions.ts b/frontend/modules/crm/constants/permissions.ts
new file mode 100644
index 0000000..1bee291
--- /dev/null
+++ b/frontend/modules/crm/constants/permissions.ts
@@ -0,0 +1,123 @@
+/** CRM permission catalog — mirrors backend definitions.py (no HTTP API). */
+export const CRM_PERMISSIONS = [
+ "crm.view",
+ "crm.manage",
+ "crm.leads.view",
+ "crm.leads.create",
+ "crm.leads.update",
+ "crm.leads.delete",
+ "crm.leads.manage",
+ "crm.leads.assign",
+ "crm.contacts.view",
+ "crm.contacts.create",
+ "crm.contacts.update",
+ "crm.contacts.delete",
+ "crm.contacts.manage",
+ "crm.organizations.view",
+ "crm.organizations.create",
+ "crm.organizations.update",
+ "crm.organizations.delete",
+ "crm.organizations.manage",
+ "crm.opportunities.view",
+ "crm.opportunities.create",
+ "crm.opportunities.update",
+ "crm.opportunities.delete",
+ "crm.opportunities.manage",
+ "crm.opportunities.win",
+ "crm.pipelines.view",
+ "crm.pipelines.create",
+ "crm.pipelines.update",
+ "crm.pipelines.delete",
+ "crm.pipelines.manage",
+ "crm.activities.view",
+ "crm.activities.create",
+ "crm.activities.update",
+ "crm.activities.delete",
+ "crm.activities.manage",
+ "crm.activities.complete",
+ "crm.tasks.view",
+ "crm.tasks.create",
+ "crm.tasks.update",
+ "crm.tasks.delete",
+ "crm.tasks.manage",
+ "crm.tasks.complete",
+ "crm.meetings.view",
+ "crm.meetings.create",
+ "crm.meetings.update",
+ "crm.meetings.delete",
+ "crm.meetings.manage",
+ "crm.calls.view",
+ "crm.calls.create",
+ "crm.calls.update",
+ "crm.calls.manage",
+ "crm.timeline.view",
+ "crm.timeline.manage",
+ "crm.comments.view",
+ "crm.comments.create",
+ "crm.comments.update",
+ "crm.comments.delete",
+ "crm.comments.manage",
+ "crm.mentions.view",
+ "crm.mentions.create",
+ "crm.mentions.manage",
+ "crm.team.view",
+ "crm.team.create",
+ "crm.team.update",
+ "crm.team.manage",
+ "crm.quotes.view",
+ "crm.quotes.create",
+ "crm.quotes.update",
+ "crm.quotes.delete",
+ "crm.quotes.manage",
+ "crm.notes.view",
+ "crm.notes.create",
+ "crm.notes.update",
+ "crm.notes.delete",
+ "crm.notes.manage",
+ "crm.attachments.view",
+ "crm.attachments.create",
+ "crm.attachments.delete",
+ "crm.attachments.manage",
+ "crm.customfields.view",
+ "crm.customfields.create",
+ "crm.customfields.update",
+ "crm.customfields.manage",
+] as const;
+
+export type CrmPermission = (typeof CRM_PERMISSIONS)[number];
+
+export const CRM_PERMISSION_GROUPS: { label: string; permissions: CrmPermission[] }[] = [
+ {
+ label: "سرنخها",
+ permissions: CRM_PERMISSIONS.filter((p) => p.startsWith("crm.leads.")) as CrmPermission[],
+ },
+ {
+ label: "مخاطبین",
+ permissions: CRM_PERMISSIONS.filter((p) => p.startsWith("crm.contacts.")) as CrmPermission[],
+ },
+ {
+ label: "سازمانها",
+ permissions: CRM_PERMISSIONS.filter((p) =>
+ p.startsWith("crm.organizations.")
+ ) as CrmPermission[],
+ },
+ {
+ label: "فرصتها",
+ permissions: CRM_PERMISSIONS.filter((p) =>
+ p.startsWith("crm.opportunities.")
+ ) as CrmPermission[],
+ },
+ {
+ label: "همکاری",
+ permissions: CRM_PERMISSIONS.filter(
+ (p) =>
+ p.startsWith("crm.tasks.") ||
+ p.startsWith("crm.meetings.") ||
+ p.startsWith("crm.calls.") ||
+ p.startsWith("crm.timeline.") ||
+ p.startsWith("crm.comments.") ||
+ p.startsWith("crm.mentions.") ||
+ p.startsWith("crm.team.")
+ ) as CrmPermission[],
+ },
+];
diff --git a/frontend/modules/crm/constants/portals.ts b/frontend/modules/crm/constants/portals.ts
new file mode 100644
index 0000000..64592db
--- /dev/null
+++ b/frontend/modules/crm/constants/portals.ts
@@ -0,0 +1,173 @@
+import type { LucideIcon } from "lucide-react";
+import {
+ Activity,
+ BarChart3,
+ Building2,
+ Calendar,
+ FileText,
+ Handshake,
+ HeartPulse,
+ Kanban,
+ LayoutDashboard,
+ Mail,
+ Phone,
+ Settings,
+ Shield,
+ Tag,
+ Target,
+ TrendingUp,
+ UserCircle,
+ Users,
+ Zap,
+} from "lucide-react";
+
+export type CrmPortalId = "sales";
+
+export type PortalNavItem = {
+ id: string;
+ label: string;
+ href: string;
+ shortcut?: string;
+};
+
+export type PortalNavGroup = {
+ id: string;
+ label: string;
+ icon: LucideIcon;
+ href?: string;
+ items?: PortalNavItem[];
+};
+
+export const CRM_PORTALS: {
+ id: CrmPortalId;
+ label: string;
+ description: string;
+ basePath: string;
+ icon: LucideIcon;
+}[] = [
+ {
+ id: "sales",
+ label: "فروش و CRM",
+ description: "مدیریت سرنخ، مخاطب، پipeline و فعالیتهای فروش",
+ basePath: "/crm/sales",
+ icon: Handshake,
+ },
+];
+
+export const SALES_NAV: PortalNavGroup[] = [
+ {
+ id: "dashboards",
+ label: "داشبوردها",
+ icon: LayoutDashboard,
+ items: [
+ { id: "overview", label: "نمای کلی", href: "/crm/sales/dashboard/overview" },
+ { id: "sales-dash", label: "فروش", href: "/crm/sales/dashboard/sales" },
+ { id: "pipeline-dash", label: "قیف فروش", href: "/crm/sales/dashboard/pipeline" },
+ { id: "lead-dash", label: "سرنخها", href: "/crm/sales/dashboard/leads" },
+ { id: "activity-dash", label: "فعالیتها", href: "/crm/sales/dashboard/activities" },
+ { id: "calendar-dash", label: "تقویم", href: "/crm/sales/dashboard/calendar" },
+ ],
+ },
+ {
+ id: "entities",
+ label: "مشتریان",
+ icon: Users,
+ items: [
+ { id: "customers", label: "مشتریان", href: "/crm/sales/customers" },
+ { id: "companies", label: "شرکتها", href: "/crm/sales/companies" },
+ { id: "contacts", label: "مخاطبین", href: "/crm/sales/contacts" },
+ { id: "leads", label: "سرنخها", href: "/crm/sales/leads" },
+ ],
+ },
+ {
+ id: "deals",
+ label: "فروش",
+ icon: TrendingUp,
+ items: [
+ { id: "deals", label: "معاملات", href: "/crm/sales/deals" },
+ { id: "kanban", label: "کانبان", href: "/crm/sales/kanban" },
+ { id: "pipelines", label: "قیفها", href: "/crm/sales/pipelines" },
+ { id: "quotes", label: "پیشفاکتورها", href: "/crm/sales/quotes" },
+ ],
+ },
+ {
+ id: "collaboration",
+ label: "همکاری",
+ icon: Activity,
+ items: [
+ { id: "activities", label: "فعالیتها", href: "/crm/sales/activities" },
+ { id: "tasks", label: "وظایف", href: "/crm/sales/tasks" },
+ { id: "meetings", label: "جلسات", href: "/crm/sales/meetings" },
+ { id: "calls", label: "تماسها", href: "/crm/sales/calls" },
+ { id: "emails", label: "ایمیلها", href: "/crm/sales/emails" },
+ { id: "notes", label: "یادداشتها", href: "/crm/sales/notes" },
+ { id: "timeline", label: "خط زمانی", href: "/crm/sales/timeline" },
+ { id: "documents", label: "اسناد", href: "/crm/sales/documents" },
+ ],
+ },
+ {
+ id: "marketing",
+ label: "بازاریابی",
+ icon: Target,
+ items: [
+ { id: "campaigns", label: "کمپینها", href: "/crm/sales/campaigns" },
+ { id: "segments", label: "سگمنتها", href: "/crm/sales/segments" },
+ { id: "tags", label: "برچسبها", href: "/crm/sales/tags" },
+ ],
+ },
+ {
+ id: "data",
+ label: "داده",
+ icon: FileText,
+ items: [
+ { id: "custom-fields", label: "فیلدهای سفارشی", href: "/crm/sales/custom-fields" },
+ { id: "import", label: "ورود داده", href: "/crm/sales/import" },
+ { id: "export", label: "خروجی", href: "/crm/sales/export" },
+ ],
+ },
+ {
+ id: "insights",
+ label: "گزارشها",
+ icon: BarChart3,
+ items: [
+ { id: "reports", label: "گزارشها", href: "/crm/sales/reports" },
+ { id: "analytics", label: "تحلیلها", href: "/crm/sales/analytics" },
+ { id: "automation", label: "اتوماسیون", href: "/crm/sales/automation" },
+ ],
+ },
+ {
+ id: "admin",
+ label: "مدیریت",
+ icon: Shield,
+ items: [
+ { id: "notifications", label: "اعلانها", href: "/crm/sales/notifications" },
+ { id: "roles", label: "نقشها", href: "/crm/sales/roles" },
+ { id: "permissions", label: "دسترسیها", href: "/crm/sales/permissions" },
+ { id: "integrations", label: "یکپارچهسازی", href: "/crm/sales/integrations" },
+ { id: "api-keys", label: "کلید API", href: "/crm/sales/api-keys" },
+ { id: "audit-logs", label: "لاگ ممیزی", href: "/crm/sales/audit-logs" },
+ { id: "settings", label: "تنظیمات", href: "/crm/sales/settings" },
+ { id: "health", label: "سلامت سرویس", href: "/crm/sales/health" },
+ { id: "capabilities", label: "قابلیتها", href: "/crm/sales/capabilities" },
+ ],
+ },
+];
+
+export function navForPortal(portalId: CrmPortalId): PortalNavGroup[] {
+ if (portalId === "sales") return SALES_NAV;
+ return [];
+}
+
+export function detectPortal(pathname: string): CrmPortalId | null {
+ if (pathname.startsWith("/crm/sales")) return "sales";
+ return null;
+}
+
+/** Keyboard shortcuts map */
+export const CRM_SHORTCUTS: Record = {
+ "g o": "/crm/sales/dashboard/overview",
+ "g l": "/crm/sales/leads",
+ "g c": "/crm/sales/contacts",
+ "g d": "/crm/sales/deals",
+ "g k": "/crm/sales/kanban",
+};
diff --git a/frontend/modules/crm/design-system/CrmStatusChip.tsx b/frontend/modules/crm/design-system/CrmStatusChip.tsx
new file mode 100644
index 0000000..c82205e
--- /dev/null
+++ b/frontend/modules/crm/design-system/CrmStatusChip.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { Badge } from "@/components/ds";
+import {
+ activityStatusLabels,
+ contactStatusLabels,
+ leadPriorityLabels,
+ leadStatusLabels,
+ opportunityStatusLabels,
+ organizationStatusLabels,
+} from "./tokens";
+
+type Tone = "default" | "primary" | "success" | "warning" | "danger" | "info";
+
+const toneMap: Record = {
+ new: "info",
+ contacted: "primary",
+ qualified: "success",
+ unqualified: "warning",
+ converted: "success",
+ lost: "danger",
+ open: "primary",
+ won: "success",
+ lost_deal: "danger",
+ on_hold: "warning",
+ planned: "default",
+ in_progress: "info",
+ completed: "success",
+ cancelled: "danger",
+ active: "success",
+ inactive: "default",
+ archived: "warning",
+ prospect: "info",
+ low: "default",
+ medium: "primary",
+ high: "warning",
+ urgent: "danger",
+};
+
+export function CrmStatusChip({
+ status,
+ domain = "generic",
+}: {
+ status: string;
+ domain?: "lead" | "opportunity" | "activity" | "contact" | "organization" | "priority" | "generic";
+}) {
+ const labels: Record = {
+ ...leadStatusLabels,
+ ...opportunityStatusLabels,
+ ...activityStatusLabels,
+ ...contactStatusLabels,
+ ...organizationStatusLabels,
+ ...leadPriorityLabels,
+ };
+ const label = labels[status] ?? status;
+ const tone = toneMap[status] ?? "default";
+ return {label};
+}
diff --git a/frontend/modules/crm/design-system/CrmTablePage.tsx b/frontend/modules/crm/design-system/CrmTablePage.tsx
new file mode 100644
index 0000000..47e0a4c
--- /dev/null
+++ b/frontend/modules/crm/design-system/CrmTablePage.tsx
@@ -0,0 +1,216 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { PageHeader, DataTable, EmptyState, Input, Pagination, Skeleton } from "@/components/ds";
+import { Search } from "lucide-react";
+
+export type CrmTableColumn = {
+ key: string;
+ header: string;
+ className?: string;
+ render?: (row: Record) => React.ReactNode;
+ searchable?: boolean;
+ sortable?: boolean;
+};
+
+export function CrmTablePage({
+ title,
+ description,
+ breadcrumbs,
+ columns,
+ rows,
+ empty,
+ actions,
+ toolbar,
+ searchPlaceholder = "جستجو…",
+ filterFn,
+ page,
+ pageSize,
+ onPageChange,
+ selectedIds,
+ onSelectionChange,
+ bulkActions,
+ loading,
+}: {
+ title: string;
+ description?: string;
+ breadcrumbs?: React.ReactNode;
+ columns: CrmTableColumn[];
+ rows: Record[];
+ empty?: React.ReactNode;
+ actions?: React.ReactNode;
+ toolbar?: React.ReactNode;
+ searchPlaceholder?: string;
+ filterFn?: (row: Record, query: string) => boolean;
+ page?: number;
+ pageSize?: number;
+ onPageChange?: (page: number) => void;
+ selectedIds?: Set;
+ onSelectionChange?: (ids: Set) => void;
+ bulkActions?: React.ReactNode;
+ loading?: boolean;
+}) {
+ const [query, setQuery] = useState("");
+ const [sortKey, setSortKey] = useState(null);
+ const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
+
+ const filtered = useMemo(() => {
+ let data = rows;
+ const q = query.trim().toLowerCase();
+ if (q) {
+ data = filterFn
+ ? data.filter((r) => filterFn(r, q))
+ : data.filter((r) =>
+ columns.some((c) => {
+ if (c.searchable === false) return false;
+ const val = r[c.key];
+ return val != null && String(val).toLowerCase().includes(q);
+ })
+ );
+ }
+ if (sortKey) {
+ data = [...data].sort((a, b) => {
+ const av = a[sortKey];
+ const bv = b[sortKey];
+ const cmp = String(av ?? "").localeCompare(String(bv ?? ""), "fa");
+ return sortDir === "asc" ? cmp : -cmp;
+ });
+ }
+ return data;
+ }, [rows, query, columns, filterFn, sortKey, sortDir]);
+
+ const paged = useMemo(() => {
+ if (!pageSize || !page) return filtered;
+ const start = (page - 1) * pageSize;
+ return filtered.slice(start, start + pageSize);
+ }, [filtered, page, pageSize]);
+
+ const displayRows = pageSize && page ? paged : filtered;
+
+ const toggleSort = (key: string) => {
+ if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
+ else {
+ setSortKey(key);
+ setSortDir("asc");
+ }
+ };
+
+ const colsWithSort = columns;
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {breadcrumbs}
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder={searchPlaceholder}
+ className="pr-10"
+ aria-label="جستجو"
+ />
+
+ {toolbar}
+ {columns.some((c) => c.sortable !== false) ? (
+
+ ) : null}
+
+ {selectedIds && selectedIds.size > 0 && bulkActions ? (
+
+ {selectedIds.size} مورد انتخاب شده
+ {bulkActions}
+
+ ) : null}
+
(
+ {
+ const id = String(row.id);
+ const next = new Set(selectedIds);
+ if (e.target.checked) next.add(id);
+ else next.delete(id);
+ onSelectionChange(next);
+ }}
+ aria-label="انتخاب"
+ />
+ ),
+ },
+ ...colsWithSort,
+ ]
+ : colsWithSort
+ }
+ rows={displayRows}
+ empty={empty ?? }
+ />
+ {pageSize && page && onPageChange ? (
+
+ ) : null}
+
+ );
+}
+
+export function CrmStatGrid({
+ stats,
+}: {
+ stats: { label: string; value: string | number; hint?: string }[];
+}) {
+ return (
+
+ {stats.map((s) => (
+
+
{s.label}
+
{s.value}
+ {s.hint ?
{s.hint}
: null}
+
+ ))}
+
+ );
+}
diff --git a/frontend/modules/crm/design-system/index.ts b/frontend/modules/crm/design-system/index.ts
new file mode 100644
index 0000000..5c29663
--- /dev/null
+++ b/frontend/modules/crm/design-system/index.ts
@@ -0,0 +1,3 @@
+export { crmTokens, leadStatusLabels, leadPriorityLabels, opportunityStatusLabels } from "./tokens";
+export { CrmTablePage, CrmStatGrid, type CrmTableColumn } from "./CrmTablePage";
+export { CrmStatusChip } from "./CrmStatusChip";
diff --git a/frontend/modules/crm/design-system/tokens.ts b/frontend/modules/crm/design-system/tokens.ts
new file mode 100644
index 0000000..0d5c6be
--- /dev/null
+++ b/frontend/modules/crm/design-system/tokens.ts
@@ -0,0 +1,52 @@
+/** CRM module visual tokens — extends global DS (globals.css). */
+export const crmTokens = {
+ accent: "var(--crm-accent)",
+ accentSoft: "var(--crm-accent-soft)",
+ slate: "var(--crm-slate)",
+ success: "var(--crm-success)",
+ warning: "var(--crm-warning)",
+ danger: "var(--crm-danger)",
+} as const;
+
+export const leadStatusLabels: Record = {
+ new: "جدید",
+ contacted: "تماس گرفته",
+ qualified: "واجد شرایط",
+ unqualified: "نامناسب",
+ converted: "تبدیلشده",
+ lost: "از دست رفته",
+};
+
+export const leadPriorityLabels: Record = {
+ low: "کم",
+ medium: "متوسط",
+ high: "بالا",
+ urgent: "فوری",
+};
+
+export const opportunityStatusLabels: Record = {
+ open: "باز",
+ won: "برنده",
+ lost: "باخته",
+ on_hold: "معلق",
+};
+
+export const activityStatusLabels: Record = {
+ planned: "برنامهریزی",
+ in_progress: "در جریان",
+ completed: "تکمیل",
+ cancelled: "لغو",
+};
+
+export const contactStatusLabels: Record = {
+ active: "فعال",
+ inactive: "غیرفعال",
+ archived: "آرشیو",
+};
+
+export const organizationStatusLabels: Record = {
+ active: "فعال",
+ inactive: "غیرفعال",
+ prospect: "بالقوه",
+ archived: "آرشیو",
+};
diff --git a/frontend/modules/crm/features/sales/activities.tsx b/frontend/modules/crm/features/sales/activities.tsx
new file mode 100644
index 0000000..cf0d9fb
--- /dev/null
+++ b/frontend/modules/crm/features/sales/activities.tsx
@@ -0,0 +1,73 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import { CrmStatusChip } from "@/modules/crm/design-system";
+import type { SalesActivity } from "@/modules/crm/types";
+
+const schema = z.object({
+ subject: z.string().min(1),
+ activity_type: z.enum(["call", "email", "meeting", "task", "note", "other"]).default("task"),
+ priority: z.enum(["low", "medium", "high", "urgent"]).default("medium"),
+ description: z.string().optional(),
+});
+
+export default createCrmListPage({
+ title: "فعالیتهای فروش",
+ description: "ثبت و پیگیری فعالیتهای مرتبط با CRM entities.",
+ breadcrumb: "فعالیتها",
+ resourceKey: "activities",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: {
+ subject: "",
+ activity_type: "task",
+ priority: "medium",
+ description: "",
+ },
+ fields: [
+ { name: "subject", label: "موضوع" },
+ {
+ name: "activity_type",
+ label: "نوع",
+ type: "select",
+ options: [
+ { value: "call", label: "تماس" },
+ { value: "email", label: "ایمیل" },
+ { value: "meeting", label: "جلسه" },
+ { value: "task", label: "وظیفه" },
+ { value: "note", label: "یادداشت" },
+ { value: "other", label: "سایر" },
+ ],
+ },
+ {
+ name: "priority",
+ label: "اولویت",
+ type: "select",
+ options: [
+ { value: "low", label: "کم" },
+ { value: "medium", label: "متوسط" },
+ { value: "high", label: "بالا" },
+ { value: "urgent", label: "فوری" },
+ ],
+ },
+ { name: "description", label: "توضیحات", type: "textarea" },
+ ],
+ columns: [
+ { key: "subject", header: "موضوع" },
+ { key: "activity_type", header: "نوع" },
+ {
+ key: "status",
+ header: "وضعیت",
+ render: (r) => ,
+ },
+ {
+ key: "priority",
+ header: "اولویت",
+ render: (r) => ,
+ },
+ ],
+ list: (tid) => crmApi.activities.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.activities.create(tid, d),
+ update: (tid, id, d) => crmApi.activities.update(tid, id, d),
+ exportColumns: ["subject", "activity_type", "status", "priority"],
+});
diff --git a/frontend/modules/crm/features/sales/admin.tsx b/frontend/modules/crm/features/sales/admin.tsx
new file mode 100644
index 0000000..3df50ff
--- /dev/null
+++ b/frontend/modules/crm/features/sales/admin.tsx
@@ -0,0 +1,296 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ Button,
+ Dialog,
+ FormField,
+ Input,
+ PageHeader,
+ Card,
+ CardContent,
+ Badge,
+ EmptyState,
+} from "@/components/ds";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { useMe } from "@/hooks/useMe";
+import {
+ CRM_PERMISSIONS,
+ CRM_PERMISSION_GROUPS,
+} from "@/modules/crm/constants/permissions";
+import { CrmPageLoader, CrmPageError, useCrmHealth } from "@/modules/crm/pages/shared";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+import { CrmScopeNotice } from "@/modules/crm/components/CrmScopeNotice";
+import { CrmTablePage } from "@/modules/crm/design-system";
+
+export function NotificationsPage() {
+ const { tenantId } = useTenantId();
+ const qc = useQueryClient();
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "mentions-unread"],
+ queryFn: () => crmApi.mentions.unread(tenantId!),
+ enabled: !!tenantId,
+ });
+
+ const readM = useMutation({
+ mutationFn: (id: string) => crmApi.mentions.markRead(tenantId!, id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["crm", tenantId, "mentions-unread"] }),
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ return (
+
+
+
+
+ {(q.data ?? []).map((m) => (
+
+
+ Mention #{m.id.slice(0, 8)}
+
+
+
+ ))}
+ {(q.data ?? []).length === 0 ? : null}
+
+
+ );
+}
+
+export function RolesPage() {
+ const { tenantId } = useTenantId();
+ const [open, setOpen] = useState(false);
+ const form = useForm({ defaultValues: { code: "", name: "" } });
+ const createM = useMutation({
+ mutationFn: (d: Record) => crmApi.team.roles.create(tenantId!, d),
+ onSuccess: () => {
+ toast.success("نقش تیم ثبت شد");
+ setOpen(false);
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const membersQ = useQuery({
+ queryKey: ["crm", tenantId, "team-members"],
+ queryFn: () => crmApi.team.members.list(tenantId!, { page: 1, page_size: 500 }),
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || membersQ.isLoading) return ;
+
+ return (
+
+
+
setOpen(true)}>نقش جدید}
+ />
+ (r.is_active ? "بله" : "خیر"),
+ },
+ ]}
+ rows={(membersQ.data ?? []) as unknown as Record[]}
+ />
+
+
+ );
+}
+
+export function PermissionsPage() {
+ return (
+
+
+
+
+ {CRM_PERMISSION_GROUPS.map((g) => (
+
+
+ {g.label}
+
+ {g.permissions.map((p) => (
+
+ {p}
+
+ ))}
+
+
+
+ ))}
+
+
+ همه ({CRM_PERMISSIONS.length})
+
+ {CRM_PERMISSIONS.map((p) => (
+
+ {p}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export function AuditLogsPage() {
+ const { tenantId } = useTenantId();
+ const [entityType, setEntityType] = useState("lead");
+ const [entityId, setEntityId] = useState("");
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "audit", entityType, entityId],
+ queryFn: () => crmApi.audit.list(tenantId!, entityType, entityId),
+ enabled: !!tenantId && !!entityId,
+ });
+
+ if (!tenantId) return ;
+
+ return (
+
+
+
+
+ setEntityType(e.target.value)} />
+ setEntityId(e.target.value)} className="max-w-md" />
+
+ {entityId && q.isLoading ?
: null}
+ {q.error ?
q.refetch()} /> : null}
+ {entityId && q.data ? (
+ []}
+ />
+ ) : (
+ !entityId &&
+ )}
+
+ );
+}
+
+export function SettingsPage() {
+ const { tenantId } = useTenantId();
+ const { me } = useMe();
+ const form = useForm({
+ defaultValues: {
+ email_mentions: true,
+ in_app_mentions: true,
+ task_reminders: true,
+ },
+ });
+ const saveM = useMutation({
+ mutationFn: (d: Record) =>
+ crmApi.collaborationPreferences.upsert(tenantId!, me!.user_id, d),
+ onSuccess: () => toast.success("تنظیمات ذخیره شد"),
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (!tenantId || !me) return ;
+
+ return (
+
+ );
+}
+
+export function HealthPage() {
+ const healthQ = useCrmHealth();
+ if (healthQ.isLoading) return ;
+ if (healthQ.error) return healthQ.refetch()} />;
+
+ return (
+
+
+
+
+
+
+ Status: {healthQ.data?.status}
+
+
+ Service: {healthQ.data?.service}
+
+
+ Version: {healthQ.data?.version}
+
+
+
+
+ );
+}
+
+export function CapabilitiesPage() {
+ const healthQ = useCrmHealth();
+ return (
+
+
+
+
+ {healthQ.data ? (
+
+
+ نسخه فعلی: {healthQ.data.version} — Phase 6.3 Collaboration complete
+
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/analytics.tsx b/frontend/modules/crm/features/sales/analytics.tsx
new file mode 100644
index 0000000..a15d2dd
--- /dev/null
+++ b/frontend/modules/crm/features/sales/analytics.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { PageHeader } from "@/components/ds";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CrmCountsGrid, CrmPageLoader } from "@/modules/crm/pages/shared";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+import { CrmScopeNotice } from "@/modules/crm/components/CrmScopeNotice";
+
+export default function AnalyticsPage() {
+ const { tenantId } = useTenantId();
+ if (!tenantId) return ;
+
+ return (
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/calls.tsx b/frontend/modules/crm/features/sales/calls.tsx
new file mode 100644
index 0000000..713f93f
--- /dev/null
+++ b/frontend/modules/crm/features/sales/calls.tsx
@@ -0,0 +1,52 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import type { CallLog } from "@/modules/crm/types";
+
+const schema = z.object({
+ subject: z.string().min(1),
+ direction: z.enum(["inbound", "outbound"]).default("outbound"),
+ called_at: z.string().min(1),
+ duration_seconds: z.coerce.number().optional(),
+ outcome: z.string().optional(),
+});
+
+export default createCrmListPage({
+ title: "تماسها",
+ description: "لاگ تماسهای فروش.",
+ breadcrumb: "تماسها",
+ resourceKey: "calls",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: {
+ subject: "",
+ direction: "outbound",
+ called_at: new Date().toISOString(),
+ duration_seconds: 0,
+ outcome: "",
+ },
+ fields: [
+ { name: "subject", label: "موضوع" },
+ {
+ name: "direction",
+ label: "جهت",
+ type: "select",
+ options: [
+ { value: "inbound", label: "ورودی" },
+ { value: "outbound", label: "خروجی" },
+ ],
+ },
+ { name: "called_at", label: "زمان تماس (ISO)" },
+ { name: "duration_seconds", label: "مدت (ثانیه)", type: "number" },
+ { name: "outcome", label: "نتیجه" },
+ ],
+ columns: [
+ { key: "subject", header: "موضوع" },
+ { key: "direction", header: "جهت" },
+ { key: "called_at", header: "زمان" },
+ { key: "outcome", header: "نتیجه" },
+ ],
+ list: (tid) => crmApi.calls.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.calls.create(tid, d),
+ exportColumns: ["subject", "direction", "called_at", "outcome"],
+});
diff --git a/frontend/modules/crm/features/sales/companies.tsx b/frontend/modules/crm/features/sales/companies.tsx
new file mode 100644
index 0000000..f256366
--- /dev/null
+++ b/frontend/modules/crm/features/sales/companies.tsx
@@ -0,0 +1,83 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import { CrmStatusChip } from "@/modules/crm/design-system";
+import type { Organization } from "@/modules/crm/types";
+
+const schema = z.object({
+ name: z.string().min(1, "نام الزامی است"),
+ legal_name: z.string().optional(),
+ email: z.string().email().optional().or(z.literal("")),
+ phone: z.string().optional(),
+ website: z.string().optional(),
+ kind: z.enum(["company", "account", "partner", "vendor"]).default("company"),
+ status: z.enum(["active", "inactive", "prospect", "archived"]).default("active"),
+ description: z.string().optional(),
+});
+
+export default createCrmListPage({
+ title: "شرکتها",
+ description: "سازمانها و حسابهای فروش B2B.",
+ breadcrumb: "شرکتها",
+ resourceKey: "organizations",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: {
+ name: "",
+ legal_name: "",
+ email: "",
+ phone: "",
+ website: "",
+ kind: "company",
+ status: "active",
+ description: "",
+ },
+ fields: [
+ { name: "name", label: "نام" },
+ { name: "legal_name", label: "نام حقوقی" },
+ { name: "email", label: "ایمیل", type: "email" },
+ { name: "phone", label: "تلفن", type: "tel" },
+ { name: "website", label: "وبسایت" },
+ {
+ name: "kind",
+ label: "نوع",
+ type: "select",
+ options: [
+ { value: "company", label: "شرکت" },
+ { value: "account", label: "حساب" },
+ { value: "partner", label: "شریک" },
+ { value: "vendor", label: "تأمینکننده" },
+ ],
+ },
+ {
+ name: "status",
+ label: "وضعیت",
+ type: "select",
+ editOnly: true,
+ options: [
+ { value: "active", label: "فعال" },
+ { value: "inactive", label: "غیرفعال" },
+ { value: "prospect", label: "بالقوه" },
+ { value: "archived", label: "آرشیو" },
+ ],
+ },
+ { name: "description", label: "توضیحات", type: "textarea" },
+ ],
+ columns: [
+ { key: "organization_number", header: "شماره" },
+ { key: "name", header: "نام" },
+ { key: "email", header: "ایمیل" },
+ { key: "phone", header: "تلفن" },
+ {
+ key: "status",
+ header: "وضعیت",
+ render: (r) => ,
+ },
+ ],
+ list: (tid) => crmApi.organizations.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.organizations.create(tid, d),
+ update: (tid, id, d) => crmApi.organizations.update(tid, id, d),
+ remove: (tid, id) => crmApi.organizations.delete(tid, id),
+ exportColumns: ["organization_number", "name", "email", "phone", "status"],
+ filterDeleted: true,
+});
diff --git a/frontend/modules/crm/features/sales/contacts.tsx b/frontend/modules/crm/features/sales/contacts.tsx
new file mode 100644
index 0000000..e72bf2d
--- /dev/null
+++ b/frontend/modules/crm/features/sales/contacts.tsx
@@ -0,0 +1,77 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import { CrmStatusChip } from "@/modules/crm/design-system";
+import type { Contact } from "@/modules/crm/types";
+
+const schema = z.object({
+ first_name: z.string().min(1, "نام الزامی است"),
+ last_name: z.string().min(1, "نام خانوادگی الزامی است"),
+ email: z.string().email().optional().or(z.literal("")),
+ phone: z.string().optional(),
+ job_title: z.string().optional(),
+ kind: z.enum(["business", "personal"]).default("business"),
+ status: z.enum(["active", "inactive", "archived"]).default("active"),
+});
+
+export default createCrmListPage({
+ title: "مخاطبین",
+ description: "دفترچه مخاطبین CRM — اشخاص مرتبط با سازمانها و معاملات.",
+ breadcrumb: "مخاطبین",
+ resourceKey: "contacts",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: {
+ first_name: "",
+ last_name: "",
+ email: "",
+ phone: "",
+ job_title: "",
+ kind: "business",
+ status: "active",
+ },
+ fields: [
+ { name: "first_name", label: "نام" },
+ { name: "last_name", label: "نام خانوادگی" },
+ { name: "email", label: "ایمیل", type: "email" },
+ { name: "phone", label: "تلفن", type: "tel" },
+ { name: "job_title", label: "سمت" },
+ {
+ name: "kind",
+ label: "نوع",
+ type: "select",
+ options: [
+ { value: "business", label: "کسبوکار" },
+ { value: "personal", label: "شخصی" },
+ ],
+ },
+ {
+ name: "status",
+ label: "وضعیت",
+ type: "select",
+ editOnly: true,
+ options: [
+ { value: "active", label: "فعال" },
+ { value: "inactive", label: "غیرفعال" },
+ { value: "archived", label: "آرشیو" },
+ ],
+ },
+ ],
+ columns: [
+ { key: "full_name", header: "نام" },
+ { key: "email", header: "ایمیل" },
+ { key: "phone", header: "تلفن" },
+ { key: "job_title", header: "سمت" },
+ {
+ key: "status",
+ header: "وضعیت",
+ render: (r) => ,
+ },
+ ],
+ list: (tid) => crmApi.contacts.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.contacts.create(tid, d),
+ update: (tid, id, d) => crmApi.contacts.update(tid, id, d),
+ remove: (tid, id) => crmApi.contacts.delete(tid, id),
+ exportColumns: ["full_name", "email", "phone", "job_title", "status"],
+ filterDeleted: true,
+});
diff --git a/frontend/modules/crm/features/sales/custom-fields.tsx b/frontend/modules/crm/features/sales/custom-fields.tsx
new file mode 100644
index 0000000..43b2189
--- /dev/null
+++ b/frontend/modules/crm/features/sales/custom-fields.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ Button,
+ Dialog,
+ FormField,
+ Input,
+ Select,
+ Checkbox,
+ PageHeader,
+ 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";
+
+export default function CustomFieldsPage() {
+ const { tenantId } = useTenantId();
+ const qc = useQueryClient();
+ const [entityType, setEntityType] = useState("lead");
+ const [open, setOpen] = useState(false);
+
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "custom-fields", entityType],
+ queryFn: () => crmApi.customFields.list(tenantId!, entityType),
+ enabled: !!tenantId,
+ });
+
+ const form = useForm({
+ defaultValues: {
+ code: "",
+ label: "",
+ field_type: "text",
+ is_required: false,
+ },
+ });
+
+ const createM = useMutation({
+ mutationFn: (d: Record) =>
+ crmApi.customFields.create(tenantId!, { ...d, entity_type: entityType }),
+ onSuccess: async () => {
+ toast.success("فیلد ایجاد شد");
+ setOpen(false);
+ form.reset();
+ await qc.invalidateQueries({ queryKey: ["crm", tenantId, "custom-fields"] });
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ return (
+
+
+
setOpen(true)}>فیلد جدید}
+ />
+
+
+ {(q.data ?? []).map((f) => (
+
+
+
{f.label}
+
+ {f.code} — {f.field_type}
+
+
+ {f.is_required ? (
+
الزامی
+ ) : null}
+
+ ))}
+ {(q.data ?? []).length === 0 ?
: null}
+
+
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/customers.tsx b/frontend/modules/crm/features/sales/customers.tsx
new file mode 100644
index 0000000..905d0b0
--- /dev/null
+++ b/frontend/modules/crm/features/sales/customers.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import Link from "next/link";
+import { PageHeader, Card, CardContent, Button, 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 { CrmStatusChip } from "@/modules/crm/design-system";
+
+/** Customers = unified view of contacts + organizations (no separate backend entity). */
+export default function CustomersPage() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "customers-view"],
+ queryFn: async () => {
+ const [contacts, orgs] = await Promise.all([
+ crmApi.contacts.list(tenantId!, { page: 1, page_size: 500 }),
+ crmApi.organizations.list(tenantId!, { page: 1, page_size: 500 }),
+ ]);
+ return { contacts: contacts.filter((c) => !c.is_deleted), orgs: orgs.filter((o) => !o.is_deleted) };
+ },
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ const { contacts, orgs } = q.data!;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+ مخاطبین ({contacts.length})
+
+ {contacts.slice(0, 12).map((c) => (
+
+
+
+
{c.full_name}
+
{c.email ?? c.phone ?? "—"}
+
+
+
+
+ ))}
+ {contacts.length === 0 ?
: null}
+
+
+
+ شرکتها ({orgs.length})
+
+ {orgs.slice(0, 12).map((o) => (
+
+
+
+
{o.name}
+
{o.organization_number}
+
+
+
+
+ ))}
+ {orgs.length === 0 ?
: null}
+
+
+
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/dashboards.tsx b/frontend/modules/crm/features/sales/dashboards.tsx
new file mode 100644
index 0000000..50a51d4
--- /dev/null
+++ b/frontend/modules/crm/features/sales/dashboards.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { PageHeader } from "@/components/ds";
+import { useTenantId } from "@/hooks/useTenantId";
+import {
+ CrmCountsGrid,
+ CrmPageLoader,
+ CrmPageError,
+ CrmServiceBadge,
+ useCrmCounts,
+} from "@/modules/crm/pages/shared";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+import { CrmStatGrid } from "@/modules/crm/design-system";
+import { crmApi } from "@/modules/crm/services/crm-api";
+
+function DashboardShell({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function OverviewDashboard() {
+ const { tenantId } = useTenantId();
+ if (!tenantId) return ;
+ return (
+
+
+
+ );
+}
+
+export function SalesDashboard() {
+ const { tenantId } = useTenantId();
+ const q = useCrmCounts(tenantId);
+ const goalsQ = useQuery({
+ queryKey: ["crm", tenantId, "goals"],
+ queryFn: () => crmApi.goals.list(tenantId!, { page: 1, page_size: 50 }),
+ enabled: !!tenantId,
+ });
+ const targetsQ = useQuery({
+ queryKey: ["crm", tenantId, "targets"],
+ queryFn: () => crmApi.targets.list(tenantId!, { page: 1, page_size: 50 }),
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ return (
+
+
+
+ );
+}
+
+export function PipelineDashboard() {
+ const { tenantId } = useTenantId();
+ const pipelinesQ = useQuery({
+ queryKey: ["crm", tenantId, "pipelines"],
+ queryFn: () => crmApi.pipelines.list(tenantId!, { page: 1, page_size: 100 }),
+ enabled: !!tenantId,
+ });
+ const oppsQ = useQuery({
+ queryKey: ["crm", tenantId, "opportunities"],
+ queryFn: () => crmApi.opportunities.list(tenantId!, { page: 1, page_size: 500 }),
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || pipelinesQ.isLoading || oppsQ.isLoading) return ;
+ if (pipelinesQ.error) return pipelinesQ.refetch()} />;
+
+ const opps = oppsQ.data ?? [];
+ const byPipeline = (pipelinesQ.data ?? []).map((p) => ({
+ label: p.name,
+ value: opps.filter((o) => o.pipeline_id === p.id).length,
+ }));
+
+ return (
+
+
+
+ );
+}
+
+export function LeadDashboard() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "leads"],
+ queryFn: () => crmApi.leads.list(tenantId!, { page: 1, page_size: 500 }),
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ const leads = q.data ?? [];
+ const byStatus = ["new", "contacted", "qualified", "converted", "lost"].map((s) => ({
+ label: s,
+ value: leads.filter((l) => l.status === s && !l.is_deleted).length,
+ }));
+
+ return (
+
+
+
+ );
+}
+
+export function ActivityDashboard() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "activity-dash"],
+ queryFn: async () => {
+ const [activities, tasks, meetings] = await Promise.all([
+ 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 }),
+ ]);
+ return { activities, tasks, meetings };
+ },
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ return (
+
+ a.status === "completed").length,
+ },
+ ]}
+ />
+
+ );
+}
+
+export function CalendarDashboard() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "meetings"],
+ queryFn: () => crmApi.meetings.list(tenantId!, { page: 1, page_size: 500 }),
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ const upcoming = (q.data ?? [])
+ .filter((m) => new Date(m.starts_at) >= new Date())
+ .slice(0, 10);
+
+ return (
+
+
+ {upcoming.map((m) => (
+
+
{m.subject}
+
+ {new Date(m.starts_at).toLocaleString("fa-IR")}
+ {m.location ? ` — ${m.location}` : ""}
+
+
+ ))}
+ {upcoming.length === 0 ? (
+
جلسه آیندهای ثبت نشده.
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/deals.tsx b/frontend/modules/crm/features/sales/deals.tsx
new file mode 100644
index 0000000..55be7e0
--- /dev/null
+++ b/frontend/modules/crm/features/sales/deals.tsx
@@ -0,0 +1,311 @@
+"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(null);
+ const [winId, setWinId] = useState(null);
+ const [loseId, setLoseId] = useState(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) =>
+ 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) =>
+ 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 ;
+ if (listQ.error) return listQ.refetch()} />;
+
+ const pipelines = pipelinesQ.data ?? [];
+
+ return (
+
+
}
+ 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) => (
+
+ ),
+ },
+ {
+ key: "actions",
+ header: "عملیات",
+ searchable: false,
+ render: (r) => (
+
+
+ {r.status === "open" ? (
+ <>
+
+
+ >
+ ) : null}
+
+ ),
+ },
+ ]}
+ rows={(listQ.data ?? []) as unknown as Record
[]}
+ page={page}
+ pageSize={20}
+ onPageChange={setPage}
+ actions={
+
+ }
+ empty={
+ setCreateOpen(true)}>ایجاد معامله
+ ) : undefined
+ }
+ />
+ }
+ />
+
+
+
+ setEditRow(null)} title="ویرایش معامله">
+
+
+
+ setWinId(null)}
+ onConfirm={() => winId && winM.mutate(winId)}
+ title="برنده شدن معامله"
+ description="وضعیت معامله به برنده تغییر میکند."
+ confirmLabel="تأیید برنده"
+ loading={winM.isPending}
+ />
+ setLoseId(null)}
+ onConfirm={() => loseId && loseM.mutate(loseId)}
+ title="باخت معامله"
+ description="وضعیت معامله به باخته تغییر میکند."
+ confirmLabel="تأیید باخت"
+ loading={loseM.isPending}
+ danger
+ />
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/documents.tsx b/frontend/modules/crm/features/sales/documents.tsx
new file mode 100644
index 0000000..31633f7
--- /dev/null
+++ b/frontend/modules/crm/features/sales/documents.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ Button,
+ Dialog,
+ FormField,
+ Input,
+ Select,
+ PageHeader,
+ 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";
+
+export default function DocumentsPage() {
+ const { tenantId } = useTenantId();
+ const qc = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const [entityType, setEntityType] = useState("lead");
+ const [entityId, setEntityId] = useState("");
+
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "attachments", entityType, entityId],
+ queryFn: () => crmApi.attachments.list(tenantId!, entityType, entityId),
+ enabled: !!tenantId && !!entityId,
+ });
+
+ const form = useForm({
+ defaultValues: { file_storage_id: "", title: "", kind: "document" },
+ });
+
+ const createM = useMutation({
+ mutationFn: (d: Record) =>
+ crmApi.attachments.create(tenantId!, {
+ entity_type: entityType,
+ entity_id: entityId,
+ file_storage_id: d.file_storage_id,
+ title: d.title,
+ kind: d.kind,
+ }),
+ onSuccess: async () => {
+ toast.success("پیوست ثبت شد");
+ setOpen(false);
+ form.reset();
+ await qc.invalidateQueries({ queryKey: ["crm", tenantId, "attachments"] });
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (!tenantId) return ;
+
+ return (
+
+
+
setOpen(true)} disabled={!entityId}>
+ ثبت پیوست
+
+ }
+ />
+
+
+ setEntityId(e.target.value)}
+ className="max-w-md"
+ />
+
+ {entityId && q.isLoading ? : null}
+ {q.error ? q.refetch()} /> : null}
+ {entityId && q.data ? (
+
+ {q.data.map((a) => (
+
+
{a.title ?? a.kind}
+
{a.file_storage_id}
+
+ ))}
+ {q.data.length === 0 ?
: null}
+
+ ) : (
+ !entityId &&
+ )}
+
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/emails.tsx b/frontend/modules/crm/features/sales/emails.tsx
new file mode 100644
index 0000000..a709e64
--- /dev/null
+++ b/frontend/modules/crm/features/sales/emails.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { useMutation } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Button, Dialog, FormField, Input, Textarea, PageHeader } from "@/components/ds";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CrmScopeNotice } from "@/modules/crm/components/CrmScopeNotice";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+
+/** Email metadata only — no delivery engine in CRM 6.3 */
+export default function EmailsPage() {
+ const { tenantId } = useTenantId();
+ const [open, setOpen] = useState(false);
+ const form = useForm({
+ defaultValues: {
+ subject: "",
+ thread_key: "",
+ body_text: "",
+ from_address: "",
+ to_addresses: "",
+ },
+ });
+
+ const threadM = useMutation({
+ mutationFn: async (d: Record) => {
+ const thread = await crmApi.emails.createThread(tenantId!, {
+ subject: d.subject,
+ thread_key: d.thread_key || d.subject,
+ });
+ await crmApi.emails.createMessage(tenantId!, {
+ thread_id: thread.id,
+ subject: d.subject,
+ body_text: d.body_text,
+ from_address: d.from_address,
+ to_addresses: d.to_addresses.split(",").map((s) => s.trim()),
+ });
+ },
+ onSuccess: () => {
+ toast.success("رشته ایمیل ثبت شد (metadata)");
+ setOpen(false);
+ form.reset();
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (!tenantId) return null;
+
+ return (
+
+
+
setOpen(true)}>ثبت ایمیل}
+ />
+
+
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/export.tsx b/frontend/modules/crm/features/sales/export.tsx
new file mode 100644
index 0000000..2947ff0
--- /dev/null
+++ b/frontend/modules/crm/features/sales/export.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Button, PageHeader } from "@/components/ds";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CrmPageLoader, CrmPageError, exportToCsv } from "@/modules/crm/pages/shared";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+
+export default function ExportPage() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "export-bundle"],
+ queryFn: async () => {
+ const [leads, contacts, orgs, opps] = 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 }),
+ ]);
+ return { leads, contacts, orgs, opps };
+ },
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ const d = q.data!;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/kanban.tsx b/frontend/modules/crm/features/sales/kanban.tsx
new file mode 100644
index 0000000..ce525ea
--- /dev/null
+++ b/frontend/modules/crm/features/sales/kanban.tsx
@@ -0,0 +1,131 @@
+"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("");
+
+ 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 ;
+ if (pipelinesQ.error) return 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 (
+
+
+
setPipelineId(e.target.value)}
+ className="min-w-[180px]"
+ >
+ {(pipelinesQ.data ?? []).map((p) => (
+
+ ))}
+
+ }
+ />
+ {!stages.length ? (
+
+ ) : (
+
+ {stages.map((stage: PipelineStage) => (
+
+
+
{stage.name}
+
+ {byStage(stage.id).length}
+
+
+
+ {byStage(stage.id).map((deal: Opportunity) => (
+
+
{deal.title ?? deal.name}
+
{deal.amount} {deal.currency_code}
+
+ {stages
+ .filter((s) => s.id !== stage.id)
+ .slice(0, 3)
+ .map((s) => (
+
+ ))}
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/leads.tsx b/frontend/modules/crm/features/sales/leads.tsx
new file mode 100644
index 0000000..d76a456
--- /dev/null
+++ b/frontend/modules/crm/features/sales/leads.tsx
@@ -0,0 +1,95 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import { CrmStatusChip } from "@/modules/crm/design-system";
+import type { Lead } from "@/modules/crm/types";
+
+const createSchema = z.object({
+ first_name: z.string().min(1, "نام الزامی است"),
+ last_name: z.string().default(""),
+ email: z.string().email("ایمیل نامعتبر").optional().or(z.literal("")),
+ mobile: z.string().optional(),
+ company: z.string().optional(),
+ priority: z.enum(["low", "medium", "high", "urgent"]).default("medium"),
+ status: z.enum(["new", "contacted", "qualified", "unqualified", "converted", "lost"]).default("new"),
+ description: z.string().optional(),
+});
+
+const editSchema = createSchema.partial().extend({
+ first_name: z.string().min(1).optional(),
+});
+
+export default createCrmListPage({
+ title: "سرنخها",
+ description: "مدیریت سرنخهای فروش — ایجاد، تخصیص، حذف و بازیابی.",
+ breadcrumb: "سرنخها",
+ resourceKey: "leads",
+ createSchema,
+ editSchema,
+ createDefaults: {
+ first_name: "",
+ last_name: "",
+ email: "",
+ mobile: "",
+ company: "",
+ priority: "medium",
+ status: "new",
+ description: "",
+ },
+ fields: [
+ { name: "first_name", label: "نام", required: true },
+ { name: "last_name", label: "نام خانوادگی" },
+ { name: "email", label: "ایمیل", type: "email" },
+ { name: "mobile", label: "موبایل", type: "tel" },
+ { name: "company", label: "شرکت" },
+ {
+ name: "priority",
+ label: "اولویت",
+ type: "select",
+ options: [
+ { value: "low", label: "کم" },
+ { value: "medium", label: "متوسط" },
+ { value: "high", label: "بالا" },
+ { value: "urgent", label: "فوری" },
+ ],
+ },
+ {
+ name: "status",
+ label: "وضعیت",
+ type: "select",
+ editOnly: true,
+ options: [
+ { value: "new", label: "جدید" },
+ { value: "contacted", label: "تماس" },
+ { value: "qualified", label: "واجد شرایط" },
+ { value: "unqualified", label: "نامناسب" },
+ { value: "converted", label: "تبدیل" },
+ { value: "lost", label: "از دست رفته" },
+ ],
+ },
+ { name: "description", label: "توضیحات", type: "textarea" },
+ ],
+ columns: [
+ { key: "lead_number", header: "شماره" },
+ { key: "full_name", header: "نام" },
+ { key: "company", header: "شرکت" },
+ { key: "email", header: "ایمیل" },
+ { key: "mobile", header: "موبایل" },
+ {
+ key: "status",
+ header: "وضعیت",
+ render: (r) => ,
+ },
+ {
+ key: "priority",
+ header: "اولویت",
+ render: (r) => ,
+ },
+ ],
+ list: (tid) => crmApi.leads.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.leads.create(tid, d),
+ update: (tid, id, d) => crmApi.leads.update(tid, id, d),
+ remove: (tid, id) => crmApi.leads.delete(tid, id),
+ restore: (tid, id) => crmApi.leads.restore(tid, id),
+ exportColumns: ["lead_number", "full_name", "email", "mobile", "company", "status", "priority"],
+});
diff --git a/frontend/modules/crm/features/sales/meetings.tsx b/frontend/modules/crm/features/sales/meetings.tsx
new file mode 100644
index 0000000..88c30a0
--- /dev/null
+++ b/frontend/modules/crm/features/sales/meetings.tsx
@@ -0,0 +1,36 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import type { Meeting } from "@/modules/crm/types";
+
+const schema = z.object({
+ subject: z.string().min(1),
+ starts_at: z.string().min(1),
+ ends_at: z.string().optional(),
+ location: z.string().optional(),
+});
+
+export default createCrmListPage({
+ title: "جلسات",
+ description: "برنامهریزی و ثبت جلسات فروش.",
+ breadcrumb: "جلسات",
+ resourceKey: "meetings",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: { subject: "", starts_at: "", ends_at: "", location: "" },
+ fields: [
+ { name: "subject", label: "موضوع" },
+ { name: "starts_at", label: "شروع (ISO datetime)" },
+ { name: "ends_at", label: "پایان" },
+ { name: "location", label: "مکان" },
+ ],
+ columns: [
+ { key: "subject", header: "موضوع" },
+ { key: "starts_at", header: "شروع" },
+ { key: "location", header: "مکان" },
+ { key: "status", header: "وضعیت" },
+ ],
+ list: (tid) => crmApi.meetings.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.meetings.create(tid, d),
+ exportColumns: ["subject", "starts_at", "status"],
+});
diff --git a/frontend/modules/crm/features/sales/notes.tsx b/frontend/modules/crm/features/sales/notes.tsx
new file mode 100644
index 0000000..c900af5
--- /dev/null
+++ b/frontend/modules/crm/features/sales/notes.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ Button,
+ Dialog,
+ FormField,
+ Input,
+ Select,
+ Textarea,
+ PageHeader,
+ 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";
+
+export default function NotesPage() {
+ const { tenantId } = useTenantId();
+ const qc = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const [entityType, setEntityType] = useState("lead");
+ const [entityId, setEntityId] = useState("");
+
+ const notesQ = useQuery({
+ queryKey: ["crm", tenantId, "notes", entityType, entityId],
+ queryFn: () => crmApi.notes.list(tenantId!, entityType, entityId),
+ enabled: !!tenantId && !!entityId,
+ });
+
+ const form = useForm({ defaultValues: { body: "" } });
+
+ const createM = useMutation({
+ mutationFn: (body: string) =>
+ crmApi.notes.create(tenantId!, { entity_type: entityType, entity_id: entityId, body }),
+ onSuccess: async () => {
+ toast.success("یادداشت ثبت شد");
+ setOpen(false);
+ form.reset();
+ await qc.invalidateQueries({ queryKey: ["crm", tenantId, "notes"] });
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (!tenantId) return ;
+
+ return (
+
+
+
setOpen(true)} disabled={!entityId}>
+ یادداشت جدید
+
+ }
+ />
+
+
+ setEntityId(e.target.value)}
+ className="max-w-md"
+ />
+
+ {entityId && notesQ.isLoading ? : null}
+ {notesQ.error ? notesQ.refetch()} /> : null}
+ {entityId && notesQ.data ? (
+
+ {notesQ.data.map((n) => (
+
+
{n.body}
+
v{n.version}
+
+ ))}
+ {notesQ.data.length === 0 ?
: null}
+
+ ) : (
+ !entityId &&
+ )}
+
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/pipelines.tsx b/frontend/modules/crm/features/sales/pipelines.tsx
new file mode 100644
index 0000000..5337e48
--- /dev/null
+++ b/frontend/modules/crm/features/sales/pipelines.tsx
@@ -0,0 +1,40 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import type { Pipeline } from "@/modules/crm/types";
+
+const schema = z.object({
+ name: z.string().min(1),
+ code: z.string().min(1),
+ description: z.string().optional(),
+ is_default: z.boolean().optional(),
+});
+
+export default createCrmListPage({
+ title: "قیفهای فروش",
+ description: "تعریف pipeline و مراحل فروش.",
+ breadcrumb: "قیفها",
+ resourceKey: "pipelines",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: { name: "", code: "", description: "", is_default: false },
+ fields: [
+ { name: "name", label: "نام" },
+ { name: "code", label: "کد", createOnly: true },
+ { name: "description", label: "توضیحات", type: "textarea" },
+ ],
+ columns: [
+ { key: "code", header: "کد" },
+ { key: "name", header: "نام" },
+ { key: "status", header: "وضعیت" },
+ {
+ key: "is_default",
+ header: "پیشفرض",
+ render: (r) => (r.is_default ? "بله" : "خیر"),
+ },
+ ],
+ list: (tid) => crmApi.pipelines.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.pipelines.create(tid, d),
+ update: (tid, id, d) => crmApi.pipelines.update(tid, id, d),
+ exportColumns: ["code", "name", "status"],
+});
diff --git a/frontend/modules/crm/features/sales/quotes.tsx b/frontend/modules/crm/features/sales/quotes.tsx
new file mode 100644
index 0000000..226b9d0
--- /dev/null
+++ b/frontend/modules/crm/features/sales/quotes.tsx
@@ -0,0 +1,56 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import type { Quote } from "@/modules/crm/types";
+
+const schema = z.object({
+ title: z.string().min(1),
+ total_amount: z.string().default("0"),
+ currency_code: z.string().default("IRR"),
+ status: z.enum(["draft", "sent", "accepted", "rejected", "expired"]).default("draft"),
+ notes: z.string().optional(),
+});
+
+export default createCrmListPage({
+ title: "پیشفاکتورها",
+ description: "Quote management — بدون حذف در API فعلی.",
+ breadcrumb: "پیشفاکتورها",
+ resourceKey: "quotes",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: {
+ title: "",
+ total_amount: "0",
+ currency_code: "IRR",
+ status: "draft",
+ notes: "",
+ },
+ fields: [
+ { name: "title", label: "عنوان" },
+ { name: "total_amount", label: "مبلغ کل" },
+ { name: "currency_code", label: "ارز" },
+ {
+ name: "status",
+ label: "وضعیت",
+ type: "select",
+ options: [
+ { value: "draft", label: "پیشنویس" },
+ { value: "sent", label: "ارسالشده" },
+ { value: "accepted", label: "پذیرفته" },
+ { value: "rejected", label: "رد شده" },
+ { value: "expired", label: "منقضی" },
+ ],
+ },
+ { name: "notes", label: "یادداشت", type: "textarea" },
+ ],
+ columns: [
+ { key: "quote_number", header: "شماره" },
+ { key: "title", header: "عنوان" },
+ { key: "total_amount", header: "مبلغ" },
+ { key: "status", header: "وضعیت" },
+ ],
+ list: (tid) => crmApi.quotes.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.quotes.create(tid, d),
+ update: (tid, id, d) => crmApi.quotes.update(tid, id, d),
+ exportColumns: ["quote_number", "title", "total_amount", "status"],
+});
diff --git a/frontend/modules/crm/features/sales/reports.tsx b/frontend/modules/crm/features/sales/reports.tsx
new file mode 100644
index 0000000..3ea7197
--- /dev/null
+++ b/frontend/modules/crm/features/sales/reports.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { PageHeader, Card, CardContent } from "@/components/ds";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CrmStatGrid } from "@/modules/crm/design-system";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+import { CrmPageLoader, CrmPageError } from "@/modules/crm/pages/shared";
+
+export default function ReportsPage() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "reports"],
+ queryFn: async () => {
+ const [leads, opps, quotes, forecasts] = await Promise.all([
+ crmApi.leads.list(tenantId!, { page: 1, page_size: 500 }),
+ crmApi.opportunities.list(tenantId!, { page: 1, page_size: 500 }),
+ crmApi.quotes.list(tenantId!, { page: 1, page_size: 500 }),
+ crmApi.forecasts.list(tenantId!, { page: 1, page_size: 50 }),
+ ]);
+ const pipelineValue = opps
+ .filter((o) => o.status === "open")
+ .reduce((s, o) => s + Number(o.amount || 0), 0);
+ return {
+ leadCount: leads.length,
+ openDeals: opps.filter((o) => o.status === "open").length,
+ wonDeals: opps.filter((o) => o.status === "won").length,
+ pipelineValue,
+ quoteCount: quotes.length,
+ forecastCount: forecasts.length,
+ };
+ },
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ return (
+
+
+
+
+
+
+ گزارشهای پیشرفته (PDF/scheduled) در Analytics Platform آینده — فعلاً فقط aggregate از list
+ endpoints.
+
+
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/scoped.tsx b/frontend/modules/crm/features/sales/scoped.tsx
new file mode 100644
index 0000000..8b7ed06
--- /dev/null
+++ b/frontend/modules/crm/features/sales/scoped.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { PageHeader } from "@/components/ds";
+import { CrmScopeNotice } from "@/modules/crm/components/CrmScopeNotice";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+
+export function CampaignsPage() {
+ return (
+
+ );
+}
+
+export function SegmentsPage() {
+ return (
+
+ );
+}
+
+export function ImportPage() {
+ return (
+
+ );
+}
+
+export function AutomationPage() {
+ return (
+
+ );
+}
+
+export function IntegrationsPage() {
+ return (
+
+ );
+}
+
+export function ApiKeysPage() {
+ return (
+
+ );
+}
diff --git a/frontend/modules/crm/features/sales/tags.tsx b/frontend/modules/crm/features/sales/tags.tsx
new file mode 100644
index 0000000..dc49702
--- /dev/null
+++ b/frontend/modules/crm/features/sales/tags.tsx
@@ -0,0 +1,52 @@
+import { z } from "zod";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import type { Tag } from "@/modules/crm/types";
+
+const schema = z.object({
+ name: z.string().min(1),
+ color: z.string().optional(),
+ scope: z.enum(["lead", "contact", "organization", "global"]).default("global"),
+});
+
+export default createCrmListPage({
+ title: "برچسبها",
+ description: "Tag engine — تخصیص به lead/contact/organization از API جداگانه.",
+ breadcrumb: "برچسبها",
+ resourceKey: "tags",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: { name: "", color: "#6366f1", scope: "global" },
+ fields: [
+ { name: "name", label: "نام" },
+ { name: "color", label: "رنگ" },
+ {
+ name: "scope",
+ label: "محدوده",
+ type: "select",
+ options: [
+ { value: "global", label: "سراسری" },
+ { value: "lead", label: "سرنخ" },
+ { value: "contact", label: "مخاطب" },
+ { value: "organization", label: "سازمان" },
+ ],
+ },
+ ],
+ columns: [
+ { key: "name", header: "نام" },
+ { key: "scope", header: "محدوده" },
+ {
+ key: "color",
+ header: "رنگ",
+ render: (r) => (
+
+ ),
+ },
+ ],
+ list: (tid) => crmApi.tags.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.tags.create(tid, d),
+ exportColumns: ["name", "scope", "color"],
+});
diff --git a/frontend/modules/crm/features/sales/tasks.tsx b/frontend/modules/crm/features/sales/tasks.tsx
new file mode 100644
index 0000000..943eb9e
--- /dev/null
+++ b/frontend/modules/crm/features/sales/tasks.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { z } from "zod";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Check } from "lucide-react";
+import { Button } from "@/components/ds";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { createCrmListPage } from "@/modules/crm/components/CrmListCrudPage";
+import type { Task } from "@/modules/crm/types";
+import { useTenantId } from "@/hooks/useTenantId";
+
+const schema = z.object({
+ title: z.string().min(1),
+ description: z.string().optional(),
+ priority: z.enum(["low", "medium", "high", "urgent"]).default("medium"),
+});
+
+function TaskCompleteButton({ row }: { row: Task }) {
+ const { tenantId } = useTenantId();
+ const qc = useQueryClient();
+ const completeM = useMutation({
+ mutationFn: () => crmApi.tasks.complete(tenantId!, row.id),
+ onSuccess: async () => {
+ toast.success("وظیفه تکمیل شد");
+ await qc.invalidateQueries({ queryKey: ["crm", tenantId, "tasks"] });
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (row.status === "completed") return ✓;
+ return (
+
+ );
+}
+
+export default createCrmListPage({
+ title: "وظایف",
+ description: "مدیریت task های فروش.",
+ breadcrumb: "وظایف",
+ resourceKey: "tasks",
+ createSchema: schema,
+ editSchema: schema.partial(),
+ createDefaults: { title: "", description: "", priority: "medium" },
+ fields: [
+ { name: "title", label: "عنوان" },
+ { name: "description", label: "توضیحات", type: "textarea" },
+ {
+ name: "priority",
+ label: "اولویت",
+ type: "select",
+ options: [
+ { value: "low", label: "کم" },
+ { value: "medium", label: "متوسط" },
+ { value: "high", label: "بالا" },
+ { value: "urgent", label: "فوری" },
+ ],
+ },
+ ],
+ columns: [
+ { key: "title", header: "عنوان" },
+ { key: "status", header: "وضعیت" },
+ { key: "priority", header: "اولویت" },
+ {
+ key: "complete",
+ header: "تکمیل",
+ render: (r) => ,
+ },
+ ],
+ list: (tid) => crmApi.tasks.list(tid, { page: 1, page_size: 500 }),
+ create: (tid, d) => crmApi.tasks.create(tid, d),
+ exportColumns: ["title", "status", "priority"],
+});
diff --git a/frontend/modules/crm/features/sales/timeline.tsx b/frontend/modules/crm/features/sales/timeline.tsx
new file mode 100644
index 0000000..ab213b3
--- /dev/null
+++ b/frontend/modules/crm/features/sales/timeline.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { PageHeader, EmptyState } from "@/components/ds";
+import { crmApi } from "@/modules/crm/services/crm-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CrmTablePage } from "@/modules/crm/design-system";
+import { CrmBreadcrumbs } from "@/modules/crm/components/CrmBreadcrumbs";
+import { CrmPageLoader, CrmPageError } from "@/modules/crm/pages/shared";
+
+export default function TimelinePage() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["crm", tenantId, "timeline"],
+ queryFn: () => crmApi.timeline.list(tenantId!, { page: 1, page_size: 500 }),
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ return (
+ }
+ title="خط زمانی فروش"
+ description="رویدادهای immutable timeline — فقط خواندنی از API."
+ columns={[
+ { key: "event_type", header: "نوع" },
+ { key: "title", header: "عنوان" },
+ { key: "summary", header: "خلاصه" },
+ { key: "occurred_at", header: "زمان" },
+ ]}
+ rows={(q.data ?? []) as unknown as Record[]}
+ empty={}
+ />
+ );
+}
diff --git a/frontend/modules/crm/hooks/useCrmKeyboardShortcuts.ts b/frontend/modules/crm/hooks/useCrmKeyboardShortcuts.ts
new file mode 100644
index 0000000..a588bb4
--- /dev/null
+++ b/frontend/modules/crm/hooks/useCrmKeyboardShortcuts.ts
@@ -0,0 +1,43 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { CRM_SHORTCUTS } from "@/modules/crm/constants/portals";
+
+/** Global keyboard shortcuts for CRM (g + key chord). */
+export function useCrmKeyboardShortcuts() {
+ const router = useRouter();
+
+ useEffect(() => {
+ let pending: string | null = null;
+ let timer: ReturnType;
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
+ const key = e.key.toLowerCase();
+ if (pending === "g") {
+ const chord = `g ${key}`;
+ const href = CRM_SHORTCUTS[chord];
+ if (href) {
+ e.preventDefault();
+ router.push(href);
+ }
+ pending = null;
+ return;
+ }
+ if (key === "g") {
+ pending = "g";
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ pending = null;
+ }, 800);
+ }
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ clearTimeout(timer);
+ };
+ }, [router]);
+}
diff --git a/frontend/modules/crm/index.ts b/frontend/modules/crm/index.ts
new file mode 100644
index 0000000..cfe44b3
--- /dev/null
+++ b/frontend/modules/crm/index.ts
@@ -0,0 +1,2 @@
+export { CrmHub } from "./pages/hub";
+export { crmApi } from "./services/crm-api";
diff --git a/frontend/modules/crm/pages/hub.tsx b/frontend/modules/crm/pages/hub.tsx
new file mode 100644
index 0000000..ccc18cf
--- /dev/null
+++ b/frontend/modules/crm/pages/hub.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import Link from "next/link";
+import { AuthGuard } from "@/components/AuthGuard";
+import { PageHeader, Card, CardContent, Button } from "@/components/ds";
+import { CRM_PORTALS } from "@/modules/crm/constants/portals";
+import { CrmServiceBadge, useCrmHealth } from "@/modules/crm/pages/shared";
+
+export function CrmHub() {
+ return (
+
+
+
+ );
+}
+
+function CrmHubInner() {
+ const healthQ = useCrmHealth();
+
+ return (
+
+
}
+ />
+
+ {CRM_PORTALS.map((portal) => {
+ const Icon = portal.icon;
+ return (
+
+
+
+
+
+
+
+
{portal.label}
+
{portal.description}
+
+ ورود ←
+
+
+
+ );
+ })}
+
+
+
+
+
+ {healthQ.data ? (
+
+ سرویس {healthQ.data.service} — نسخه {healthQ.data.version}
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/modules/crm/pages/shared.tsx b/frontend/modules/crm/pages/shared.tsx
new file mode 100644
index 0000000..523957a
--- /dev/null
+++ b/frontend/modules/crm/pages/shared.tsx
@@ -0,0 +1,124 @@
+"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 ;
+}
+
+export function CrmPageError({
+ error,
+ onRetry,
+}: {
+ error: unknown;
+ onRetry?: () => void;
+}) {
+ if (error instanceof CrmApiError && error.status === 403) {
+ return (
+
+ );
+ }
+ const message = error instanceof Error ? error.message : "خطا در دریافت دادهها";
+ return ;
+}
+
+export function useCrmHealth() {
+ return useQuery({
+ queryKey: ["crm", "health"],
+ queryFn: () => crmApi.health(),
+ });
+}
+
+export function CrmServiceBadge() {
+ const healthQ = useCrmHealth();
+ if (!healthQ.data) return null;
+ return (
+
+ CRM v{healthQ.data.version}
+
+ );
+}
+
+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 ;
+ if (q.error) return q.refetch()} />;
+ const c = q.data!;
+ return (
+
+ );
+}
+
+export function exportToCsv(filename: string, rows: Record[], 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);
+}
diff --git a/frontend/modules/crm/services/crm-api.ts b/frontend/modules/crm/services/crm-api.ts
new file mode 100644
index 0000000..6e76158
--- /dev/null
+++ b/frontend/modules/crm/services/crm-api.ts
@@ -0,0 +1,734 @@
+/**
+ * CRM Service API client — real backend only (Phase 6.0–6.3).
+ * Browser → /api/crm BFF; SSR → direct upstream.
+ */
+
+import { getValidAccessToken, refreshSession } from "@/lib/auth";
+import type {
+ Attachment,
+ AuditEntry,
+ CallLog,
+ Comment,
+ Contact,
+ CustomField,
+ Forecast,
+ Goal,
+ HealthResponse,
+ Lead,
+ ListParams,
+ LookupRead,
+ Meeting,
+ Mention,
+ Note,
+ Opportunity,
+ Organization,
+ Pipeline,
+ PipelineStage,
+ Playbook,
+ Quote,
+ SalesActivity,
+ Tag,
+ Target,
+ Task,
+ TeamMember,
+ TimelineEvent,
+} from "@/modules/crm/types";
+
+const CRM_BASE =
+ typeof window !== "undefined"
+ ? "/api/crm"
+ : process.env.CRM_SERVICE_URL ||
+ process.env.NEXT_PUBLIC_CRM_API_URL ||
+ "http://localhost:8003";
+
+export class CrmApiError extends Error {
+ constructor(
+ public status: number,
+ public code: string,
+ message: string
+ ) {
+ super(message);
+ this.name = "CrmApiError";
+ }
+}
+
+type RequestOptions = RequestInit & { tenantId: string; auth?: boolean };
+
+async function request(path: string, options: RequestOptions): Promise {
+ const { tenantId, auth = true, headers: customHeaders, ...init } = options;
+ const headers = new Headers(customHeaders);
+ if (!headers.has("Content-Type") && init.body) {
+ headers.set("Content-Type", "application/json");
+ }
+ headers.set("X-Tenant-ID", tenantId);
+ if (auth) {
+ const token = await getValidAccessToken();
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+ }
+
+ const url = path.startsWith("http")
+ ? path
+ : `${CRM_BASE.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
+
+ let res: Response;
+ try {
+ res = await fetch(url, { ...init, headers });
+ } catch (err) {
+ const detail = err instanceof Error ? err.message : "network_error";
+ throw new Error(`اتصال به سرویس CRM برقرار نشد (${url}) — ${detail}`);
+ }
+
+ if (res.status === 401 && auth) {
+ const refreshed = await refreshSession({ force: true });
+ if (refreshed) {
+ headers.set("Authorization", `Bearer ${refreshed}`);
+ res = await fetch(url, { ...init, headers });
+ }
+ }
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new CrmApiError(
+ res.status,
+ body?.error?.code || body?.detail || "unknown_error",
+ body?.error?.message || body?.detail || res.statusText
+ );
+ }
+ if (res.status === 204) return undefined as T;
+ return res.json() as Promise;
+}
+
+function qs(params?: ListParams) {
+ if (!params) return "";
+ const sp = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
+ });
+ const s = sp.toString();
+ return s ? `?${s}` : "";
+}
+
+function apiPath(segment: string) {
+ return `/api/v1${segment.startsWith("/") ? segment : `/${segment}`}`;
+}
+
+export const crmApi = {
+ health: () =>
+ request("/health", {
+ tenantId: "00000000-0000-0000-0000-000000000000",
+ auth: false,
+ }),
+
+ leads: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/leads")}${qs(params)}`, { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request(apiPath(`/leads/${id}`), { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/leads"), { tenantId, method: "POST", body: JSON.stringify(body) }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/leads/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ assign: (tenantId: string, id: string, body: { assigned_user_id: string; note?: string }) =>
+ request(apiPath(`/leads/${id}/assign`), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ delete: (tenantId: string, id: string) =>
+ request(apiPath(`/leads/${id}/delete`), { tenantId, method: "POST" }),
+ restore: (tenantId: string, id: string) =>
+ request(apiPath(`/leads/${id}/restore`), { tenantId, method: "POST" }),
+ },
+
+ contacts: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/contacts")}${qs(params)}`, { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request(apiPath(`/contacts/${id}`), { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/contacts"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/contacts/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ delete: (tenantId: string, id: string) =>
+ request(apiPath(`/contacts/${id}/delete`), { tenantId, method: "POST" }),
+ },
+
+ organizations: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/organizations")}${qs(params)}`, { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request(apiPath(`/organizations/${id}`), { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/organizations"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/organizations/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ delete: (tenantId: string, id: string) =>
+ request(apiPath(`/organizations/${id}/delete`), {
+ tenantId,
+ method: "POST",
+ }),
+ },
+
+ opportunities: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/opportunities")}${qs(params)}`, { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request(apiPath(`/opportunities/${id}`), { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/opportunities"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/opportunities/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ changeStage: (tenantId: string, id: string, body: { stage_id: string; note?: string }) =>
+ request(apiPath(`/opportunities/${id}/stage`), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ win: (tenantId: string, id: string, body?: Record) =>
+ request(apiPath(`/opportunities/${id}/win`), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body ?? {}),
+ }),
+ lose: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/opportunities/${id}/lose`), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ stageHistory: (tenantId: string, id: string) =>
+ request[]>(apiPath(`/opportunities/${id}/stage-history`), {
+ tenantId,
+ }),
+ },
+
+ pipelines: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/pipelines")}${qs(params)}`, { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request(apiPath(`/pipelines/${id}`), { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/pipelines"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/pipelines/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ stages: (tenantId: string, pipelineId: string, params?: ListParams) =>
+ request(
+ `${apiPath(`/pipelines/${pipelineId}/stages`)}${qs(params)}`,
+ { tenantId }
+ ),
+ createStage: (tenantId: string, body: Record) =>
+ request(apiPath("/pipelines/stages"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ updateStage: (tenantId: string, stageId: string, body: Record) =>
+ request(apiPath(`/pipelines/stages/${stageId}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ getStage: (tenantId: string, stageId: string) =>
+ request(apiPath(`/pipelines/stages/${stageId}`), { tenantId }),
+ },
+
+ activities: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/activities")}${qs(params)}`, { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request(apiPath(`/activities/${id}`), { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/activities"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/activities/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ complete: (tenantId: string, id: string) =>
+ request(apiPath(`/activities/${id}/complete`), {
+ tenantId,
+ method: "POST",
+ }),
+ },
+
+ quotes: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/quotes")}${qs(params)}`, { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request(apiPath(`/quotes/${id}`), { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/quotes"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/quotes/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ tags: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/tags")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/tags"), { tenantId, method: "POST", body: JSON.stringify(body) }),
+ assignLead: (tenantId: string, leadId: string, tagId: string) =>
+ request>(apiPath(`/tags/leads/${leadId}`), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify({ tag_id: tagId }),
+ }),
+ assignContact: (tenantId: string, contactId: string, tagId: string) =>
+ request>(apiPath(`/tags/contacts/${contactId}`), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify({ tag_id: tagId }),
+ }),
+ assignOrganization: (tenantId: string, orgId: string, tagId: string) =>
+ request>(apiPath(`/tags/organizations/${orgId}`), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify({ tag_id: tagId }),
+ }),
+ },
+
+ notes: {
+ list: (tenantId: string, entityType: string, entityId: string) =>
+ request(
+ `${apiPath("/notes")}?entity_type=${entityType}&entity_id=${entityId}`,
+ { tenantId }
+ ),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/notes"), { tenantId, method: "POST", body: JSON.stringify(body) }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/notes/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ attachments: {
+ list: (tenantId: string, entityType: string, entityId: string) =>
+ request(
+ `${apiPath("/attachments")}?entity_type=${entityType}&entity_id=${entityId}`,
+ { tenantId }
+ ),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/attachments"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ customFields: {
+ list: (tenantId: string, entityType: string) =>
+ request(`${apiPath("/custom-fields")}?entity_type=${entityType}`, {
+ tenantId,
+ }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/custom-fields"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ upsertValue: (tenantId: string, body: Record) =>
+ request>(apiPath("/custom-fields/values"), {
+ tenantId,
+ method: "PUT",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ audit: {
+ list: (tenantId: string, entityType: string, entityId: string) =>
+ request(
+ `${apiPath("/audit")}?entity_type=${entityType}&entity_id=${entityId}`,
+ { tenantId }
+ ),
+ },
+
+ lookups: {
+ leadSources: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/lookups/lead-sources")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/lookups/lead-sources"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ leadStatuses: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/lookups/lead-statuses")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/lookups/lead-statuses"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ contactTypes: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/lookups/contact-types")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/lookups/contact-types"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ organizationTypes: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(
+ `${apiPath("/lookups/organization-types")}${qs(params)}`,
+ { tenantId }
+ ),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/lookups/organization-types"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ organizationIndustries: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(
+ `${apiPath("/lookups/organization-industries")}${qs(params)}`,
+ { tenantId }
+ ),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/lookups/organization-industries"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ },
+
+ tasks: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/tasks")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/tasks"), { tenantId, method: "POST", body: JSON.stringify(body) }),
+ complete: (tenantId: string, id: string) =>
+ request(apiPath(`/tasks/${id}/complete`), { tenantId, method: "POST" }),
+ createChecklistItem: (tenantId: string, body: Record) =>
+ request>(apiPath("/tasks/checklist"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ completeChecklistItem: (tenantId: string, itemId: string) =>
+ request>(apiPath(`/tasks/checklist/${itemId}/complete`), {
+ tenantId,
+ method: "POST",
+ }),
+ },
+
+ meetings: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/meetings")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/meetings"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ finish: (tenantId: string, id: string, body?: Record) =>
+ request(apiPath(`/meetings/${id}/finish`), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body ?? {}),
+ }),
+ addAttendee: (tenantId: string, body: Record) =>
+ request>(apiPath("/meetings/attendees"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ calls: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/calls")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/calls"), { tenantId, method: "POST", body: JSON.stringify(body) }),
+ },
+
+ emails: {
+ createThread: (tenantId: string, body: Record) =>
+ request>(apiPath("/email-threads"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ createMessage: (tenantId: string, body: Record) =>
+ request>(apiPath("/emails"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ timeline: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/timeline")}${qs(params)}`, { tenantId }),
+ forOpportunity: (tenantId: string, opportunityId: string) =>
+ request(apiPath(`/timeline/opportunities/${opportunityId}`), { tenantId }),
+ },
+
+ comments: {
+ list: (tenantId: string, entityType: string, entityId: string) =>
+ request(
+ `${apiPath("/comments")}?entity_type=${entityType}&entity_id=${entityId}`,
+ { tenantId }
+ ),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/comments"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request(apiPath(`/comments/${id}`), {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ mentions: {
+ unread: (tenantId: string) =>
+ request(apiPath("/mentions/unread"), { tenantId }),
+ markRead: (tenantId: string, id: string) =>
+ request(apiPath(`/mentions/${id}/read`), { tenantId, method: "POST" }),
+ },
+
+ team: {
+ members: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/team-members")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/team-members"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ roles: {
+ create: (tenantId: string, body: Record) =>
+ request>(apiPath("/team-roles"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ assignments: {
+ create: (tenantId: string, body: Record) =>
+ request>(apiPath("/team-assignments"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ },
+
+ bookmarks: {
+ list: (tenantId: string, params?: ListParams) =>
+ request[]>(`${apiPath("/bookmarks")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request>(apiPath("/bookmarks"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ favorites: {
+ create: (tenantId: string, body: Record) =>
+ request>(apiPath("/favorites"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ playbooks: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/playbooks")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/playbooks"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ steps: (tenantId: string, playbookId: string) =>
+ request[]>(apiPath(`/playbooks/${playbookId}/steps`), { tenantId }),
+ createStep: (tenantId: string, body: Record) =>
+ request>(apiPath("/playbooks/steps"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ assign: (tenantId: string, body: Record) =>
+ request>(apiPath("/playbooks/assignments"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ completeStep: (tenantId: string, assignmentId: string, body: Record) =>
+ request>(
+ apiPath(`/playbooks/assignments/${assignmentId}/complete-step`),
+ { tenantId, method: "POST", body: JSON.stringify(body) }
+ ),
+ },
+
+ forecasts: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/forecasts")}${qs(params)}`, { tenantId }),
+ compute: (tenantId: string, body: Record) =>
+ request(apiPath("/forecasts/compute"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ goals: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/goals")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/goals"), { tenantId, method: "POST", body: JSON.stringify(body) }),
+ },
+
+ targets: {
+ list: (tenantId: string, params?: ListParams) =>
+ request(`${apiPath("/targets")}${qs(params)}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request(apiPath("/targets"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ salesProcesses: {
+ list: (tenantId: string, params?: ListParams) =>
+ request[]>(`${apiPath("/sales-processes")}${qs(params)}`, {
+ tenantId,
+ }),
+ create: (tenantId: string, body: Record) =>
+ request>(apiPath("/sales-processes"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ winLoss: {
+ categories: {
+ list: (tenantId: string, params?: ListParams) =>
+ request[]>(
+ `${apiPath("/win-loss/categories")}${qs(params)}`,
+ { tenantId }
+ ),
+ create: (tenantId: string, body: Record) =>
+ request>(apiPath("/win-loss/categories"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ reasons: {
+ list: (tenantId: string, params?: ListParams) =>
+ request[]>(
+ `${apiPath("/win-loss/reasons")}${qs(params)}`,
+ { tenantId }
+ ),
+ create: (tenantId: string, body: Record) =>
+ request>(apiPath("/win-loss/reasons"), {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ forOpportunity: (tenantId: string, opportunityId: string) =>
+ request>(
+ apiPath(`/win-loss/opportunities/${opportunityId}`),
+ { tenantId }
+ ),
+ },
+
+ collaborationPreferences: {
+ upsert: (tenantId: string, userId: string, body: Record) =>
+ request>(apiPath(`/collaboration-preferences/${userId}`), {
+ tenantId,
+ method: "PUT",
+ body: JSON.stringify(body),
+ }),
+ },
+};
+
+/** Fetch all pages client-side (backend has no total count). */
+export async function crmFetchAll(
+ fetchPage: (params: ListParams) => Promise,
+ pageSize = 500
+): Promise {
+ const all: T[] = [];
+ let page = 1;
+ for (;;) {
+ const batch = await fetchPage({ page, page_size: pageSize });
+ all.push(...batch);
+ if (batch.length < pageSize) break;
+ page += 1;
+ if (page > 50) break;
+ }
+ return all;
+}
diff --git a/frontend/modules/crm/types/index.ts b/frontend/modules/crm/types/index.ts
new file mode 100644
index 0000000..b26dfe4
--- /dev/null
+++ b/frontend/modules/crm/types/index.ts
@@ -0,0 +1,428 @@
+/** CRM TypeScript types — mirrors backend DTOs (Phase 6.0–6.3). */
+
+export type CrmEntityType = "lead" | "contact" | "organization";
+export type CollaborationEntityType =
+ | "lead"
+ | "contact"
+ | "organization"
+ | "opportunity"
+ | "activity"
+ | "task"
+ | "meeting";
+
+export type LeadStatus = "new" | "contacted" | "qualified" | "unqualified" | "converted" | "lost";
+export type LeadPriority = "low" | "medium" | "high" | "urgent";
+export type ContactKind = "business" | "personal";
+export type ContactStatus = "active" | "inactive" | "archived";
+export type OrganizationKind = "company" | "account" | "partner" | "vendor";
+export type OrganizationStatus = "active" | "inactive" | "prospect" | "archived";
+export type OpportunityStatus = "open" | "won" | "lost" | "on_hold";
+export type QuoteStatus = "draft" | "sent" | "accepted" | "rejected" | "expired";
+export type ActivityType = "call" | "email" | "meeting" | "task" | "note" | "other";
+export type ActivityStatus = "planned" | "in_progress" | "completed" | "cancelled";
+export type ActivityPriority = "low" | "medium" | "high" | "urgent";
+
+export interface Timestamps {
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Lead {
+ id: string;
+ tenant_id: string;
+ lead_number: string;
+ first_name: string;
+ last_name: string;
+ full_name: string;
+ title: string | null;
+ company: string | null;
+ job_title: string | null;
+ email: string | null;
+ mobile: string | null;
+ phone: string | null;
+ website: string | null;
+ lead_source_id: string | null;
+ lead_status_id: string | null;
+ status: LeadStatus;
+ source: string | null;
+ assigned_user_id: string | null;
+ priority: LeadPriority;
+ estimated_value: string;
+ expected_close_date: string | null;
+ description: string | null;
+ owner_user_id: string;
+ contact_id: string | null;
+ organization_id: string | null;
+ is_deleted: boolean;
+ created_by: string | null;
+ updated_by: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Contact {
+ id: string;
+ tenant_id: string;
+ first_name: string;
+ last_name: string;
+ full_name: string;
+ kind: ContactKind;
+ contact_type_id: string | null;
+ email: string | null;
+ phone: string | null;
+ emails: string[] | null;
+ phones: string[] | null;
+ default_contact_method: string | null;
+ social_links: Record