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%);
+ }
+}
+