feat: implement OTP login flow and enhance role-based access control

- Added OTP login functionality in driver.mjs to handle user authentication with a fixed code in dev environment.
- Enhanced RoleRoute component in App.tsx to support clinic-scoped doctor roles and permissions.
- Updated ClinicDoctorsManager component to include pagination and search functionality for better user experience.
- Refactored tests for ClinicDoctorsManager to cover new features and ensure proper API mocking.
- Adjusted permissions in settingsMenu.ts and PracticeDomainSettingsPage.tsx to align with updated backend requirements.
- Created RoleRoute.test.tsx to validate role-based access logic for different user scenarios.
This commit is contained in:
hamed
2026-08-09 12:41:11 +03:30
parent cfeb447645
commit 60ccd5cc1d
8 changed files with 515 additions and 121 deletions
@@ -1,48 +1,129 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import ClinicDoctorsManager from './ClinicDoctorsManager';
const get = api.get as ReturnType<typeof vi.fn>;
vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }));
beforeEach(() => {
get.mockReset();
get.mockImplementation((url: string) => {
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
{ id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
] });
if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
{ uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
status: 'pending', token_used: false, invited_at: 1, expires_at: 9_999_999_999,
responded_at: null, doctor: null },
], meta: { totalRecords: 1, totalPages: 1, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
const DOCTOR = {
id: '1',
uuid: 'doc-1',
name: 'پزشک دعوت‌شده',
gender: null,
degree: null,
img: [],
specialties: [],
active: false, // نوبت‌دهی آنلاینِ خودِ پزشک خاموش است
};
/** پاسخ‌ها بر اساس مسیر تفکیک می‌شوند، چون سه کوئری موازی می‌روند. */
function mockApi({ doctors = [DOCTOR], total = 1, permissions = [] as any[], failDoctors = false } = {}) {
(api.get as any).mockImplementation((url: string) => {
if (url.includes('/doctor-permissions')) return Promise.resolve({ data: permissions });
if (url.includes('/invitations')) return Promise.resolve({ data: [] });
if (url.includes('/doctor-list/')) {
return failDoctors
? Promise.reject(new Error('boom'))
: Promise.resolve({ data: doctors, meta: { totalRecords: total, totalPages: 1, currentPage: 1 } });
}
return Promise.resolve({ data: [] });
});
});
}
describe('ClinicDoctorsManager', () => {
it('lists clinic doctors and shows management controls by default', async () => {
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" />, { route: '/admin/settings/clinic-doctors' });
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.getByText('قلب')).toBeInTheDocument();
// manager controls
expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
beforeEach(() => vi.clearAllMocks());
it('صفحه و سقف را به سرور می‌فرستد — وگرنه backend سر ۱۰ پزشک بی‌صدا می‌برد', async () => {
mockApi();
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
await waitFor(() => expect(api.get).toHaveBeenCalled());
const listCall = (api.get as any).mock.calls.find((c: any[]) => c[0].includes('/doctor-list/'));
expect(listCall[0]).toContain('page=1');
expect(listCall[0]).toContain('limit=10');
});
it('hides every mutating control when readOnly', async () => {
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" readOnly />, { route: '/admin/settings/clinic-doctors' });
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
it('شمارندهٔ تب از meta می‌آید، نه از طول صفحهٔ جاری', async () => {
mockApi({ doctors: [DOCTOR], total: 25 });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText(/پزشکان \(۲۵\)/)).toBeInTheDocument();
});
it('«نوبت‌دهی آنلاین» و «دسترسی در کلینیک» دو ستون جدا هستند', async () => {
mockApi();
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('دسترسی در کلینیک')).toBeInTheDocument();
expect(screen.getByText('نوبت‌دهی آنلاین')).toBeInTheDocument();
});
it('شمار مجوزها زیر همان بجِ دسترسی می‌آید، نه در ستون جدا', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: true,
permissions: { resources: { appointments: { view: true, create: false } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
expect(screen.queryByText('مجوزها')).not.toBeInTheDocument();
});
it('پزشکِ بدون ردیف مجوز، «پیش‌فرض» و دسترسی فعال می‌گیرد', async () => {
mockApi({ permissions: [] });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('پیش‌فرض')).toBeInTheDocument();
});
it('ردیف مجوزِ خاموش، «غیرفعال» نشان می‌دهد و شمار مجوزها را می‌آورد', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: false,
permissions: { resources: { appointments: { view: true, create: false } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
// دو «غیرفعال»: یکی دسترسیِ کلینیک، یکی نوبت‌دهی آنلاین. پیش از این یک بج جای
// هر دو می‌نشست و همین ابهام، پزشکِ سالم را «غیرفعال» نشان می‌داد.
expect(screen.getAllByText('غیرفعال')).toHaveLength(2);
});
it('دسترسیِ فعال با نوبت‌دهیِ خاموش قاطی نمی‌شود', async () => {
mockApi({
permissions: [{
doctor_uuid: 'doc-1',
active: true,
permissions: { resources: { appointments: { view: true } } },
}],
});
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
// دسترسی فعال است…
expect(await screen.findByText('فعال')).toBeInTheDocument();
// …ولی نوبت‌دهی آنلاینِ خودِ پزشک همچنان خاموش.
expect(screen.getByText('غیرفعال')).toBeInTheDocument();
});
it('خطای فهرست، پیام خطا می‌دهد نه حالت خالی', async () => {
mockApi({ failDoctors: true });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('فهرست پزشکان بارگذاری نشد')).toBeInTheDocument();
expect(screen.queryByText('هیچ پزشکی به این کلینیک متصل نیست')).not.toBeInTheDocument();
});
it('فهرست خالی، پیام خالی می‌دهد', async () => {
mockApi({ doctors: [], total: 0 });
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
expect(await screen.findByText('هیچ پزشکی به این کلینیک متصل نیست')).toBeInTheDocument();
});
});
+201 -53
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -11,8 +11,11 @@ import { formatNumber } from '../lib/utils';
import ConfirmDialog from './ui/ConfirmDialog';
import InviteDoctorModal from './ui/InviteDoctorModal';
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
import DataTable, { type Column } from './ui/DataTable';
import Pagination from './ui/Pagination';
const HUES_LIST = [256, 205, 162, 295, 272];
const PAGE_SIZE = 10;
export interface ClinicDoctorItem {
id: string; uuid: string; name: string;
@@ -35,6 +38,29 @@ export interface ClinicInvitation {
doctor: { uuid: string; name: string } | null;
}
/** یک ردیف از `GET /api/v1/admin/clinic/{uuid}/doctor-permissions`. */
interface DoctorPermissionRow {
doctor_uuid: string;
active: boolean;
permissions: { resources: Record<string, Record<string, boolean>> };
}
/** چند اکشن از کل اکشن‌های تعریف‌شده به این پزشک داده شده. */
function grantedCount(row?: DoctorPermissionRow): { granted: number; total: number } {
const resources = row?.permissions?.resources ?? {};
let granted = 0;
let total = 0;
for (const actions of Object.values(resources)) {
for (const allowed of Object.values(actions)) {
total += 1;
if (allowed) granted += 1;
}
}
return { granted, total };
}
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
pending: { label: 'در انتظار', cls: 'amber' },
accepted: { label: 'پذیرفته‌شده', cls: 'green' },
@@ -72,10 +98,30 @@ export default function ClinicDoctorsManager({
const [inviteOpen, setInviteOpen] = useState(false);
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
// فیلدِ جستجو محلی می‌ماند و فقط مقدارِ آرام‌شده به کوئری می‌رود؛ وگرنه هر حرف یک
// درخواست به سرور می‌زند.
useEffect(() => {
const t = setTimeout(() => { setDebouncedSearch(search); setPage(1); }, 350);
return () => clearTimeout(t);
}, [search]);
/**
* صفحه و جستجو به سرور می‌روند.
*
* پیش از این هیچ‌کدام فرستاده نمی‌شد و backend سقف پیش‌فرضِ ۱۰ را اعمال می‌کرد
* (`DoctorRepository::findByClinicWithFilters`)، پس کلینیکِ یازده‌پزشکه بی‌هیچ نشانه‌ای
* یک پزشک را گم می‌کرد.
*/
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
queryKey: ['clinic-doctors', clinicUuid, page, debouncedSearch],
queryFn: () => api.get<PaginatedResponse<ClinicDoctorItem>>(
`/api/v1/clinic/doctor-list/${clinicUuid}?page=${page}&limit=${PAGE_SIZE}`
+ (debouncedSearch ? `&name=${encodeURIComponent(debouncedSearch)}` : ''),
),
enabled: !!clinicUuid,
});
@@ -85,11 +131,30 @@ export default function ClinicDoctorsManager({
enabled: !!clinicUuid,
});
/**
* مجوزهای همهٔ پزشکان کلینیک با **یک** درخواست.
*
* اندپوینت تک‌پزشکی از قبل بود ولی برای فهرست یعنی N درخواست؛ نسخهٔ گروهی هم از قبل
* وجود داشت و فقط مصرف نمی‌شد.
*/
const permissionsQ = useQuery({
queryKey: ['clinic-doctor-permissions', clinicUuid],
queryFn: () => api.get<ApiResponse<DoctorPermissionRow[]>>(`/api/v1/admin/clinic/${clinicUuid}/doctor-permissions`),
enabled: !!clinicUuid && canUpdate,
});
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const doctorTotal = doctorsQ.data?.meta?.totalRecords ?? doctorList.length;
const permissionByDoctor = useMemo(() => {
const rows = permissionsQ.data?.data ?? [];
return new Map((Array.isArray(rows) ? rows : []).map(r => [r.doctor_uuid, r]));
}, [permissionsQ.data]);
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const resendInvMut = useMutation({
@@ -114,6 +179,75 @@ export default function ClinicDoctorsManager({
onError: (e: Error) => toast.error(e.message),
});
/**
* ستون‌ها دو «فعال بودن» را از هم جدا می‌کنند، چون دو چیز متفاوت‌اند و پیش از این
* یکی جای هر دو می‌نشست:
*
* • «دسترسی در کلینیک» → `ClinicDoctorPermission::active` — کلیدِ خودِ مالک کلینیک،
* و خاموش‌بودنش یعنی `can()` همه‌چیز را رد می‌کند.
* • «نوبت‌دهی آنلاین» → `Doctor::$activeDoctorAppointment && has_schedule` — حالِ
* پروفایلِ خودِ پزشک و ربطی به عضویتش ندارد.
*
* بجِ قبلی دومی را نشان می‌داد با متنِ «فعال/غیرفعال»، پس پزشکِ سالمِ بدون برنامهٔ
* هفتگی «غیرفعال» خوانده می‌شد و مالک ممکن بود بی‌دلیل جدایش کند.
*/
const doctorColumns: Column<ClinicDoctorItem>[] = useMemo(() => [
{
key: 'name',
header: 'پزشک',
render: (doc) => {
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const img = doc.img?.[0]?.url;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
{img
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
}
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{doc.name}</div>
{doc.specialties?.length > 0 && (
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
)}
</div>
</div>
);
},
},
{
// بج و شمارِ مجوز یک ستون‌اند نه دو: هر دو یک سؤال را جواب می‌دهند («این پزشک
// چقدر دسترسی دارد؟») و ستونِ کمتر یعنی جدولی که در موبایل هم جا می‌شود.
key: 'clinic_access',
header: 'دسترسی در کلینیک',
render: (doc) => {
// ردیفِ نبوده یعنی هنوز چیزی تنظیم نشده و پیش‌فرض‌ها برقرارند.
const row = permissionByDoctor.get(doc.uuid);
const on = row?.active ?? true;
const { granted, total } = grantedCount(row);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
<span className={`badge ${on ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{on ? 'فعال' : 'غیرفعال'}
</span>
<span className="muted" style={{ fontSize: 11 }}>
{row ? `${formatNumber(granted)} از ${formatNumber(total)} مجوز` : 'پیش‌فرض'}
</span>
</div>
);
},
},
{
key: 'online_booking',
header: 'نوبت‌دهی آنلاین',
render: (doc) => (
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
</span>
),
},
], [permissionByDoctor]);
const detachDoctorMut = useMutation({
mutationFn: (doctorUuid: string) =>
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
@@ -131,8 +265,9 @@ export default function ClinicDoctorsManager({
{/* Card header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="seg">
{/* شمارنده از meta می‌آید نه از طول آرایه؛ طول آرایه فقط صفحهٔ جاری است. */}
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
پزشکان ({formatNumber(doctorList.length)})
پزشکان ({formatNumber(doctorTotal)})
</button>
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
دعوتنامهها ({formatNumber(invitationList.length)})
@@ -147,67 +282,76 @@ export default function ClinicDoctorsManager({
{/* Doctors tab */}
{doctorsTab === 'doctors' && (
doctorList.length === 0 ? (
doctorsQ.isError ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
<p className="muted">فهرست پزشکان بارگذاری نشد</p>
<button className="btn secondary sm" onClick={() => doctorsQ.refetch()}>تلاش دوباره</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{doctorList.map(doc => {
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const img = doc.img?.[0]?.url;
return (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
{img
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
{doc.specialties?.length > 0 && (
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
</span>
<>
<DataTable<ClinicDoctorItem>
columns={doctorColumns}
data={doctorList}
loading={doctorsQ.isLoading}
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="جستجوی نام پزشک"
emptyMessage={debouncedSearch ? 'پزشکی با این نام پیدا نشد' : 'هیچ پزشکی به این کلینیک متصل نیست'}
actions={doc => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
className="mini-btn"
title="مشاهده پروفایل"
aria-label={`مشاهده پروفایل ${doc.name}`}
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && canUpdate && (
<button
className="mini-btn"
title=شاهده پروفایل"
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
title=دیریت دسترسی‌ها"
aria-label={`مدیریت دسترسی‌های ${doc.name}`}
onClick={() => setPermissionsFor(doc)}
>
<EyeIcon style={{ width: 14, height: 14 }} />
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && canUpdate && (
<button
className="mini-btn"
title="مدیریت دسترسی‌ها"
onClick={() => setPermissionsFor(doc)}
>
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
)}
{!readOnly && canDelete && (
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
</div>
)}
{!readOnly && canDelete && (
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
aria-label={`جداسازی ${doc.name} از کلینیک`}
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
</div>
);
})}
</div>
)}
/>
<Pagination
page={page}
total={doctorTotal}
limit={PAGE_SIZE}
onPageChange={setPage}
/>
</>
)
)}
{/* Invitations tab */}
{doctorsTab === 'invitations' && (
invitationList.length === 0 ? (
invitationsQ.isLoading ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">در حال بارگذاری</p>
</div>
) : invitationsQ.isError ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">فهرست دعوتنامهها بارگذاری نشد</p>
<button className="btn secondary sm" onClick={() => invitationsQ.refetch()}>تلاش دوباره</button>
</div>
) : invitationList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<EnvelopeIcon style={{ width: 30, height: 30 }} />
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
@@ -305,7 +449,11 @@ export default function ClinicDoctorsManager({
clinicUuid={clinicUuid}
doctorUuid={permissionsFor.uuid}
doctorName={permissionsFor.name}
onClose={() => setPermissionsFor(null)}
onClose={() => {
setPermissionsFor(null);
// ستون «دسترسی در کلینیک» و «مجوزها» باید تغییرِ همین مودال را نشان دهند.
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid] });
}}
/>
)}
@@ -58,7 +58,8 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
// ── مطب و کلینیک — چه کسی اینجا کار می‌کند و چه چیزی ارائه می‌شود ───────────
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, group: 'practice', to: '/admin/profile', roles: ['doctor'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, group: 'practice', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
{ key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, group: 'practice', to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
// مجوزش `clinic_info` است چون ذخیره‌اش روی PATCH /api/v1/clinic/{uuid} می‌نشیند.
{ key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, group: 'practice', to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['clinic_info', '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'] },