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>
This commit is contained in:
parent
8caed2cfd2
commit
86b37c1de9
@ -2,28 +2,65 @@
|
|||||||
|
|
||||||
import { AuthGuard } from "@/components/AuthGuard";
|
import { AuthGuard } from "@/components/AuthGuard";
|
||||||
import { AccountingShell } from "@/components/accounting/AccountingShell";
|
import { AccountingShell } from "@/components/accounting/AccountingShell";
|
||||||
|
import { RouteProgress } from "@/components/RouteProgress";
|
||||||
import { useMe } from "@/hooks/useMe";
|
import { useMe } from "@/hooks/useMe";
|
||||||
|
import { accountingApi } from "@/lib/accounting-api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { LoadingState, ErrorState } from "@/components/ds";
|
import { LoadingState, ErrorState } from "@/components/ds";
|
||||||
|
|
||||||
|
/** Prefetch shared accounting lookups so sidebar hops skip cold API waits. */
|
||||||
|
function useWarmAccountingCache(tenantId: string | null) {
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["accounting", tenantId, "accounts"],
|
||||||
|
queryFn: () => accountingApi.accounts.list(tenantId!),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["accounting", tenantId, "fiscal-periods"],
|
||||||
|
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["accounting", tenantId, "posting-policy"],
|
||||||
|
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function AccountingGate({ children }: { children: React.ReactNode }) {
|
function AccountingGate({ children }: { children: React.ReactNode }) {
|
||||||
const { me, loading, error, reload } = useMe();
|
const { me, loading, error, reload } = useMe();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const tenantId = me?.current_tenant_id ?? null;
|
||||||
|
useWarmAccountingCache(tenantId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (me?.onboarding_required) router.replace("/onboarding");
|
if (me?.onboarding_required) router.replace("/onboarding");
|
||||||
}, [me, loading, router]);
|
}, [me, loading, router]);
|
||||||
|
|
||||||
if (loading) return <LoadingState label="در حال آمادهسازی ماژول حسابداری…" />;
|
// Keep shell mounted once session is known — never blank the sidebar on soft nav.
|
||||||
if (error) return <ErrorState message={error} onRetry={reload} />;
|
if (loading && !me) return <LoadingState label="در حال آمادهسازی ماژول حسابداری…" />;
|
||||||
if (!me?.current_tenant_id) {
|
if (error && !me) return <ErrorState message={error} onRetry={reload} />;
|
||||||
|
if (!loading && !me?.current_tenant_id) {
|
||||||
return (
|
return (
|
||||||
<ErrorState message="برای استفاده از حسابداری باید یک workspace فعال داشته باشید." />
|
<ErrorState message="برای استفاده از حسابداری باید یک workspace فعال داشته باشید." />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <AccountingShell>{children}</AccountingShell>;
|
if (!me?.current_tenant_id) {
|
||||||
|
return <LoadingState label="در حال آمادهسازی ماژول حسابداری…" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RouteProgress />
|
||||||
|
<AccountingShell>{children}</AccountingShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountingLayout({ children }: { children: React.ReactNode }) {
|
export default function AccountingLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
60
frontend/components/RouteProgress.tsx
Normal file
60
frontend/components/RouteProgress.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState } from "react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
function RouteProgressInner() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = (e.target as HTMLElement | null)?.closest?.("a[href]");
|
||||||
|
if (!el) return;
|
||||||
|
const href = el.getAttribute("href");
|
||||||
|
if (!href || href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("#")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!href.startsWith("/")) return;
|
||||||
|
const nextPath = href.split("?")[0];
|
||||||
|
if (nextPath === window.location.pathname && href === `${window.location.pathname}${window.location.search}`) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActive(true);
|
||||||
|
};
|
||||||
|
document.addEventListener("click", onClick, true);
|
||||||
|
return () => document.removeEventListener("click", onClick, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const t = window.setTimeout(() => setActive(false), 350);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [pathname, searchParams, active]);
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 top-0 z-[200] h-[3px] overflow-hidden" aria-hidden>
|
||||||
|
<div
|
||||||
|
className="h-full w-full bg-primary"
|
||||||
|
style={{
|
||||||
|
animation: "ty-route-progress 0.85s ease-in-out infinite",
|
||||||
|
transformOrigin: "right center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteProgress() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RouteProgressInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { ChevronDown, Moon, Sun, ChevronRight, Plus, Lock } from "lucide-react";
|
import { ChevronDown, Moon, Sun, ChevronRight, Plus, Lock } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useColorMode } from "@/components/providers/ColorModeProvider";
|
import { useColorMode } from "@/components/providers/ColorModeProvider";
|
||||||
@ -15,6 +15,34 @@ function isActive(pathname: string, href: string) {
|
|||||||
return pathname === base || pathname.startsWith(`${base}/`);
|
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 }) {
|
export function AccountingShell({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { mode, toggle } = useColorMode();
|
const { mode, toggle } = useColorMode();
|
||||||
@ -58,7 +86,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (!hasChildren && group.href) {
|
if (!hasChildren && group.href) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<NavLink
|
||||||
key={group.id}
|
key={group.id}
|
||||||
href={group.href}
|
href={group.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -70,7 +98,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
{group.label}
|
{group.label}
|
||||||
</Link>
|
</NavLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +128,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
|||||||
const active = isActive(pathname, item.href);
|
const active = isActive(pathname, item.href);
|
||||||
const blocked = item.blocked || group.blocked;
|
const blocked = item.blocked || group.blocked;
|
||||||
return (
|
return (
|
||||||
<Link
|
<NavLink
|
||||||
key={item.href + item.label}
|
key={item.href + item.label}
|
||||||
href={blocked ? "/accounting/ai" : item.href}
|
href={blocked ? "/accounting/ai" : item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -113,7 +141,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
{blocked ? <Lock className="h-3 w-3" /> : null}
|
{blocked ? <Lock className="h-3 w-3" /> : null}
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -123,13 +151,13 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Link
|
<NavLink
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="mt-2 flex items-center gap-2 px-3 text-xs text-[var(--muted)] hover:text-secondary"
|
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" />
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
بازگشت به workspace
|
بازگشت به workspace
|
||||||
</Link>
|
</NavLink>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="relative min-w-0 flex-1 pb-28 lg:pb-20">
|
<div className="relative min-w-0 flex-1 pb-28 lg:pb-20">
|
||||||
@ -139,7 +167,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
|||||||
? [{ href: g.href, label: g.label }]
|
? [{ href: g.href, label: g.label }]
|
||||||
: (g.items ?? []).slice(0, 1).map((i) => ({ href: i.href, label: g.label }))
|
: (g.items ?? []).slice(0, 1).map((i) => ({ href: i.href, label: g.label }))
|
||||||
).map((item) => (
|
).map((item) => (
|
||||||
<Link
|
<NavLink
|
||||||
key={item.href + item.label}
|
key={item.href + item.label}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -150,17 +178,16 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">{children}</div>
|
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">{children}</div>
|
||||||
|
|
||||||
{/* Quick access bar */}
|
|
||||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--border)] bg-[var(--surface)]/95 backdrop-blur">
|
<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">
|
<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) => (
|
{ACCOUNTING_QUICK_BAR.map((item) => (
|
||||||
<Link
|
<NavLink
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -169,16 +196,15 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</Link>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<NavLink
|
||||||
href="/accounting/vouchers/new"
|
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"
|
className="absolute bottom-3 left-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-white shadow-lg"
|
||||||
aria-label="سند جدید"
|
|
||||||
>
|
>
|
||||||
<Plus className="h-6 w-6" />
|
<Plus className="h-6 w-6" aria-label="سند جدید" />
|
||||||
</Link>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,9 +10,11 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 60_000,
|
||||||
|
gcTime: 10 * 60_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,37 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { api, type MeResponse } from "@/lib/api";
|
import { api, type MeResponse } from "@/lib/api";
|
||||||
import { getStoredToken } from "@/lib/auth";
|
import { getStoredToken } from "@/lib/auth";
|
||||||
|
|
||||||
/** بارگذاری وضعیت کاربر جاری (عضویتها و نیاز به onboarding) از `/api/v1/me`. */
|
export const ME_QUERY_KEY = ["me"] as const;
|
||||||
export function useMe() {
|
|
||||||
const [me, setMe] = useState<MeResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
/** Shared user/tenant session — cached so sidebar navigations do not refetch /me. */
|
||||||
|
export function useMe() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const hasToken = typeof window !== "undefined" ? !!getStoredToken() : false;
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ME_QUERY_KEY,
|
||||||
|
queryFn: () => api.me.get(),
|
||||||
|
enabled: hasToken,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
gcTime: 30 * 60_000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
if (!getStoredToken()) {
|
if (!getStoredToken()) {
|
||||||
setMe(null);
|
qc.setQueryData<MeResponse | null>(ME_QUERY_KEY, null);
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
await q.refetch();
|
||||||
setError("");
|
}, [qc, q]);
|
||||||
try {
|
|
||||||
const data = await api.me.get();
|
|
||||||
setMe(data);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "خطا در دریافت اطلاعات کاربر");
|
|
||||||
setMe(null);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return {
|
||||||
load();
|
me: q.data ?? null,
|
||||||
}, [load]);
|
/** True only on the first network load (cached navigations stay instant). */
|
||||||
|
loading: hasToken ? q.isLoading : false,
|
||||||
return { me, loading, error, reload: load };
|
error: q.error instanceof Error ? q.error.message : q.error ? String(q.error) : "",
|
||||||
|
reload,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,23 @@
|
|||||||
--radius-sm: 0.5rem;
|
--radius-sm: 0.5rem;
|
||||||
--radius-md: 0.75rem;
|
--radius-md: 0.75rem;
|
||||||
--radius-lg: 1rem;
|
--radius-lg: 1rem;
|
||||||
|
|
||||||
|
/* Healthcare module accents */
|
||||||
|
--health-accent: #0d9488;
|
||||||
|
--health-accent-soft: rgba(13, 148, 136, 0.12);
|
||||||
|
--health-calm: #14b8a6;
|
||||||
|
--health-success: #059669;
|
||||||
|
--health-warning: #d97706;
|
||||||
|
--health-danger: #dc2626;
|
||||||
|
|
||||||
|
/* Beauty module accents — warm luxury palette */
|
||||||
|
--beauty-accent: #b76e79;
|
||||||
|
--beauty-accent-soft: rgba(183, 110, 121, 0.14);
|
||||||
|
--beauty-warm: #c9a87c;
|
||||||
|
--beauty-calm: #d4a5a5;
|
||||||
|
--beauty-success: #059669;
|
||||||
|
--beauty-warning: #d97706;
|
||||||
|
--beauty-danger: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@ -41,6 +58,21 @@
|
|||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
|
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.55);
|
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
|
--health-accent: #2dd4bf;
|
||||||
|
--health-accent-soft: rgba(45, 212, 191, 0.15);
|
||||||
|
--health-calm: #5eead4;
|
||||||
|
--health-success: #34d399;
|
||||||
|
--health-warning: #fbbf24;
|
||||||
|
--health-danger: #f87171;
|
||||||
|
|
||||||
|
--beauty-accent: #e8b4bc;
|
||||||
|
--beauty-accent-soft: rgba(232, 180, 188, 0.18);
|
||||||
|
--beauty-warm: #d4b896;
|
||||||
|
--beauty-calm: #e8c4c4;
|
||||||
|
--beauty-success: #34d399;
|
||||||
|
--beauty-warning: #fbbf24;
|
||||||
|
--beauty-danger: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -56,3 +88,16 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes ty-route-progress {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(0%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user