TorbatYar/frontend/modules/crm/components/CrmPortalShell.tsx
Mortezakoohjani e140908034 Ship enterprise CRM frontend with real API wiring and thin app routes.
Connect all CRM pages to the BFF and CRM service, add module docs, and mark CRM available in the SuperApp catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:58:17 +03:30

173 lines
6.6 KiB
TypeScript

"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<Record<string, boolean>>({});
const [mobileOpen, setMobileOpen] = useState(false);
const nav = navForPortal(portalId);
const portalMeta = CRM_PORTALS.find((p) => p.id === portalId);
const autoOpen = useMemo(() => {
const map: Record<string, boolean> = {};
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 = (
<>
<div className="mb-6 px-2">
<Link href="/crm/hub" className="text-xs text-[var(--muted)] hover:text-[var(--crm-accent)]">
تربت CRM
</Link>
<p className="mt-1 font-semibold text-secondary">{title ?? portalMeta?.label}</p>
</div>
<nav className="space-y-0.5 overflow-y-auto pb-8">
{nav.map((group) => {
const Icon = group.icon;
const hasChildren = !!group.items?.length;
const groupActive = group.href
? isActive(pathname, group.href)
: group.items?.some((i) => isActive(pathname, i.href));
const isOpen = expanded[group.id] ?? false;
if (!hasChildren && group.href) {
return (
<Link
key={group.id}
href={group.href}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200",
groupActive
? "bg-[var(--crm-accent)] text-white shadow-[var(--shadow-sm)]"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)] hover:text-secondary"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{group.label}
</Link>
);
}
return (
<div key={group.id}>
<button
type="button"
onClick={() =>
setOpenGroups((prev) => ({ ...prev, [group.id]: !(expanded[group.id] ?? false) }))
}
className={cn(
"flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
groupActive
? "bg-[var(--surface-muted)] text-secondary"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)]"
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 text-right">{group.label}</span>
<ChevronDown className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")} />
</button>
{isOpen && group.items ? (
<div className="mr-3 mt-0.5 space-y-0.5 border-r border-[var(--border)] pr-2">
{group.items.map((item) => (
<Link
key={item.id}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
"block rounded-lg px-3 py-2 text-sm transition-colors",
isActive(pathname, item.href)
? "bg-[var(--crm-accent-soft)] font-medium text-[var(--crm-accent)]"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)] hover:text-secondary"
)}
>
{item.label}
</Link>
))}
</div>
) : null}
</div>
);
})}
</nav>
</>
);
return (
<div className="flex min-h-screen bg-[var(--canvas)]">
<aside className="hidden w-64 shrink-0 flex-col border-l border-[var(--border)] bg-[var(--surface)] p-4 lg:flex">
{sidebar}
<div className="mt-auto flex items-center gap-2 border-t border-[var(--border)] pt-4">
<Button variant="ghost" size="icon" onClick={toggle} aria-label="تغییر حالت تاریک/روشن">
{mode === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<Link href="/dashboard" className="text-xs text-[var(--muted)] hover:text-secondary">
workspace
</Link>
</div>
</aside>
{mobileOpen ? (
<div className="fixed inset-0 z-50 lg:hidden">
<button
type="button"
className="absolute inset-0 bg-black/40"
onClick={() => setMobileOpen(false)}
aria-label="بستن منو"
/>
<aside className="absolute right-0 top-0 flex h-full w-72 flex-col bg-[var(--surface)] p-4 shadow-[var(--shadow-lg)]">
<div className="mb-4 flex justify-end">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
<X className="h-5 w-5" />
</Button>
</div>
{sidebar}
</aside>
</div>
) : null}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 lg:hidden">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<span className="font-semibold text-secondary">{portalMeta?.label}</span>
</header>
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
</div>
</div>
);
}