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 { 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 <LoadingState label="در حال آمادهسازی ماژول حسابداری…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={reload} />;
|
||||
if (!me?.current_tenant_id) {
|
||||
// Keep shell mounted once session is known — never blank the sidebar on soft nav.
|
||||
if (loading && !me) return <LoadingState label="در حال آمادهسازی ماژول حسابداری…" />;
|
||||
if (error && !me) return <ErrorState message={error} onRetry={reload} />;
|
||||
if (!loading && !me?.current_tenant_id) {
|
||||
return (
|
||||
<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 }) {
|
||||
|
||||
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 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 (
|
||||
<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();
|
||||
@ -58,7 +86,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (!hasChildren && group.href) {
|
||||
return (
|
||||
<Link
|
||||
<NavLink
|
||||
key={group.id}
|
||||
href={group.href}
|
||||
className={cn(
|
||||
@ -70,7 +98,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{group.label}
|
||||
</Link>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@ -100,7 +128,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
||||
const active = isActive(pathname, item.href);
|
||||
const blocked = item.blocked || group.blocked;
|
||||
return (
|
||||
<Link
|
||||
<NavLink
|
||||
key={item.href + item.label}
|
||||
href={blocked ? "/accounting/ai" : item.href}
|
||||
className={cn(
|
||||
@ -113,7 +141,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
{blocked ? <Lock className="h-3 w-3" /> : null}
|
||||
{item.label}
|
||||
</Link>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -123,13 +151,13 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Link
|
||||
<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
|
||||
</Link>
|
||||
</NavLink>
|
||||
</aside>
|
||||
|
||||
<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 }]
|
||||
: (g.items ?? []).slice(0, 1).map((i) => ({ href: i.href, label: g.label }))
|
||||
).map((item) => (
|
||||
<Link
|
||||
<NavLink
|
||||
key={item.href + item.label}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
@ -150,17 +178,16 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</NavLink>
|
||||
))}
|
||||
</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="mx-auto flex max-w-5xl items-stretch justify-around gap-1 px-2 py-2 pe-16">
|
||||
{ACCOUNTING_QUICK_BAR.map((item) => (
|
||||
<Link
|
||||
<NavLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
@ -169,16 +196,15 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
<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"
|
||||
aria-label="سند جدید"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</Link>
|
||||
<Plus className="h-6 w-6" aria-label="سند جدید" />
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -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<MeResponse | null>(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<MeResponse | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user