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