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>
213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|