From 86b37c1de912dd86411185dfe4e5cf0fc6c8f302 Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Sun, 26 Jul 2026 18:31:24 +0330 Subject: [PATCH] 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 --- frontend/app/accounting/layout.tsx | 45 ++++++++++++-- frontend/components/RouteProgress.tsx | 60 +++++++++++++++++++ .../components/accounting/AccountingShell.tsx | 58 +++++++++++++----- .../components/providers/AppProviders.tsx | 4 +- frontend/hooks/useMe.ts | 56 +++++++++-------- frontend/styles/globals.css | 45 ++++++++++++++ 6 files changed, 221 insertions(+), 47 deletions(-) create mode 100644 frontend/components/RouteProgress.tsx diff --git a/frontend/app/accounting/layout.tsx b/frontend/app/accounting/layout.tsx index d02e7b0..d938ad9 100644 --- a/frontend/app/accounting/layout.tsx +++ b/frontend/app/accounting/layout.tsx @@ -2,28 +2,65 @@ import { AuthGuard } from "@/components/AuthGuard"; import { AccountingShell } from "@/components/accounting/AccountingShell"; +import { RouteProgress } from "@/components/RouteProgress"; import { useMe } from "@/hooks/useMe"; +import { accountingApi } from "@/lib/accounting-api"; +import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; 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 }) { const { me, loading, error, reload } = useMe(); const router = useRouter(); + const tenantId = me?.current_tenant_id ?? null; + useWarmAccountingCache(tenantId); 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) { + // Keep shell mounted once session is known — never blank the sidebar on soft nav. + if (loading && !me) return ; + if (error && !me) return ; + if (!loading && !me?.current_tenant_id) { return ( ); } - return {children}; + if (!me?.current_tenant_id) { + return ; + } + + return ( + <> + + {children} + + ); } export default function AccountingLayout({ children }: { children: React.ReactNode }) { diff --git a/frontend/components/RouteProgress.tsx b/frontend/components/RouteProgress.tsx new file mode 100644 index 0000000..76b60c2 --- /dev/null +++ b/frontend/components/RouteProgress.tsx @@ -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 ( +
+
+
+ ); +} + +export function RouteProgress() { + return ( + + + + ); +} diff --git a/frontend/components/accounting/AccountingShell.tsx b/frontend/components/accounting/AccountingShell.tsx index 992d370..86d350d 100644 --- a/frontend/components/accounting/AccountingShell.tsx +++ b/frontend/components/accounting/AccountingShell.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; 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 { cn } from "@/lib/utils"; import { useColorMode } from "@/components/providers/ColorModeProvider"; @@ -15,6 +15,34 @@ function isActive(pathname: string, href: string) { return pathname === base || pathname.startsWith(`${base}/`); } +function NavLink({ + href, + className, + children, +}: { + href: string; + className?: string; + children: React.ReactNode; +}) { + const router = useRouter(); + return ( + { + try { + router.prefetch(href); + } catch { + /* ignore */ + } + }} + > + {children} + + ); +} + export function AccountingShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const { mode, toggle } = useColorMode(); @@ -58,7 +86,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) { if (!hasChildren && group.href) { return ( - {group.label} - + ); } @@ -100,7 +128,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) { const active = isActive(pathname, item.href); const blocked = item.blocked || group.blocked; return ( - {blocked ? : null} {item.label} - + ); })}
@@ -123,13 +151,13 @@ export function AccountingShell({ children }: { children: React.ReactNode }) { })} - بازگشت به workspace - +
@@ -139,7 +167,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) { ? [{ href: g.href, label: g.label }] : (g.items ?? []).slice(0, 1).map((i) => ({ href: i.href, label: g.label })) ).map((item) => ( - {item.label} - + ))}
{children}
- {/* Quick access bar */}
{ACCOUNTING_QUICK_BAR.map((item) => ( - {item.label} - + ))}
- - - + +
diff --git a/frontend/components/providers/AppProviders.tsx b/frontend/components/providers/AppProviders.tsx index ce70d82..af4139f 100644 --- a/frontend/components/providers/AppProviders.tsx +++ b/frontend/components/providers/AppProviders.tsx @@ -10,9 +10,11 @@ export function AppProviders({ children }: { children: ReactNode }) { new QueryClient({ defaultOptions: { queries: { - staleTime: 30_000, + staleTime: 60_000, + gcTime: 10 * 60_000, retry: 1, refetchOnWindowFocus: false, + refetchOnMount: false, }, }, }) diff --git a/frontend/hooks/useMe.ts b/frontend/hooks/useMe.ts index 60cc0c8..d79e05f 100644 --- a/frontend/hooks/useMe.ts +++ b/frontend/hooks/useMe.ts @@ -1,37 +1,41 @@ "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 { getStoredToken } from "@/lib/auth"; -/** بارگذاری وضعیت کاربر جاری (عضویت‌ها و نیاز به onboarding) از `/api/v1/me`. */ -export function useMe() { - const [me, setMe] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); +export const ME_QUERY_KEY = ["me"] as const; - 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()) { - setMe(null); - setLoading(false); + qc.setQueryData(ME_QUERY_KEY, null); return; } - setLoading(true); - setError(""); - try { - const data = await api.me.get(); - setMe(data); - } catch (e) { - setError(e instanceof Error ? e.message : "خطا در دریافت اطلاعات کاربر"); - setMe(null); - } finally { - setLoading(false); - } - }, []); + await q.refetch(); + }, [qc, q]); - useEffect(() => { - load(); - }, [load]); - - return { me, loading, error, reload: load }; + return { + me: q.data ?? null, + /** True only on the first network load (cached navigations stay instant). */ + loading: hasToken ? q.isLoading : false, + error: q.error instanceof Error ? q.error.message : q.error ? String(q.error) : "", + reload, + }; } diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index 97db972..4f3934e 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -24,6 +24,23 @@ --radius-sm: 0.5rem; --radius-md: 0.75rem; --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 { @@ -41,6 +58,21 @@ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45); --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 { @@ -56,3 +88,16 @@ body { overflow: hidden; } } + +@keyframes ty-route-progress { + 0% { + transform: translateX(100%); + } + 50% { + transform: translateX(0%); + } + 100% { + transform: translateX(-100%); + } +} +