diff --git a/assets/admin/components/layout/ProfileMenu.test.tsx b/assets/admin/components/layout/ProfileMenu.test.tsx new file mode 100644 index 00000000..ec3e97f5 --- /dev/null +++ b/assets/admin/components/layout/ProfileMenu.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; + +const navigateMock = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => navigateMock }; +}); + +import ProfileMenu, { settingsPath } from './ProfileMenu'; +import { useAuthStore } from '../../stores/authStore'; +import { useUiStore } from '../../stores/uiStore'; + +const logoutMock = vi.fn(); +const togglePanelMock = vi.fn(); + +function setAuth(partial: Record) { + useAuthStore.setState({ + userName: 'دکتر رضایی', + primaryRole: 'doctor', + context: { name: 'کلینیک نور' }, + logout: logoutMock, + ...partial, + } as any); +} + +beforeEach(() => { + navigateMock.mockClear(); + logoutMock.mockClear(); + togglePanelMock.mockClear(); + useUiStore.setState({ toggleSettingsPanel: togglePanelMock } as any); + setAuth({}); +}); + +describe('ProfileMenu — نمایش نام و تخصص', () => { + it('نام کاربر و نام محیط کاری (context) را نشان می‌دهد', () => { + renderWithProviders(); + expect(screen.getByText('دکتر رضایی')).toBeInTheDocument(); + expect(screen.getByText('کلینیک نور')).toBeInTheDocument(); + }); + + it('وقتی context نیست، برچسب نقش را جایگزین می‌کند', () => { + setAuth({ context: null }); + renderWithProviders(); + expect(screen.getByText('پزشک')).toBeInTheDocument(); + }); + + it('حالت خالی: نام پیش‌فرض «کاربر» و حرف اول «ک»', () => { + setAuth({ userName: null, context: null, primaryRole: null }); + renderWithProviders(); + expect(screen.getByText('کاربر')).toBeInTheDocument(); + // حرف اولِ آواتار + expect(screen.getByTitle('کاربر').textContent).toContain('ک'); + }); +}); + +describe('ProfileMenu — باز/بستن و آیتم‌ها', () => { + it('منو ابتدا بسته است و با کلیک باز می‌شود', () => { + renderWithProviders(); + expect(screen.queryByText('خروج')).toBeNull(); + + fireEvent.click(screen.getByTitle('دکتر رضایی')); + + ['پروفایل من', 'پرداخت‌ها', 'تنظیمات', 'شخصی‌سازی', 'خروج'].forEach((label) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); +}); + +describe('ProfileMenu — لینک‌های نقش‌محور', () => { + it('پزشک: پروفایل → /admin/profile، پرداخت‌ها → /admin/my-payments', () => { + setAuth({ primaryRole: 'doctor' }); + renderWithProviders(); + fireEvent.click(screen.getByTitle('دکتر رضایی')); + + fireEvent.click(screen.getByText('پرداخت‌ها')); + expect(navigateMock).toHaveBeenCalledWith('/admin/my-payments'); + + fireEvent.click(screen.getByTitle('دکتر رضایی')); + fireEvent.click(screen.getByText('پروفایل من')); + expect(navigateMock).toHaveBeenCalledWith('/admin/profile'); + }); + + it('مدیر: پرداخت‌ها → /admin/payments، تنظیمات → /admin/settings', () => { + setAuth({ primaryRole: 'admin', userName: 'ادمین' }); + renderWithProviders(); + fireEvent.click(screen.getByTitle('ادمین')); + + fireEvent.click(screen.getByText('پرداخت‌ها')); + expect(navigateMock).toHaveBeenCalledWith('/admin/payments'); + + fireEvent.click(screen.getByTitle('ادمین')); + fireEvent.click(screen.getByText('تنظیمات')); + expect(navigateMock).toHaveBeenCalledWith('/admin/settings'); + }); +}); + +describe('ProfileMenu — اکشن‌ها', () => { + it('شخصی‌سازی پنل تنظیمات را باز می‌کند', () => { + renderWithProviders(); + fireEvent.click(screen.getByTitle('دکتر رضایی')); + fireEvent.click(screen.getByText('شخصی‌سازی')); + expect(togglePanelMock).toHaveBeenCalledTimes(1); + }); + + it('خروج، logout را صدا می‌زند و به صفحه ورود می‌رود', () => { + renderWithProviders(); + fireEvent.click(screen.getByTitle('دکتر رضایی')); + fireEvent.click(screen.getByText('خروج')); + expect(logoutMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('/admin/login'); + }); +}); + +describe('settingsPath — نگاشت نقش', () => { + it('نقش‌ها را به مسیر درست نگاشت می‌کند', () => { + expect(settingsPath('admin')).toBe('/admin/settings'); + expect(settingsPath('doctor')).toBe('/admin/settings-menu'); + expect(settingsPath('clinic')).toBe('/admin/settings-menu'); + expect(settingsPath('secretary')).toBe('/admin/account-settings'); + expect(settingsPath(null)).toBe('/admin/account-settings'); + }); +}); diff --git a/assets/admin/components/layout/ProfileMenu.tsx b/assets/admin/components/layout/ProfileMenu.tsx new file mode 100644 index 00000000..bfae7a30 --- /dev/null +++ b/assets/admin/components/layout/ProfileMenu.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { + ChevronDownIcon, + UserCircleIcon, + CreditCardIcon, + Cog6ToothIcon, + SwatchIcon, + ArrowLeftOnRectangleIcon, +} from '@heroicons/react/24/outline'; +import { useAuthStore } from '../../stores/authStore'; +import { useUiStore } from '../../stores/uiStore'; + +type Role = string | null; + +// نگاشت نقش → برچسب فارسی (هم‌تراز با Sidebar). +const ROLE_LABELS: Record = { + admin: 'مدیر کل', + clinic: 'مالک کلینیک', + doctor: 'پزشک', + secretary: 'منشی', + representation: 'نماینده', + user: 'کاربر', +}; + +const HUES = [256, 205, 162, 295, 272]; +function avatarBg(name: string): string { + const hue = HUES[(name.charCodeAt(0) ?? 0) % HUES.length]; + return `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`; +} + +// مسیرهای نقش‌محور — معادلِ لینک‌های MenuProfile در clinic-pro-tauri. +function profilePath(role: Role): string { + if (role === 'doctor') return '/admin/profile'; + if (role === 'representation') return '/admin/representation-profile'; + return '/admin/account-settings'; +} +function paymentsPath(role: Role): string { + return role === 'admin' ? '/admin/payments' : '/admin/my-payments'; +} +export function settingsPath(role: Role): string { + if (role === 'admin') return '/admin/settings'; + if (role === 'doctor' || role === 'clinic') return '/admin/settings-menu'; + return '/admin/account-settings'; +} + +export default function ProfileMenu() { + const navigate = useNavigate(); + const userName = useAuthStore((s) => s.userName); + const primaryRole = useAuthStore((s) => s.primaryRole); + const context = useAuthStore((s) => s.context); + const logout = useAuthStore((s) => s.logout); + const toggleSettingsPanel = useUiStore((s) => s.toggleSettingsPanel); + + const [open, setOpen] = useState(false); + const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); + const btnRef = useRef(null); + const menuRef = useRef(null); + + const name = userName ?? 'کاربر'; + const initial = name.charAt(0).toUpperCase(); + // زیرعنوان: نام محیط کاری (context) وگرنه برچسب نقش — معادلِ «expertise» در tauri. + const subtitle = context?.name ?? ROLE_LABELS[primaryRole ?? ''] ?? primaryRole ?? ''; + + useEffect(() => { + if (!open) return; + function handler(e: MouseEvent) { + const target = e.target as Node; + if (!btnRef.current?.contains(target) && !menuRef.current?.contains(target)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + useEffect(() => { + if (!open) return; + function reposition() { + if (btnRef.current) { + const rect = btnRef.current.getBoundingClientRect(); + setMenuPos({ top: rect.bottom + 6, right: window.innerWidth - rect.right }); + } + } + window.addEventListener('scroll', reposition, true); + window.addEventListener('resize', reposition); + return () => { + window.removeEventListener('scroll', reposition, true); + window.removeEventListener('resize', reposition); + }; + }, [open]); + + function handleToggle() { + if (!open && btnRef.current) { + const rect = btnRef.current.getBoundingClientRect(); + setMenuPos({ top: rect.bottom + 6, right: window.innerWidth - rect.right }); + } + setOpen((o) => !o); + } + + function go(path: string) { + setOpen(false); + navigate(path); + } + + function handleLogout() { + setOpen(false); + logout(); + navigate('/admin/login'); + } + + const items = [ + { label: 'پروفایل من', icon: UserCircleIcon, onClick: () => go(profilePath(primaryRole)) }, + { label: 'پرداخت‌ها', icon: CreditCardIcon, onClick: () => go(paymentsPath(primaryRole)) }, + { label: 'تنظیمات', icon: Cog6ToothIcon, onClick: () => go(settingsPath(primaryRole)) }, + { label: 'شخصی‌سازی', icon: SwatchIcon, onClick: () => { setOpen(false); toggleSettingsPanel(); } }, + ]; + + return ( + <> + + + {open && menuPos && ReactDOM.createPortal( +
+ {items.map((item) => ( + + ))} + +
, + document.body, + )} + + ); +} diff --git a/assets/admin/components/layout/Topbar.test.tsx b/assets/admin/components/layout/Topbar.test.tsx new file mode 100644 index 00000000..0bf5ab7f --- /dev/null +++ b/assets/admin/components/layout/Topbar.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; + +const navigateMock = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => navigateMock }; +}); + +import Topbar from './Topbar'; +import { useAuthStore } from '../../stores/authStore'; + +beforeEach(() => { + navigateMock.mockClear(); + useAuthStore.setState({ + userName: 'ادمین', primaryRole: 'admin', context: null, logout: vi.fn(), + } as any); +}); + +describe('Topbar — هدر مطابق clinic-pro-tauri', () => { + it('placeholder سرچ «جستجو» است و ⌘K ندارد', () => { + renderWithProviders(); + expect(screen.getByPlaceholderText('جستجو')).toBeInTheDocument(); + expect(screen.queryByText('⌘K')).toBeNull(); + }); + + it('دکمه تنظیمات (چرخ‌دنده) به صفحه تنظیمات نقش می‌رود', () => { + renderWithProviders(); + fireEvent.click(screen.getByTitle('تنظیمات')); + expect(navigateMock).toHaveBeenCalledWith('/admin/settings'); // نقش admin + }); + + it('منوی پروفایل با نام کاربر رندر می‌شود', () => { + renderWithProviders(); + expect(screen.getByText('ادمین')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/layout/Topbar.tsx b/assets/admin/components/layout/Topbar.tsx index e2c45a3e..f95e522d 100644 --- a/assets/admin/components/layout/Topbar.tsx +++ b/assets/admin/components/layout/Topbar.tsx @@ -1,12 +1,17 @@ import React from 'react'; +import { useNavigate } from 'react-router-dom'; import { BellIcon, SunIcon, MoonIcon, Bars3Icon, Cog6ToothIcon, MagnifyingGlassIcon, XMarkIcon, CheckIcon, } from '@heroicons/react/24/outline'; import { useUiStore, HUES, type BrandHue } from '../../stores/uiStore'; +import { useAuthStore } from '../../stores/authStore'; import Portal from '../ui/Portal'; +import ProfileMenu, { settingsPath } from './ProfileMenu'; export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () => void }) { + const navigate = useNavigate(); + const primaryRole = useAuthStore((s) => s.primaryRole); const toggleSidebar = useUiStore((s) => s.toggleSidebar); const darkMode = useUiStore((s) => s.darkMode); const toggleDarkMode = useUiStore((s) => s.toggleDarkMode); @@ -30,15 +35,15 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () => - {/* Search bar */} + {/* Search bar — placeholder «جستجو» مطابق clinic-pro-tauri */}
- - ⌘K +
+ {/* ترتیب آیکون‌ها مطابق header در clinic-pro-tauri: تم → تنظیمات → اعلان */} {/* Dark mode */} + {/* Settings — به صفحه تنظیمات می‌رود (مثل tauri) */} + + {/* Bell */} - {/* Settings */} - - - {/* Avatar */} -
- A -
+ {/* Profile menu — آواتار + نام + تخصص + منوی کشویی مطابق tauri */} + {/* Settings Panel */} diff --git a/assets/admin/styles.css b/assets/admin/styles.css index d8ffd5cc..c8dd3e74 100644 --- a/assets/admin/styles.css +++ b/assets/admin/styles.css @@ -457,6 +457,43 @@ body { .topbar-crumbs { display: flex; align-items: center; gap: 8px; color: var(--text-3); font-size: 13px; } .topbar-crumbs b { color: var(--text); } +/* ── Profile menu (topbar) — معادلِ MenuProfile در clinic-pro-tauri ── */ +.profile-trigger { + display: flex; align-items: center; gap: 8px; flex-shrink: 0; + padding: 4px 6px; border-radius: var(--r-sm); + background: none; border: 1px solid transparent; cursor: pointer; + transition: .15s; color: var(--text); +} +.profile-trigger:hover { background: var(--surface-2); border-color: var(--border); } +.profile-meta { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.35; overflow: hidden; } +.profile-meta b { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; } +.profile-meta span { font-size: 11.5px; color: var(--text-3); white-space: nowrap; } +.profile-caret { color: var(--text-3); } +/* موبایل: فقط آواتار (مثل md:hidden در tauri) */ +@media (max-width: 640px) { + .profile-meta, .profile-caret { display: none; } +} + +.profile-menu { + z-index: 9000; min-width: 190px; overflow: hidden; + padding: 8px; display: flex; flex-direction: column; gap: 2px; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r); box-shadow: var(--shadow-lg); +} +.profile-item { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 8px 10px; border-radius: var(--r-sm); + font-size: 13px; font-weight: 500; color: var(--text-2); + background: transparent; border: none; cursor: pointer; text-align: right; + transition: .12s; +} +.profile-item:hover { background: var(--surface-2); color: var(--text); } +.profile-item.danger { + margin-top: 6px; justify-content: center; + color: var(--on-primary); background: var(--danger); font-weight: 600; +} +.profile-item.danger:hover { background: var(--danger); filter: brightness(0.94); color: var(--on-primary); } + /* در dark مرز سایدبار/هدر محو است — مثل clinic-pro-tauri */ [data-theme="dark"] .sidebar, [data-theme="dark"] .sidebar-brand,