feat(settings): implement grouped menu structure and update related components

This commit is contained in:
hamed
2026-08-09 07:56:48 +03:30
parent 89a1428ca0
commit e48ab9a974
6 changed files with 328 additions and 101 deletions
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
import { SearchHeaderP } from '../../pages/subscriptionIcons'; import { SearchHeaderP } from '../../pages/subscriptionIcons';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import { menuForRole, type SettingsMenuItem } from './settingsMenu'; import { groupMenu, menuForRole, type SettingsMenuItem } from './settingsMenu';
/** /**
* سایدبار تنظیمات — همان فهرستی که `SettingsMenuPage` روی موبایل نشان می‌دهد * سایدبار تنظیمات — همان فهرستی که `SettingsMenuPage` روی موبایل نشان می‌دهد
@@ -21,6 +21,7 @@ const SECURITY_ITEM: SettingsMenuItem = {
key: 'security', key: 'security',
label: 'تنظیمات', label: 'تنظیمات',
icon: () => null, icon: () => null,
group: 'account',
alwaysOpen: true, alwaysOpen: true,
disabled: true, disabled: true,
}; };
@@ -30,13 +31,18 @@ export default function PurchaseSubscriptionSidebar({ active }: { active: string
const primaryRole = useAuthStore((s) => s.primaryRole); const primaryRole = useAuthStore((s) => s.primaryRole);
const scope = useAuthStore((s) => s.context?.scope); const scope = useAuthStore((s) => s.context?.scope);
const { can } = usePermissions(); const { can } = usePermissions();
const items = useMemo( // گروه‌ها بعد از فیلترِ جستجو ساخته می‌شوند تا تیترِ دسته‌ای که هیچ نتیجه‌ای ندارد
() => [ // بالای فضای خالی نماند.
...menuForRole(primaryRole, can, scope).filter((i) => !HIDDEN_KEYS.has(i.key)), const groups = useMemo(
SECURITY_ITEM, () => groupMenu(
].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], [query, primaryRole, scope, can],
); );
const itemCount = groups.reduce((sum, g) => sum + g.items.length, 0);
return ( return (
<aside <aside
@@ -74,36 +80,46 @@ export default function PurchaseSubscriptionSidebar({ active }: { active: string
</div> </div>
<nav> <nav>
{items.map((item) => { {groups.map((group, groupIdx) => (
const isActive = item.key === active; <section key={group.key} aria-label={group.label}>
const rowStyle: React.CSSProperties = { <div style={{
borderRadius: 12, height: 44, marginBottom: 8, fontSize: 12, fontWeight: 700, color: 'var(--text-3)', textAlign: 'right',
display: 'flex', alignItems: 'center', justifyContent: 'flex-start', padding: '0 14px', marginTop: groupIdx === 0 ? 0 : 14, marginBottom: 6,
padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500, }}>
lineHeight: 1, textAlign: 'right', {group.label}
background: isActive ? 'var(--accent)' : 'transparent', </div>
color: isActive ? 'var(--on-primary)' : 'var(--text-2)', {group.items.map((item) => {
cursor: item.to ? 'pointer' : 'not-allowed', const isActive = item.key === active;
transition: 'background .14s', const rowStyle: React.CSSProperties = {
}; borderRadius: 12, height: 44, marginBottom: 8,
const inner = <span style={{ flex: 1 }}>{item.label}</span>; display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
if (!item.to || item.disabled) { padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500,
return <div key={item.key} style={{ ...rowStyle, opacity: 0.55 }}>{inner}</div>; lineHeight: 1, textAlign: 'right',
} background: isActive ? 'var(--accent)' : 'transparent',
return ( color: isActive ? 'var(--on-primary)' : 'var(--text-2)',
<Link cursor: item.to ? 'pointer' : 'not-allowed',
key={item.key} transition: 'background .14s',
to={item.to} };
aria-current={isActive ? 'page' : undefined} const inner = <span style={{ flex: 1 }}>{item.label}</span>;
style={rowStyle} if (!item.to || item.disabled) {
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }} return <div key={item.key} style={{ ...rowStyle, opacity: 0.55 }}>{inner}</div>;
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }} }
> return (
{inner} <Link
</Link> key={item.key}
); to={item.to}
})} aria-current={isActive ? 'page' : undefined}
{items.length === 0 && ( style={rowStyle}
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
{inner}
</Link>
);
})}
</section>
))}
{itemCount === 0 && (
<div style={{ padding: '12px 4px', fontSize: 13, color: 'var(--text-3)', textAlign: 'center' }}> <div style={{ padding: '12px 4px', fontSize: 13, color: 'var(--text-3)', textAlign: 'center' }}>
موردی یافت نشد موردی یافت نشد
</div> </div>
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import {
SETTINGS_GROUPS, SETTINGS_MENU, groupMenu, groupedMenuForRole, menuForRole,
} from './settingsMenu';
/**
* منو یک فهرست تختِ ۱۶ ردیفی بود که ترتیبش از تاریخِ اضافه‌شدنِ آیتم‌ها می‌آمد:
* «خرید اشتراک» — کم‌مصرف‌ترین — ردیف اول بود و «مدیریت پرداخت» وسط آیتم‌های
* نوبت‌دهی. این تست‌ها همان چیدمان تازه را قفل می‌کنند.
*/
describe('گروه‌بندی منوی تنظیمات', () => {
it('هر آیتم منو دقیقاً به یکی از گروه‌های تعریف‌شده تعلق دارد', () => {
const known = new Set(SETTINGS_GROUPS.map((g) => g.key));
for (const item of SETTINGS_MENU) {
expect(known.has(item.group), `${item.key} گروه نامعتبر دارد`).toBe(true);
}
});
it('گروه‌ها به ترتیب تعریف‌شده برمی‌گردند', () => {
const keys = groupedMenuForRole('clinic').map((g) => g.key);
expect(keys).toEqual(['practice', 'scheduling', 'patients', 'finance', 'account']);
});
it('خرید اشتراک در گروه آخر است، نه ردیف اول', () => {
const groups = groupedMenuForRole('doctor');
const last = groups[groups.length - 1];
expect(last.key).toBe('account');
expect(last.items.map((i) => i.key)).toEqual(['subscription', 'account']);
expect(menuForRole('doctor')[0].key).not.toBe('subscription');
});
it('مدیریت پرداخت کنار بیمه و تخفیف است، نه وسط نوبت‌دهی', () => {
const finance = groupedMenuForRole('doctor').find((g) => g.key === 'finance');
expect(finance?.items.map((i) => i.key)).toEqual(['payment', 'insurance', 'discounts', 'sms']);
});
it('گروه خالی برنمی‌گردد', () => {
// منشیِ فقط-با-مجوزِ پرداخت: نباید تیتر «نوبت‌دهی» بالای فضای خالی ببیند.
const can = (resource: string) => resource === 'payments';
const groups = groupedMenuForRole('secretary', can);
expect(groups.map((g) => g.key)).toEqual(['finance', 'account']);
for (const group of groups) {
expect(group.items.length).toBeGreaterThan(0);
}
});
it('فیلترِ جستجو گروه بی‌نتیجه را حذف می‌کند', () => {
const filtered = groupMenu(menuForRole('doctor').filter((i) => i.label.includes('بیمه')));
expect(filtered).toHaveLength(1);
expect(filtered[0].key).toBe('finance');
expect(filtered[0].items.map((i) => i.key)).toEqual(['insurance']);
});
it('گروه‌بندی هیچ آیتمی را نمی‌اندازد و تکراری نمی‌سازد', () => {
for (const role of ['doctor', 'clinic']) {
const flat = menuForRole(role);
const grouped = groupedMenuForRole(role).flatMap((g) => g.items);
expect(grouped).toHaveLength(flat.length);
expect(new Set(grouped.map((i) => i.to)).size).toBe(flat.length);
}
});
it('گِیت نقشی بعد از گروه‌بندی هم برقرار است', () => {
const practice = groupedMenuForRole('doctor').find((g) => g.key === 'practice');
expect(practice?.items.map((i) => i.key)).not.toContain('clinic-doctors');
expect(practice?.items.map((i) => i.key)).toContain('doctor');
});
});
+76 -17
View File
@@ -13,10 +13,35 @@ import {
* قبلاً هرکدام فهرست خودش را داشت و آیتم تازه فقط در یکی ظاهر می‌شد؛ «منابع» و * قبلاً هرکدام فهرست خودش را داشت و آیتم تازه فقط در یکی ظاهر می‌شد؛ «منابع» و
* «دسته‌بندی‌ها» در موبایل بودند و در سایدبار نبودند. * «دسته‌بندی‌ها» در موبایل بودند و در سایدبار نبودند.
*/ */
/**
* دسته‌های منو، به ترتیبِ نمایش.
*
* ترتیب از «چقدر به کارِ روزمره نزدیک است» می‌آید، نه از تاریخِ اضافه‌شدنِ آیتم:
* اول ساختارِ مطب، بعد نوبت‌دهی، بعد بیماران، بعد مالی، و ته فهرست حساب و اشتراک
* که ماهی یک‌بار سراغش می‌روند. پیش از این فهرست تخت بود و «خرید اشتراک» — کم‌مصرف‌ترین
* آیتم — اولین ردیف بود، در حالی که «مدیریت پرداخت» وسط آیتم‌های نوبت‌دهی افتاده بود.
*/
export const SETTINGS_GROUPS = [
{ key: 'practice', label: 'مطب و کلینیک' },
{ key: 'scheduling', label: 'نوبت‌دهی' },
{ key: 'patients', label: 'بیماران' },
{ key: 'finance', label: 'مالی' },
{ key: 'account', label: 'حساب و اشتراک' },
] as const;
export type SettingsGroupKey = (typeof SETTINGS_GROUPS)[number]['key'];
export type SettingsMenuGroup = {
key: SettingsGroupKey;
label: string;
items: SettingsMenuItem[];
};
export type SettingsMenuItem = { export type SettingsMenuItem = {
key: string; key: string;
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
group: SettingsGroupKey;
to?: string; to?: string;
/** when set, the item is only shown to these roles (omit = every role) */ /** when set, the item is only shown to these roles (omit = every role) */
roles?: string[]; roles?: string[];
@@ -28,26 +53,36 @@ export type SettingsMenuItem = {
disabled?: boolean; disabled?: boolean;
}; };
// ترتیب همین آرایه ترتیبِ نمایش است؛ آیتم‌های هر دسته پشت سر هم می‌آیند.
export const SETTINGS_MENU: SettingsMenuItem[] = [ 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: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, group: 'practice', to: '/admin/profile', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] }, { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, group: 'practice', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, { key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, group: 'practice', to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, group: 'practice', to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, group: 'practice', to: '/admin/staff', perm: ['staff', 'view'] },
// ── نوبت‌دهی — قواعدی که تقویم را می‌سازند ────────────────────────────────
// «منابع» به سایدبار اصلی («مدیریت») منتقل شد — کارِ روزمره است، نه تنظیمات. // «منابع» به سایدبار اصلی («مدیریت») منتقل شد — کارِ روزمره است، نه تنظیمات.
// گِیتش آنجا همین است: `appointment_settings.view` در هر چهار نقشی که اینجا می‌دیدندش. // گِیتش آنجا همین است: `appointment_settings.view` در هر چهار نقشی که اینجا می‌دیدندش.
{ key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
{ key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, group: 'scheduling', to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, { key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', '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: 'record-number', label: 'شماره پرونده', icon: HashtagIcon, group: 'patients', to: '/admin/record-number-settings', roles: ['doctor', 'clinic'], perm: ['patients', 'view'] },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] }, { key: 'tags', label: 'برچسب‌ها', icon: TagIcon, group: 'patients', to: '/admin/tags-settings', perm: ['tags', 'view'] },
{ key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] },
{ key: 'record-number', label: 'شماره پرونده', icon: HashtagIcon, to: '/admin/record-number-settings', roles: ['doctor', 'clinic'], perm: ['patients', 'view'] }, // ── مالی — هر چیزی که به پول یا اعتبار وصل است ────────────────────────────
{ key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, group: 'finance', to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true }, { key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, group: 'finance', to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, group: 'finance', to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
{ key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, group: 'finance', to: '/admin/sms-wallet', perm: ['sms', 'view'] },
// ── حساب و اشتراک — کم‌مصرف‌ترین‌ها، ته فهرست ─────────────────────────────
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, group: 'account', to: '/admin/subscription', perm: ['subscription', 'view'] },
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, group: 'account', to: '/admin/account-settings', alwaysOpen: true },
]; ];
/** /**
@@ -72,3 +107,27 @@ export function menuForRole(
return !i.roles || (role != null && i.roles.includes(role)); return !i.roles || (role != null && i.roles.includes(role));
}); });
} }
/**
* همان خروجی `menuForRole`، دسته‌بندی‌شده و به ترتیبِ `SETTINGS_GROUPS`.
*
* دستهٔ خالی برنمی‌گردد: منشی‌ای که مجوز مالی ندارد نباید تیترِ «مالی» را ببیند و
* زیرش هیچ. هر دو مصرف‌کنندهٔ منو از همین می‌خوانند تا ترتیب دسکتاپ و موبایل واگرا نشود.
*/
export function groupMenu(items: SettingsMenuItem[]): SettingsMenuGroup[] {
return SETTINGS_GROUPS
.map((group) => ({
key: group.key,
label: group.label,
items: items.filter((i) => i.group === group.key),
}))
.filter((group) => group.items.length > 0);
}
export function groupedMenuForRole(
role: string | null | undefined,
can?: (resource: string, action: string) => boolean,
scope?: string | null,
): SettingsMenuGroup[] {
return groupMenu(menuForRole(role, can, scope));
}
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
import PracticeDomainSettingsPage from './PracticeDomainSettingsPage';
import { useAuthStore } from '../stores/authStore';
import { api } from '../lib/api';
const DOMAINS = [
{ uuid: 'd-1', name: 'دندانپزشکی', has_workflow: true },
{ uuid: 'd-2', name: 'کلینیک زیبایی', has_workflow: false },
];
beforeEach(() => {
useAuthStore.setState({
primaryRole: 'clinic',
context: { scope: 'clinic', db_uuid: 'clinic-1' } as never,
});
vi.spyOn(api, 'get').mockImplementation(async (path: string) => {
if (path.startsWith('/api/v1/practice-domains')) return { data: DOMAINS } as never;
return { data: { data: { practice_domain: DOMAINS[1] } } } as never;
});
});
describe('PracticeDomainSettingsPage', () => {
/**
* این صفحه تنها عضو منوی تنظیمات بود که پوستهٔ تنظیمات را نداشت: کاربر برای رفتن
* به بخش بعدی مجبور بود «بازگشت» بزند.
*/
it('داخل پوستهٔ تنظیمات رندر می‌شود و منوی تنظیمات را نشان می‌دهد', async () => {
renderWithProviders(<PracticeDomainSettingsPage />);
expect(await screen.findByLabelText('منوی تنظیمات')).toBeInTheDocument();
});
it('همین آیتم در منو فعال است', async () => {
renderWithProviders(<PracticeDomainSettingsPage />);
// عنوان صفحه هم همین متن را دارد، پس آیتم منو با نقش link گرفته می‌شود.
const active = await screen.findByRole('link', { name: 'حوزهٔ فعالیت' });
expect(active).toHaveAttribute('aria-current', 'page');
expect(active).toHaveAttribute('href', '/admin/settings/practice-domain');
});
it('عنوان صفحه یک بار می‌آید، نه هم در هدر هم در کارت', async () => {
renderWithProviders(<PracticeDomainSettingsPage />);
expect(await screen.findByRole('heading', { name: 'حوزهٔ فعالیت' })).toBeInTheDocument();
expect(screen.getAllByRole('heading', { name: 'حوزهٔ فعالیت' })).toHaveLength(1);
});
it('حوزهٔ بدون فرآیند، هشدارش را نشان می‌دهد', async () => {
renderWithProviders(<PracticeDomainSettingsPage />);
expect(
await screen.findByText(/برای این حوزه هنوز فرآیند اختصاصی تعریف نشده است/),
).toBeInTheDocument();
});
});
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api'; import { api, ApiError, type ApiResponse } from '../lib/api';
import PageHeader from '../components/ui/PageHeader'; import SettingsLayout from '../components/layout/SettingsLayout';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import type { PracticeDomain } from '../types'; import type { PracticeDomain } from '../types';
@@ -55,10 +55,12 @@ export default function PracticeDomainSettingsPage() {
const chosen = domains.find((d) => d.uuid === selected) ?? null; const chosen = domains.find((d) => d.uuid === selected) ?? null;
return ( return (
<> // پوستهٔ تنظیمات، مثل بقیهٔ صفحات همین منو. بدون آن، این صفحه تنها صفحه‌ای بود که
<PageHeader title="حوزهٔ فعالیت" backTo="/admin/settings" /> // منوی تنظیمات را نشان نمی‌داد و کاربر برای رفتن به بخش بعدی باید «بازگشت» می‌زد.
<SettingsLayout active="practice-domain">
<div className="card card-pad" style={{ display: 'grid', gap: 14, maxWidth: 560 }}> <div className="card card-pad" style={{ display: 'grid', gap: 14, maxWidth: 560 }}>
<h1 className="section-title" style={{ margin: 0 }}>حوزهٔ فعالیت</h1>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}> <p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با حوزهٔ فعالیت تعیین میکند سیستم چه فرآیند درمانی برای کلینیک شما اجرا کند. این با
«تخصص» فرق دارد: تخصص برچسبی است که در سایت عمومی دیده میشود، این یک تنظیم است. «تخصص» فرق دارد: تخصص برچسبی است که در سایت عمومی دیده میشود، این یک تنظیم است.
@@ -96,6 +98,6 @@ export default function PracticeDomainSettingsPage() {
</> </>
)} )}
</div> </div>
</> </SettingsLayout>
); );
} }
+57 -43
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { ChevronLeftIcon } from '@heroicons/react/24/outline'; import { ChevronLeftIcon } from '@heroicons/react/24/outline';
import { menuForRole } from '../components/layout/SettingsLayout'; import { groupedMenuForRole } from '../components/layout/settingsMenu';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
@@ -10,58 +10,72 @@ import { usePermissions } from '../hooks/usePermissions';
* A full-width tappable list of the settings sections available to the current * A full-width tappable list of the settings sections available to the current
* role (mobile-first, matches the Figma mobile design). Each section links to * role (mobile-first, matches the Figma mobile design). Each section links to
* its route; on desktop the same sections render the shell via SettingsLayout. * its route; on desktop the same sections render the shell via SettingsLayout.
*
* روی موبایل کل این فهرست بالای محتوا می‌نشیند، پس ۱۶ ردیفِ یکدست یعنی اسکرول
* طولانیِ بی‌نشانه. دسته‌ها همان دسته‌های سایدبار دسکتاپ‌اند (`settingsMenu.ts`).
*/ */
export default function SettingsMenuPage() { export default function SettingsMenuPage() {
const primaryRole = useAuthStore((s) => s.primaryRole); const primaryRole = useAuthStore((s) => s.primaryRole);
const scope = useAuthStore((s) => s.context?.scope); const scope = useAuthStore((s) => s.context?.scope);
const { can } = usePermissions(); const { can } = usePermissions();
const items = menuForRole(primaryRole, can, scope); const groups = groupedMenuForRole(primaryRole, can, scope);
return ( return (
<div className="fade-in" style={{ maxWidth: 720, margin: '0 auto' }}> <div className="fade-in" style={{ maxWidth: 720, margin: '0 auto' }}>
<h1 className="section-title" style={{ marginBottom: 16 }}>تنظیمات</h1> <h1 className="section-title" style={{ marginBottom: 16 }}>تنظیمات</h1>
<div style={{ {groups.map((group) => (
background: 'var(--surface)', border: '1px solid var(--border)', <section key={group.key} aria-label={group.label} style={{ marginBottom: 20 }}>
borderRadius: 'var(--r-lg)', overflow: 'hidden', <h2 style={{
}}> fontSize: 13, fontWeight: 700, color: 'var(--text-3)',
{items.map((item, idx) => { marginBottom: 8, paddingInlineStart: 4,
const Icon = item.icon; }}>
const disabled = !item.to; {group.label}
const rowStyle: React.CSSProperties = { </h2>
display: 'flex', alignItems: 'center', gap: 12,
padding: '16px 18px', fontSize: 15, fontFamily: 'inherit', <div style={{
borderTop: idx === 0 ? 'none' : '1px solid var(--border)', background: 'var(--surface)', border: '1px solid var(--border)',
color: disabled ? 'var(--text-3)' : 'var(--text)', borderRadius: 'var(--r-lg)', overflow: 'hidden',
cursor: disabled ? 'not-allowed' : 'pointer', }}>
background: 'transparent', width: '100%', textAlign: 'right', {group.items.map((item, idx) => {
}; const Icon = item.icon;
const inner = ( const disabled = !item.to;
<> const rowStyle: React.CSSProperties = {
<Icon style={{ width: 20, height: 20, flexShrink: 0, color: disabled ? 'var(--text-3)' : 'var(--primary)' }} /> display: 'flex', alignItems: 'center', gap: 12,
<span style={{ flex: 1, fontWeight: 600 }}>{item.label}</span> padding: '16px 18px', fontSize: 15, fontFamily: 'inherit',
{disabled borderTop: idx === 0 ? 'none' : '1px solid var(--border)',
? <span style={{ fontSize: 11, color: 'var(--text-3)' }}>بهزودی</span> color: disabled ? 'var(--text-3)' : 'var(--text)',
: <ChevronLeftIcon style={{ width: 18, color: 'var(--text-3)', flexShrink: 0 }} />} cursor: disabled ? 'not-allowed' : 'pointer',
</> background: 'transparent', width: '100%', textAlign: 'right',
); };
return disabled ? ( const inner = (
<button key={item.key} type="button" disabled style={{ ...rowStyle, border: 'none', borderTop: rowStyle.borderTop }}> <>
{inner} <Icon style={{ width: 20, height: 20, flexShrink: 0, color: disabled ? 'var(--text-3)' : 'var(--primary)' }} />
</button> <span style={{ flex: 1, fontWeight: 600 }}>{item.label}</span>
) : ( {disabled
<Link ? <span style={{ fontSize: 11, color: 'var(--text-3)' }}>بهزودی</span>
key={item.key} : <ChevronLeftIcon style={{ width: 18, color: 'var(--text-3)', flexShrink: 0 }} />}
to={item.to!} </>
style={rowStyle} );
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }} return disabled ? (
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; }} <button key={item.key} type="button" disabled style={{ ...rowStyle, border: 'none', borderTop: rowStyle.borderTop }}>
> {inner}
{inner} </button>
</Link> ) : (
); <Link
})} key={item.key}
</div> to={item.to!}
style={rowStyle}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
{inner}
</Link>
);
})}
</div>
</section>
))}
</div> </div>
); );
} }