feat: port tauri header top-menu to admin topbar

Rebuild admin Topbar to match clinic-pro-tauri header:
- Icon order: theme -> settings -> bell (was theme -> bell -> settings)
- Settings gear now navigates to the role-based settings page
  instead of opening the personalization panel
- Search placeholder changed to «جستجو», dropped the ⌘K hint
- Add ProfileMenu: avatar + user name + context/role subtitle + caret,
  with a dropdown (profile / payments / settings / personalization / logout).
  Links are role-aware; personalization keeps the existing settings panel.

Frontend-only, no API changes. Adds ProfileMenu.test + Topbar.test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-15 17:44:47 +03:30
co-authored by Claude Opus 4.8
parent 952fcf3634
commit 1aa6794786
5 changed files with 368 additions and 19 deletions
@@ -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<typeof import('react-router-dom')>('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<string, unknown>) {
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(<ProfileMenu />);
expect(screen.getByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.getByText('کلینیک نور')).toBeInTheDocument();
});
it('وقتی context نیست، برچسب نقش را جایگزین می‌کند', () => {
setAuth({ context: null });
renderWithProviders(<ProfileMenu />);
expect(screen.getByText('پزشک')).toBeInTheDocument();
});
it('حالت خالی: نام پیش‌فرض «کاربر» و حرف اول «ک»', () => {
setAuth({ userName: null, context: null, primaryRole: null });
renderWithProviders(<ProfileMenu />);
expect(screen.getByText('کاربر')).toBeInTheDocument();
// حرف اولِ آواتار
expect(screen.getByTitle('کاربر').textContent).toContain('ک');
});
});
describe('ProfileMenu — باز/بستن و آیتم‌ها', () => {
it('منو ابتدا بسته است و با کلیک باز می‌شود', () => {
renderWithProviders(<ProfileMenu />);
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(<ProfileMenu />);
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(<ProfileMenu />);
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(<ProfileMenu />);
fireEvent.click(screen.getByTitle('دکتر رضایی'));
fireEvent.click(screen.getByText('شخصی‌سازی'));
expect(togglePanelMock).toHaveBeenCalledTimes(1);
});
it('خروج، logout را صدا می‌زند و به صفحه ورود می‌رود', () => {
renderWithProviders(<ProfileMenu />);
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');
});
});
@@ -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<string, string> = {
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<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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 (
<>
<button ref={btnRef} className="profile-trigger" onClick={handleToggle} title={name}>
<div className="avatar sm" style={{ background: avatarBg(name), flexShrink: 0 }}>
{initial}
</div>
<div className="profile-meta">
<b>{name}</b>
{subtitle && <span>{subtitle}</span>}
</div>
<ChevronDownIcon className="profile-caret" style={{ width: 15, height: 15, flexShrink: 0 }} />
</button>
{open && menuPos && ReactDOM.createPortal(
<div
ref={menuRef}
className="profile-menu"
style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
>
{items.map((item) => (
<button key={item.label} className="profile-item" onClick={item.onClick}>
<item.icon style={{ width: 17, height: 17, flexShrink: 0 }} />
{item.label}
</button>
))}
<button className="profile-item danger" onClick={handleLogout}>
<ArrowLeftOnRectangleIcon style={{ width: 17, height: 17, flexShrink: 0 }} />
خروج
</button>
</div>,
document.body,
)}
</>
);
}
@@ -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<typeof import('react-router-dom')>('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(<Topbar />);
expect(screen.getByPlaceholderText('جستجو')).toBeInTheDocument();
expect(screen.queryByText('⌘K')).toBeNull();
});
it('دکمه تنظیمات (چرخ‌دنده) به صفحه تنظیمات نقش می‌رود', () => {
renderWithProviders(<Topbar />);
fireEvent.click(screen.getByTitle('تنظیمات'));
expect(navigateMock).toHaveBeenCalledWith('/admin/settings'); // نقش admin
});
it('منوی پروفایل با نام کاربر رندر می‌شود', () => {
renderWithProviders(<Topbar />);
expect(screen.getByText('ادمین')).toBeInTheDocument();
});
});
+15 -19
View File
@@ -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?: () =>
<Bars3Icon style={{ width: 20, height: 20 }} />
</button>
{/* Search bar */}
{/* Search bar — placeholder «جستجو» مطابق clinic-pro-tauri */}
<div className="topbar-search">
<MagnifyingGlassIcon style={{ width: 17, height: 17, flexShrink: 0 }} />
<input placeholder="جستجوی بیمار، پزشک، نوبت..." readOnly />
<kbd>K</kbd>
<input placeholder="جستجو" readOnly />
</div>
<div style={{ flex: 1 }} />
{/* ترتیب آیکون‌ها مطابق header در clinic-pro-tauri: تم → تنظیمات → اعلان */}
{/* Dark mode */}
<button className="icon-btn" onClick={toggleDarkMode} title={darkMode ? 'حالت روشن' : 'حالت تاریک'}>
{darkMode
@@ -46,28 +51,19 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () =>
: <MoonIcon style={{ width: 18, height: 18 }} />}
</button>
{/* Settings — به صفحه تنظیمات می‌رود (مثل tauri) */}
<button className="icon-btn" onClick={() => navigate(settingsPath(primaryRole))} title="تنظیمات">
<Cog6ToothIcon style={{ width: 18, height: 18 }} />
</button>
{/* Bell */}
<button className="icon-btn" title="اعلان‌ها" style={{ position: 'relative' }}>
<BellIcon style={{ width: 18, height: 18 }} />
<span className="dot" />
</button>
{/* Settings */}
<button className="icon-btn" onClick={toggleSettingsPanel} title="شخصی‌سازی">
<Cog6ToothIcon style={{ width: 18, height: 18 }} />
</button>
{/* Avatar */}
<div
className="avatar sm"
style={{
background: 'linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))',
cursor: 'pointer',
flexShrink: 0,
}}
>
A
</div>
{/* Profile menu — آواتار + نام + تخصص + منوی کشویی مطابق tauri */}
<ProfileMenu />
</header>
{/* Settings Panel */}
+37
View File
@@ -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,