TorbatYar/frontend/components/accounting/AccountingShell.tsx
Mortezakoohjani 86b37c1de9 Speed up accounting sidebar navigation with shared session cache and prefetch.
Stop refetching /me on every page mount, warm shared accounting queries, prefetch nav routes, and keep the shell visible during soft SPA transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:31:24 +03:30

213 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown, Moon, Sun, ChevronRight, Plus, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { useColorMode } from "@/components/providers/ColorModeProvider";
import { Button } from "@/components/ds";
import { ACCOUNTING_NAV, ACCOUNTING_QUICK_BAR } from "@/lib/accounting-nav";
function isActive(pathname: string, href: string) {
const base = href.split("?")[0];
if (base === "/accounting") return pathname === "/accounting";
return pathname === base || pathname.startsWith(`${base}/`);
}
function NavLink({
href,
className,
children,
}: {
href: string;
className?: string;
children: React.ReactNode;
}) {
const router = useRouter();
return (
<Link
href={href}
prefetch
className={className}
onMouseEnter={() => {
try {
router.prefetch(href);
} catch {
/* ignore */
}
}}
>
{children}
</Link>
);
}
export function AccountingShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { mode, toggle } = useColorMode();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const autoOpen = useMemo(() => {
const map: Record<string, boolean> = {};
for (const g of ACCOUNTING_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]);
const expanded = { ...autoOpen, ...openGroups };
const toggleGroup = (id: string) =>
setOpenGroups((prev) => ({ ...prev, [id]: !(expanded[id] ?? false) }));
return (
<div className="flex min-h-[calc(100vh-4rem)] bg-[var(--canvas)]">
<aside className="sticky top-16 hidden h-[calc(100vh-4rem)] w-72 shrink-0 overflow-y-auto border-l border-[var(--border)] bg-[var(--surface)] p-3 lg:block">
<div className="mb-3 flex items-center justify-between px-2">
<div>
<p className="text-xs text-[var(--muted)]">ماژول</p>
<p className="font-semibold text-secondary">حسابداری پنل اصلی</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={toggle} aria-label="تغییر تم">
{mode === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
<nav className="space-y-0.5 pb-24">
{ACCOUNTING_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 (
<NavLink
key={group.id}
href={group.href}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
groupActive
? "bg-primary text-white shadow-sm"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)] hover:text-secondary"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{group.label}
</NavLink>
);
}
return (
<div key={group.id} className="rounded-xl">
<button
type="button"
onClick={() => toggleGroup(group.id)}
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)] hover:text-secondary",
group.blocked && "opacity-70"
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 text-right">{group.label}</span>
{group.blocked ? <Lock className="h-3.5 w-3.5" /> : null}
<ChevronDown
className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")}
/>
</button>
{isOpen ? (
<div className="mb-1 mr-3 space-y-0.5 border-r border-[var(--border)] pr-2">
{group.items?.map((item) => {
const active = isActive(pathname, item.href);
const blocked = item.blocked || group.blocked;
return (
<NavLink
key={item.href + item.label}
href={blocked ? "/accounting/ai" : item.href}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium",
active
? "bg-primary text-white"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)] hover:text-secondary",
blocked && "opacity-60"
)}
>
{blocked ? <Lock className="h-3 w-3" /> : null}
{item.label}
</NavLink>
);
})}
</div>
) : null}
</div>
);
})}
</nav>
<NavLink
href="/dashboard"
className="mt-2 flex items-center gap-2 px-3 text-xs text-[var(--muted)] hover:text-secondary"
>
<ChevronRight className="h-3.5 w-3.5" />
بازگشت به workspace
</NavLink>
</aside>
<div className="relative min-w-0 flex-1 pb-28 lg:pb-20">
<div className="flex gap-2 overflow-x-auto border-b border-[var(--border)] bg-[var(--surface)] px-3 py-2 lg:hidden">
{ACCOUNTING_NAV.flatMap((g) =>
g.href
? [{ href: g.href, label: g.label }]
: (g.items ?? []).slice(0, 1).map((i) => ({ href: i.href, label: g.label }))
).map((item) => (
<NavLink
key={item.href + item.label}
href={item.href}
className={cn(
"whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium",
isActive(pathname, item.href)
? "bg-primary text-white"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)]"
)}
>
{item.label}
</NavLink>
))}
</div>
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">{children}</div>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--border)] bg-[var(--surface)]/95 backdrop-blur">
<div className="mx-auto flex max-w-5xl items-stretch justify-around gap-1 px-2 py-2 pe-16">
{ACCOUNTING_QUICK_BAR.map((item) => (
<NavLink
key={item.href}
href={item.href}
className={cn(
"flex min-w-0 flex-1 flex-col items-center gap-0.5 rounded-lg px-1 py-1 text-[10px] font-medium sm:text-xs",
isActive(pathname, item.href) ? "text-primary" : "text-[var(--muted)]"
)}
>
<span className="truncate">{item.label}</span>
</NavLink>
))}
</div>
<NavLink
href="/accounting/vouchers/new"
className="absolute bottom-3 left-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-white shadow-lg"
>
<Plus className="h-6 w-6" aria-label="سند جدید" />
</NavLink>
</div>
</div>
</div>
);
}