diff --git a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx index 96bfcc36..c6a98cec 100644 --- a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx +++ b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx @@ -3,71 +3,38 @@ import { Link } from 'react-router-dom'; import { SearchHeaderP } from '../../pages/subscriptionIcons'; import { useAuthStore } from '../../stores/authStore'; import { usePermissions } from '../../hooks/usePermissions'; +import { menuForRole, type SettingsMenuItem } from './settingsMenu'; /** - * Settings sub-navigation for the subscription page — item list and order copied - * verbatim from clinic-pro-tauri's `PurchaseSubscription.jsx` navItems, minus - * "مدیریت پزشک" (that lives in the main nav / doctor profile, not here). Shown - * ungated to mirror the tauri source. Each entry maps to a real admin route; the - * "تنظیمات" (security) section has no doctor/clinic route yet → disabled. + * سایدبار تنظیمات — همان فهرستی که `SettingsMenuPage` روی موبایل نشان می‌دهد + * (`settingsMenu.ts`)، با دو تفاوتِ عمدی: * - * This list is intentionally separate from SETTINGS_MENU (SettingsLayout) so the - * other settings pages are not affected. + * ۱. «مدیریت پزشک» اینجا نیست؛ جایش منوی اصلی/پروفایل پزشک است. + * ۲. یک آیتم «تنظیمات» غیرفعال ته فهرست است — بخش امنیت هنوز مسیر پزشک/کلینیک ندارد. + * + * قبلاً این فهرست کپی جدا بود و هر آیتم تازه باید دو جا اضافه می‌شد؛ «منابع» و + * «دسته‌بندی‌ها» فقط در موبایل ظاهر شدند و در سایدبار غایب بودند. */ -/** - * `roles`: when set, the item is only shown to those roles (omit = every role). - * `perm`: [resource, action] — منشی فقط با داشتن این مجوز آیتم را می‌بیند. - * `alwaysOpen`: برای منشی همیشه نمایش داده می‌شود (حساب/تنظیمات پایه). - * آیتم‌های owner-only (خرید اشتراک، مدیریت منشی) نه `perm` دارند نه `alwaysOpen` - * → برای منشی پنهان می‌شوند. - */ -type NavItem = { - key: string; - label: string; - to?: string; - roles?: string[]; - perm?: [string, string]; - alwaysOpen?: boolean; -}; +const HIDDEN_KEYS = new Set(['doctor']); -const NAV_ITEMS: NavItem[] = [ - { key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription', perm: ['subscription', 'view'] }, - { key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial', perm: ['payments', 'view'] }, - { key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] }, - { key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, - { key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing', perm: ['insurances', 'view'] }, - { key: 'discounts', label: 'مدیریت تخفیف‌ها', to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] }, - { key: 'tags', label: 'تگ ها', to: '/admin/tags-settings', perm: ['tags', 'view'] }, - { key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet', perm: ['sms', 'view'] }, - { key: 'clinic-doctors', label: 'پزشکان کلینیک', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, - { key: 'secretary', label: 'مدیریت منشی', to: '/admin/my-secretaries' }, - { key: 'staff', label: 'پرسنل', to: '/admin/staff', perm: ['staff', 'view'] }, - { key: 'account', label: 'حساب کاربری', to: '/admin/account-settings', alwaysOpen: true }, - { key: 'security', label: 'تنظیمات', alwaysOpen: true }, -]; +const SECURITY_ITEM: SettingsMenuItem = { + key: 'security', + label: 'تنظیمات', + icon: () => null, + alwaysOpen: true, + disabled: true, +}; export default function PurchaseSubscriptionSidebar({ active }: { active: string }) { const [query, setQuery] = useState(''); const primaryRole = useAuthStore((s) => s.primaryRole); const scope = useAuthStore((s) => s.context?.scope); const { can } = usePermissions(); - // منشی، و پزشکِ عضوِ کلینیک (scope=clinic) هر دو محدود-به-مجوزند؛ پزشکِ مستقل و - // مالک آزادند و با فیلترِ نقشیِ معمول کار می‌کنند. - const permissionRestricted = primaryRole === 'secretary' || (primaryRole === 'doctor' && scope === 'clinic'); const items = useMemo( - () => NAV_ITEMS - // آیتمِ بدونِ perm/alwaysOpen برای کاربرِ محدود پنهان است. برای آیتم‌هایی که - // واریانتِ نقشی دارند (مثلِ «نوبت‌دهی» با doctor vs clinic)، با scope تطبیق داده می‌شود. - .filter((i) => { - if (!permissionRestricted) { - return !i.roles || (primaryRole != null && i.roles.includes(primaryRole)); - } - if (i.alwaysOpen) return true; - if (!i.perm || !can(i.perm[0], i.perm[1])) return false; - if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor'); - return true; - }) - .filter((i) => i.label.includes(query.trim())), + () => [ + ...menuForRole(primaryRole, can, scope).filter((i) => !HIDDEN_KEYS.has(i.key)), + SECURITY_ITEM, + ].filter((i) => i.label.includes(query.trim())), [query, primaryRole, scope, can], ); @@ -120,7 +87,7 @@ export default function PurchaseSubscriptionSidebar({ active }: { active: string transition: 'background .14s', }; const inner = {item.label}; - if (!item.to) { + if (!item.to || item.disabled) { return
{inner}
; } return ( diff --git a/assets/admin/components/layout/SettingsLayout.test.tsx b/assets/admin/components/layout/SettingsLayout.test.tsx index 53ddcc3f..ce81a028 100644 --- a/assets/admin/components/layout/SettingsLayout.test.tsx +++ b/assets/admin/components/layout/SettingsLayout.test.tsx @@ -26,14 +26,28 @@ describe('SettingsLayout', () => { expect(active).toHaveAttribute('href', '/admin/subscription'); }); - it('renders the tauri-sourced items as navigable links', () => { + it('renders the shared menu items as navigable links', () => { renderWithProviders(
); expect(screen.getByText('مدیریت نوبت دهی').closest('a')).toHaveAttribute('href', '/admin/appointment-settings'); - expect(screen.getByText('تگ ها').closest('a')).toHaveAttribute('href', '/admin/tags-settings'); + expect(screen.getByText('برچسب‌ها').closest('a')).toHaveAttribute('href', '/admin/tags-settings'); // 'مدیریت پزشک' lives in the main nav, not the settings menu expect(screen.queryByText('مدیریت پزشک')).not.toBeInTheDocument(); }); + /** + * سایدبار و فهرست موبایل یک منبع دارند؛ آیتمی که به منو اضافه می‌شود باید در هر دو + * دیده شود. قبلاً دو کپی بود و «منابع»/«دسته‌بندی‌ها» فقط در موبایل ظاهر شدند. + */ + it('shows the resource-first entries in the desktop sidebar too', () => { + useAuthStore.setState({ primaryRole: 'clinic' }); + renderWithProviders(
); + + expect(screen.getByText('منابع').closest('a')).toHaveAttribute('href', '/admin/resources'); + expect(screen.getByText('دسته‌بندی‌ها').closest('a')).toHaveAttribute('href', '/admin/service-categories'); + expect(screen.getByText('شعبه‌ها و اتاق‌ها').closest('a')).toHaveAttribute('href', '/admin/branches'); + expect(screen.getByText('منابع').closest('a')).toHaveAttribute('aria-current', 'page'); + }); + it('role-gates the desktop sidebar: a doctor does not see the clinic-doctors tab', () => { renderWithProviders(
); expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument(); diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 5497d383..f9c09ed2 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -1,71 +1,9 @@ import React from 'react'; -import { - CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, - BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, NoSymbolIcon, QueueListIcon, ChartBarIcon, -} from '@heroicons/react/24/outline'; import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; -// ── Settings menu configuration ───────────────────────────────────────────── -// Source of truth for the *mobile* settings list (SettingsMenuPage). The desktop -// shell renders the shared PurchaseSubscriptionSidebar instead, so there is a -// single settings sidebar across all settings pages (no duplicate menu). -export type SettingsMenuItem = { - key: string; - label: string; - icon: React.ElementType; - to?: string; - /** when set, the item is only shown to these roles (omit = every role) */ - roles?: string[]; - /** [resource, action] — منشی فقط با داشتن این مجوز آیتم را می‌بیند. */ - perm?: [string, string]; - /** برای منشی همیشه نمایش داده می‌شود (حساب کاربری). */ - alwaysOpen?: boolean; -}; - -export const SETTINGS_MENU: SettingsMenuItem[] = [ - { key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription', perm: ['subscription', 'view'] }, - { key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] }, - { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] }, - { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, - { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, - { key: 'branches', label: 'شعبه‌ها و اتاق‌ها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, - { key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, - { key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, - { key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, - { key: 'price-lists', label: 'لیست‌های قیمت', icon: BanknotesIcon, to: '/admin/price-lists', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, - { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, - { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, - { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, - { key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing', perm: ['insurances', 'view'] }, - { key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] }, - { key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] }, - { key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] }, - { key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true }, -]; - -/** - * Menu items visible to the given role. برای منشی بر اساس مجوز فیلتر می‌شود - * (آیتمِ بدونِ perm/alwaysOpen پنهان است)؛ سایر نقش‌ها با roles. - */ -export function menuForRole( - role: string | null | undefined, - can?: (resource: string, action: string) => boolean, - scope?: string | null, -): SettingsMenuItem[] { - // منشی و پزشکِ عضوِ کلینیک (scope=clinic) محدود-به-مجوزند؛ بقیه با فیلترِ نقشی. - const permissionRestricted = role === 'secretary' || (role === 'doctor' && scope === 'clinic'); - return SETTINGS_MENU.filter((i) => { - if (permissionRestricted) { - if (i.alwaysOpen) return true; - if (!i.perm || !can || !can(i.perm[0], i.perm[1])) return false; - // واریانتِ نقشی (نوبت‌دهی/پزشکان کلینیک) را با scope تطبیق بده. - if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor'); - return true; - } - return !i.roles || (role != null && i.roles.includes(role)); - }); -} +// فهرست منو به `settingsMenu.ts` منتقل شد تا سایدبار دسکتاپ و فهرست موبایل یک منبع +// داشته باشند. این re-export برای مصرف‌کننده‌های موجود می‌ماند. +export { SETTINGS_MENU, menuForRole, type SettingsMenuItem } from './settingsMenu'; /** * SettingsLayout — presentational shell for the doctor/clinic settings area. diff --git a/assets/admin/components/layout/settingsMenu.ts b/assets/admin/components/layout/settingsMenu.ts new file mode 100644 index 00000000..e2f911cc --- /dev/null +++ b/assets/admin/components/layout/settingsMenu.ts @@ -0,0 +1,73 @@ +import React from 'react'; +import { + CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, + BanknotesIcon, UsersIcon, ShieldCheckIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, + MapPinIcon, CubeIcon, RectangleStackIcon, +} from '@heroicons/react/24/outline'; + +/** + * تنها منبعِ حقیقتِ منوی تنظیمات — هم فهرست موبایل (`SettingsMenuPage`) و هم سایدبار + * دسکتاپ (`PurchaseSubscriptionSidebar`) از همین می‌خوانند. + * + * قبلاً هرکدام فهرست خودش را داشت و آیتم تازه فقط در یکی ظاهر می‌شد؛ «منابع» و + * «دسته‌بندی‌ها» در موبایل بودند و در سایدبار نبودند. + */ +export type SettingsMenuItem = { + key: string; + label: string; + icon: React.ElementType; + to?: string; + /** when set, the item is only shown to these roles (omit = every role) */ + roles?: string[]; + /** [resource, action] — منشی فقط با داشتن این مجوز آیتم را می‌بیند. */ + perm?: [string, string]; + /** برای منشی همیشه نمایش داده می‌شود (حساب کاربری). */ + alwaysOpen?: boolean; + /** مقصدی ندارد — در سایدبار خاکستری و غیرقابل کلیک نشان داده می‌شود. */ + disabled?: boolean; +}; + +export const SETTINGS_MENU: SettingsMenuItem[] = [ + { key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription', perm: ['subscription', 'view'] }, + { key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] }, + { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] }, + { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, + { key: 'branches', label: 'شعبه‌ها و اتاق‌ها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'price-lists', label: 'لیست‌های قیمت', icon: BanknotesIcon, to: '/admin/price-lists', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, + { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, + { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, + { key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing', perm: ['insurances', 'view'] }, + { key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] }, + { key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] }, + { key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] }, + { key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true }, +]; + +/** + * Menu items visible to the given role. برای منشی بر اساس مجوز فیلتر می‌شود + * (آیتمِ بدونِ perm/alwaysOpen پنهان است)؛ سایر نقش‌ها با roles. + */ +export function menuForRole( + role: string | null | undefined, + can?: (resource: string, action: string) => boolean, + scope?: string | null, +): SettingsMenuItem[] { + // منشی و پزشکِ عضوِ کلینیک (scope=clinic) محدود-به-مجوزند؛ بقیه با فیلترِ نقشی. + const permissionRestricted = role === 'secretary' || (role === 'doctor' && scope === 'clinic'); + return SETTINGS_MENU.filter((i) => { + if (permissionRestricted) { + if (i.alwaysOpen) return true; + if (!i.perm || !can || !can(i.perm[0], i.perm[1])) return false; + // واریانتِ نقشی (نوبت‌دهی/پزشکان کلینیک) را با scope تطبیق بده. + if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor'); + return true; + } + return !i.roles || (role != null && i.roles.includes(role)); + }); +}