feat: refactor clinic management into a dedicated settings tab

- Removed MyClinicPage and redirected its functionality to a new ClinicDoctorsPage.
- Created ClinicDoctorsManager component for managing doctors and invitations within the settings layout.
- Updated backend permissions to allow clinic owners to detach doctors, alongside admins.
- Adjusted API documentation to reflect new permission structure.
- Updated tests to cover new functionality and permissions.
- Modified sidebar and settings menu to reflect the new structure and role-based visibility.
This commit is contained in:
hamed
2026-07-17 21:56:27 +03:30
parent 385b81fae0
commit 6ab7ed38b8
15 changed files with 731 additions and 318 deletions
@@ -0,0 +1,206 @@
# انتقال مدیریت کلینیک و پزشکان کلینیک به یک تب مجزا در تنظیمات (نقش‌محور)
## پروژه
`clinicpro` (پنل ادمین React + یک اصلاح کوچک permission در Backend Symfony — همان ریپو، cross-repo نیست).
## زمینه
کاربری که به‌عنوان **مدیر/مالک کلینیک** ثبت‌نام می‌کند `primaryRole === 'clinic'` می‌گیرد (برچسب «مالک کلینیک»). امروز در منوی تنظیمات یک تب به نام **«مدیریت مطب»** وجود دارد (`key: 'clinic'`) که به `/admin/my-clinic` می‌رود؛ آن صفحه بلافاصله به `/admin/clinics/{dbUuid}` = `ClinicDetailPage` **ری‌دایرکت** می‌کند. یعنی با کلیک روی تب تنظیمات، کاربر از پوسته‌ی تنظیمات (`SettingsLayout`) خارج می‌شود و به یک صفحه‌ی جنریک ادمین (همان صفحه‌ای که مدیرکل برای هر کلینیک می‌بیند) پرتاب می‌شود. این صفحه هم مدیریت اطلاعات کلینیک و هم مدیریت پزشکانِ کلینیک (لیست/دعوت/تعلیق/حذف دعوتنامه/جداسازی پزشک) را در خود دارد.
دو مشکل:
1. مدیریت پزشکانِ کلینیک به‌جای اینکه یک تب مستقل و تمیز داخل تنظیمات باشد، داخل یک صفحه‌ی بزرگ ادمین قاطی شده و کاربر را از تنظیمات بیرون می‌برد.
2. تب «مدیریت مطب» در **sidebar دسکتاپِ تنظیمات** (`PurchaseSubscriptionSidebar`) اصلاً نقش‌محور نیست و برای همه (از جمله پزشک مهمان) نمایش داده می‌شود — این در `SettingsLayout.test.tsx` هم به‌عنوان رفتار فعلی ثبت شده.
## هدف
1. یک **تب مجزا** در تنظیمات به نام **«پزشکان کلینیک»** ساخته شود که کل مدیریت کلینیک و پزشکانِ کلینیک را **داخل پوسته‌ی تنظیمات** (`SettingsLayout`) در دسترس بگذارد.
2. تب فعلی **«مدیریت مطب»** به‌طور کامل از هر دو منوی تنظیمات حذف شود (موبایل: `SETTINGS_MENU`؛ دسکتاپ: `PurchaseSubscriptionSidebar`).
3. تمام امکانات مدیریت کلینیک + پزشکانِ کلینیک از همان تب جدید در دسترس باشد.
4. کنترل دسترسی نقش‌محور:
* **مدیر کلینیک** (`primaryRole === 'clinic'`): افزودن، ویرایش، حذف/جداسازی و مدیریت کامل پزشکانِ کلینیک.
* **پزشک** (`primaryRole === 'doctor'`): این تب مدیریتی را **اصلاً نبیند** و به تنظیمات مدیریتی کلینیک دسترسی نداشته باشد؛ فقط بخش‌های مربوط به خودش (پروفایل/نوبت‌دهی/دعوت‌نامه‌های دریافتی خودش که جای دیگری است).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/components/layout/SettingsLayout.tsx` | منبع حقیقت `SETTINGS_MENU` (منوی موبایل) + `menuForRole()` + پوسته‌ی تنظیمات |
| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx` | sidebar دسکتاپِ تنظیمات؛ `NAV_ITEMS` مستقل و **بدون نقش‌گِیت** |
| `assets/admin/pages/MyClinicPage.tsx` | تب فعلی «مدیریت مطب» → فقط ری‌دایرکت به `ClinicDetailPage` |
| `assets/admin/pages/ClinicDetailPage.tsx` | صفحه‌ی جنریک ادمین؛ بلوک «پزشکان + دعوتنامه‌ها» (خطوط ~۴۷۶–۹۹۰) منبع کد قابل‌استخراج |
| `assets/admin/App.tsx` | جدول route؛ `RoleRoute` (خط ۱۱۷) و route `my-clinic` (خط ۱۹۱) |
| `assets/admin/stores/authStore.ts` | `primaryRole`, `dbUuid`, `context` (`ContextItem.scope`) |
| `assets/admin/components/layout/SettingsLayout.test.tsx` | تست رفتار فعلی نمایش «مدیریت مطب» |
| `assets/admin/pages/SettingsMenuPage.test.tsx` | تست منوی موبایل |
| `assets/admin/components/layout/Sidebar.tsx` (خط ~۲۰۶) | لینک `/admin/my-clinic` در نویگیشن اصلی |
| `src/Clinic/Controller/ClinicController.php` (خط ۳۵۵–۳۵۶) | endpoint جداسازی پزشک — **`ROLE_ADMIN` only** |
| `src/ClinicInvitation/Controller/ClinicInvitationController.php` | endpointهای دعوت/لیست/تعلیق/حذف دعوتنامه (`IS_AUTHENTICATED_FULLY`) |
## وضعیت فعلی
### منوی تنظیمات موبایل — `SettingsLayout.tsx`
```tsx
export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' },
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] },
{ key: 'clinic', label: 'مدیریت مطب', icon: BuildingOffice2Icon, to: '/admin/my-clinic', roles: ['clinic'] },
// ...
];
export function menuForRole(role: string | null | undefined): SettingsMenuItem[] {
return SETTINGS_MENU.filter((i) => !i.roles || (role != null && i.roles.includes(role)));
}
```
### sidebar دسکتاپ — `PurchaseSubscriptionSidebar.tsx` (بدون نقش‌گِیت!)
```tsx
const NAV_ITEMS: NavItem[] = [
// ...
{ key: 'clinic', label: 'مدیریت مطب', to: '/admin/my-clinic' }, // برای همه‌ی نقش‌ها دیده می‌شود
// ...
];
export default function PurchaseSubscriptionSidebar({ active }: { active: string }) { /* هیچ نقشی نمی‌گیرد */ }
```
### تب فعلی — `MyClinicPage.tsx` (فقط ری‌دایرکت، از تنظیمات خارج می‌شود)
```tsx
function MyClinicPageContent() {
const { dbUuid, fetchMe } = useAuthStore();
const navigate = useNavigate();
useEffect(() => {
if (dbUuid) navigate(`/admin/clinics/${dbUuid}`, { replace: true }); // ← به ClinicDetailPage می‌پرد
// ...
}, [dbUuid, fetchMe, navigate]);
// ...
}
```
### route فعلی — `App.tsx:191`
```tsx
<Route path="my-clinic" element={<RoleRoute roles={['clinic']}><MyClinicPage /></RoleRoute>} />
```
### endpointهای موجود مدیریت پزشکانِ کلینیک (از `ClinicDetailPage.tsx`)
```
GET /api/v1/clinic/doctor-list/{uuid} لیست پزشکان کلینیک
GET /api/v1/admin/clinic/{uuid}/invitations?limit=50 لیست دعوتنامه‌ها
POST /api/v1/admin/clinic/{uuid}/invite-doctor دعوت پزشک (InviteDoctorModal)
POST /api/v1/admin/clinic/invitation/{invUuid}/resend ارسال مجدد
PATCH /api/v1/admin/clinic/invitation/{invUuid}/status تعلیق/فعال
DELETE /api/v1/admin/clinic/invitation/{invUuid} حذف دعوتنامه
DELETE /api/v1/admin/clinic/{uuid}/doctor/{doctorUuid} جداسازی پزشک ← ROLE_ADMIN only ⚠️
```
### مشکل permission در Backend — `ClinicController.php:355`
```php
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')] // ← مالک کلینیک نمی‌تواند پزشک را جدا کند
public function detachDoctor(...) { ... }
```
## وظایف
### ۱. Backend — اجازه‌ی جداسازی پزشک به مالک کلینیک
هدف task شماره ۴ این است که مدیر کلینیک بتواند پزشک را حذف/جدا کند، ولی endpoint جداسازی الان `ROLE_ADMIN` است.
- در `src/Clinic/Controller/ClinicController.php` متد `detachDoctor` (خط ۳۵۵): گارد `#[IsGranted('ROLE_ADMIN')]` را به `#[IsGranted('IS_AUTHENTICATED_FULLY')]` تغییر بده و **داخل متد/سرویس یک بررسی مالکیت** اضافه کن: کاربر فعلی یا ادمین باشد یا مالک همان کلینیک (`clinicUuid`). اگر نه → `throw new AppException(ErrorCodes::ERR_FORBIDDEN, null, 403)`.
- **اول بگرد**: احتمالاً همین الگوی بررسی مالکیت در `invite-doctor`/`invitations` (که `IS_AUTHENTICATED_FULLY` هستند) در سرویس `ClinicInvitation` وجود دارد — همان helper را دوباره استفاده کن، کد جدید ننویس.
- endpointهای دعوتنامه را هم بررسی کن که مالک کلینیک (نه فقط ادمین) بتواند صدایشان بزند؛ اگر بررسی مالکیت ندارند، همان helper را اضافه کن.
- پس از تغییر، فایل `docs/api/clinic.md` (و در صورت لزوم مستندِ ClinicInvitation) را در همین session به‌روزرسانی کن (Standing Rule).
- تست: PHPUnit برای سه حالت — مالک کلینیک (موفق)، پزشک/کاربر غیرمالک (۴۰۳)، ادمین (موفق).
> اگر بررسی مالکیت روی این endpointها از قبل به‌شکل کامل وجود دارد، فقط گارد `ROLE_ADMIN` را شل کن و دلیلش را در توضیح PR/commit بنویس.
### ۲. استخراج بلوک مدیریت پزشکان به یک کامپوننت مشترک (SOLID)
`ClinicDetailPage.tsx` بلوک «پزشکان + دعوتنامه‌ها» را در خطوط ~۸۴۷–۹۹۰ دارد (tab پزشکان/دعوتنامه‌ها، دعوت، ارسال مجدد، تعلیق، حذف دعوتنامه، جداسازی پزشک، `InviteDoctorModal`، `ConfirmDialog` جداسازی). این منطق نباید کپی شود.
- یک کامپوننت جدید بساز: `assets/admin/components/ClinicDoctorsManager.tsx` با prop `clinicUuid: string` و `readOnly?: boolean`.
- تمام state/queryها/mutationهای مربوط به `doctorsQ`, `invitationsQ`, `resendInvMut`, `changeInvStatusMut`, `deleteInvMut`, `detachDoctorMut`, `inviteOpen`, `detachDoctorConfirm` را به این کامپوننت منتقل کن (از `ClinicDetailPage` بردار).
- `ClinicDetailPage.tsx` را ریفکتور کن تا همین کامپوننت مشترک را با `clinicUuid={uuid}` رندر کند (رفتار صفحه‌ی ادمین نباید تغییر کند).
- endpointها و envelopeها دقیقاً همان‌های فعلی (`data?.data ?? raw`).
### ۳. صفحه‌ی تنظیماتِ تب جدید — `ClinicDoctorsPage.tsx`
- فایل جدید: `assets/admin/pages/ClinicDoctorsPage.tsx`.
- `dbUuid` مالک کلینیک را از `useAuthStore` بگیر (اگر خالی بود `fetchMe()` مثل `MyClinicPage`). سپس **داخل** `SettingsLayout` رندر کن — نه ری‌دایرکت:
```tsx
export default function ClinicDoctorsPage() {
const { dbUuid, fetchMe } = useAuthStore();
useEffect(() => { if (!dbUuid) fetchMe(); }, [dbUuid, fetchMe]);
return (
<SettingsLayout active="clinic-doctors">
{dbUuid
? <ClinicDoctorsManager clinicUuid={dbUuid} />
: <div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
</div>}
</SettingsLayout>
);
}
```
- اگر می‌خواهی ویرایش اطلاعات کلینیک (نام/تلفن/تخصص‌ها/بیمه/گالری/آدرس) هم زیر همین تب باشد (task: «تمام امکانات مدیریت کلینیک»)، یک دکمه/لینک «ویرایش اطلاعات کلینیک» به همان `ClinicDetailPage` بگذار یا آن بلوک‌ها را هم به کامپوننت مشترک اضافه کن. **پیشنهاد:** برای این iteration فقط مدیریت پزشکان + دعوت را داخل تب بیاور و ویرایش اطلاعات کلینیک را با یک لینک به صفحه‌ی موجود نگه‌دار تا صفحه‌ی تنظیمات سبک بماند؛ اگر کاربر مدیریت کامل خواست، در وظیفه‌ی جدا انجام شود.
### ۴. route جدید + حذف route قدیمی — `App.tsx`
- route جدید (به‌جای/کنار `my-clinic`) با گارد نقش:
```tsx
<Route
path="settings/clinic-doctors"
element={
<RoleRoute roles={['clinic']} blockClinicScope>
<ClinicDoctorsPage />
</RoleRoute>
}
/>
```
- `RoleRoute` قبلاً `roles=['clinic']` را چک می‌کند و با `blockClinicScope` پزشکِ مهمان در scope کلینیک را هم رد می‌کند — همین برای task شماره ۴ کافی است (پزشک به تب مدیریتی نمی‌رسد).
- route قدیمی `my-clinic` و `MyClinicPage` را حذف کن؛ اگر لینک قدیمی ممکن است جایی باز شود، یک ری‌دایرکت از `my-clinic` به `settings/clinic-doctors` بگذار.
### ۵. به‌روزرسانی هر دو منوی تنظیمات
- در `SettingsLayout.tsx` آیتم `{ key:'clinic', label:'مدیریت مطب', ... }` را حذف و جایگزین کن با:
```tsx
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
```
- در `PurchaseSubscriptionSidebar.tsx`:
- آیتم `{ key:'clinic', label:'مدیریت مطب', to:'/admin/my-clinic' }` را حذف و با `{ key:'clinic-doctors', label:'پزشکان کلینیک', to:'/admin/settings/clinic-doctors' }` جایگزین کن.
- این sidebar را **نقش‌محور** کن: `primaryRole` را از `useAuthStore` بگیر و `NAV_ITEMS` را با همان منطق `menuForRole` فیلتر کن (به `NavItem` فیلد اختیاری `roles?: string[]` اضافه کن و به آیتم `clinic-doctors` بده `roles: ['clinic']`، به آیتم `doctor`/`appointment` هم `roles:['doctor']` مطابق `SETTINGS_MENU`). این باعث می‌شود پزشک تب «پزشکان کلینیک» را در دسکتاپ هم نبیند (رفع باگ فعلی).
### ۶. اصلاح لینک نویگیشن اصلی — `Sidebar.tsx`
- خط ~۲۰۶ که `/admin/my-clinic` می‌سازد را به `/admin/settings/clinic-doctors` تغییر بده (فقط برای نقش `clinic`). منطق نقش همان‌جا را حفظ کن.
### ۷. تست‌ها
- `SettingsLayout.test.tsx`: تست فعلی که انتظار دارد «مدیریت مطب» دیده شود را به‌روز کن — حالا:
* برای `role='clinic'` باید «پزشکان کلینیک» دیده شود و «مدیریت مطب» **نباشد**.
* برای `role='doctor'` باید «پزشکان کلینیک» **دیده نشود** (چون دسکتاپ حالا نقش‌محور است — کامنت قدیمیِ «desktop sidebar is not role-gated» را هم اصلاح کن).
- `SettingsMenuPage.test.tsx`: `menuForRole('doctor')` نباید `clinic-doctors` بدهد؛ `menuForRole('clinic')` باید بدهد.
- تست جدید برای `ClinicDoctorsManager` (رندر لیست پزشکان از mock، نمایش دکمه‌های مدیریت وقتی `readOnly` نیست).
- `yarn test` و `npx tsc --noEmit` باید سبز شوند.
## نکات مهم
- **نقش‌ها:** `admin` (مدیرکل) · `clinic` (مالک/مدیر کلینیک — برچسب «مالک کلینیک») · `doctor` · `secretary` · `representation` · `user`. «مدیر کلینیک» در این سیستم = `primaryRole === 'clinic'`. پزشکِ مهمانِ دعوت‌شده = `doctor` با `context.scope === 'clinic'` که با `blockClinicScope` در `RoleRoute` رد می‌شود.
- **envelope:** لیست پزشکان و دعوتنامه‌ها با `data?.data ?? raw` استخراج می‌شوند (double-nest احتمالی). دقیقاً از الگوی فعلی `ClinicDetailPage` کپی کن، تغییر نده.
- **تاریخ‌ها:** timestampهای Unix (مثل `expires_at`)؛ انقضا با `Date.now()/1000 > inv.expires_at` سنجیده می‌شود — همین را نگه‌دار.
- **SearchableSelect:** طبق قانون پروژه هر جا `select` لازم شد از `SearchableSelect` استفاده کن، نه `<select>` بومی.
- **رشته‌ها فارسی، RTL.** دکمه‌ها/بج‌ها/آیکن‌ها از همان کلاس‌های موجود (`btn`, `badge`, `mini-btn`, `seg`).
- **گارد سطح UI کافی نیست:** چون پزشک نباید بتواند مدیریت کند، هم UI را گِیت کن (`RoleRoute` + منوی نقش‌محور) و هم Backend را (وظیفه‌ی ۱). بدون وظیفه‌ی ۱، دکمه‌ی «جداسازی پزشک» برای مالک کلینیک ۴۰۳ می‌دهد.
- **SOLID:** منطق مدیریت پزشکان فقط در `ClinicDoctorsManager` باشد؛ نه در `ClinicDetailPage` کپی بماند نه در `ClinicDoctorsPage` دوباره نوشته شود.
- **بعد از اتمام:** `graphify update .` برای به‌روز نگه‌داشتن گراف (طبق قانون پروژه).
+5 -3
View File
@@ -31,7 +31,7 @@ import CategoriesPage from './pages/CategoriesPage';
import BlogsPage from './pages/BlogsPage';
import BlogFormPage from './pages/BlogFormPage';
import SecretariesPage from './pages/SecretariesPage';
import MyClinicPage from './pages/MyClinicPage';
import ClinicDoctorsPage from './pages/ClinicDoctorsPage';
import SettingsPage from './pages/SettingsPage';
import FinancialReportPage from './pages/FinancialReportPage';
import RepresentationSettlementPage from './pages/RepresentationSettlementPage';
@@ -187,8 +187,10 @@ export default function App() {
<Route path="clinics" element={<RoleRoute roles={['admin', 'representation']}><ClinicsPage /></RoleRoute>} />
<Route path="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
{/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */}
<Route path="my-clinic" element={<RoleRoute roles={['clinic']}><MyClinicPage /></RoleRoute>} />
{/* پزشکان کلینیک — تب تنظیماتِ مالک کلینیک */}
<Route path="settings/clinic-doctors" element={<RoleRoute roles={['clinic']} blockClinicScope><ClinicDoctorsPage /></RoleRoute>} />
{/* مسیر قدیمی «مدیریت مطب» → ریدایرکت به تب جدید */}
<Route path="my-clinic" element={<Navigate to="/admin/settings/clinic-doctors" replace />} />
{/* ادمین + کلینیک */}
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'representation']}><ClinicDetailPage /></RoleRoute>} />
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } 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>;
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: [] });
});
});
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();
});
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();
});
});
@@ -0,0 +1,284 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from './ui/ConfirmDialog';
import InviteDoctorModal from './ui/InviteDoctorModal';
const HUES_LIST = [256, 205, 162, 295, 272];
export interface ClinicDoctorItem {
id: string; uuid: string; name: string;
gender: string | null; degree: string | null;
img: { url: string }[];
specialties: { id: string; name: string }[];
active: boolean;
}
export interface ClinicInvitation {
uuid: string;
mobile: string;
invited_name: string | null;
invited_specialty: string | null;
status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed';
token_used: boolean;
invited_at: number;
expires_at: number;
responded_at: number | null;
doctor: { uuid: string; name: string } | null;
}
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
pending: { label: 'در انتظار', cls: 'amber' },
accepted: { label: 'پذیرفته‌شده', cls: 'green' },
rejected: { label: 'رد شده', cls: 'gray' },
suspended: { label: 'تعلیق', cls: 'violet' },
removed: { label: 'حذف‌شده', cls: 'gray' },
};
/**
* ClinicDoctorsManager — self-contained management of a clinic's doctors and
* pending invitations (list, invite, resend, suspend, delete invitation, detach
* doctor). Reused by both the admin ClinicDetailPage and the clinic-owner
* settings tab (ClinicDoctorsPage). `readOnly` hides every mutating control.
*/
export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
clinicUuid: string;
readOnly?: boolean;
}) {
const navigate = useNavigate();
const qc = useQueryClient();
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
const [inviteOpen, setInviteOpen] = useState(false);
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
enabled: !!clinicUuid,
});
const invitationsQ = useQuery({
queryKey: ['clinic-invitations', clinicUuid],
queryFn: () => api.get<PaginatedResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${clinicUuid}/invitations?limit=50`),
enabled: !!clinicUuid,
});
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const resendInvMut = useMutation({
mutationFn: (invUuid: string) => api.post<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}),
onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const changeInvStatusMut = useMutation({
mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }),
onSuccess: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const deleteInvMut = useMutation({
mutationFn: (invUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/invitation/${invUuid}`),
onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const detachDoctorMut = useMutation({
mutationFn: (doctorUuid: string) =>
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
onSuccess: () => {
toast.success('پزشک از کلینیک جدا شد');
qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] });
qc.invalidateQueries({ queryKey: ['clinic-detail', clinicUuid] });
},
onError: (e: Error) => toast.error(e.message),
});
return (
<>
<div className="card card-pad">
{/* Card header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="seg">
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
پزشکان ({formatNumber(doctorList.length)})
</button>
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
دعوتنامهها ({formatNumber(invitationList.length)})
</button>
</div>
{!readOnly && (
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
</button>
)}
</div>
{/* Doctors tab */}
{doctorsTab === 'doctors' && (
doctorList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
</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>
<button
className="mini-btn"
title="مشاهده پروفایل"
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && (
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
</div>
</div>
);
})}
</div>
)
)}
{/* Invitations tab */}
{doctorsTab === 'invitations' && (
invitationList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<EnvelopeIcon style={{ width: 30, height: 30 }} />
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{invitationList.map(inv => {
const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' };
const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at;
return (
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{inv.invited_name ?? inv.mobile}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>{inv.mobile}</span>
{inv.invited_specialty && (
<span className="muted" style={{ fontSize: 11 }}>{inv.invited_specialty}</span>
)}
{inv.doctor && (
<button
className="badge blue"
style={{ fontSize: 11, cursor: 'pointer', border: 'none', background: 'none', padding: 0 }}
onClick={() => navigate(`/admin/doctors/${inv.doctor!.uuid}`)}
>
<span className="bdot" />{inv.doctor.name}
</button>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<span className={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
</span>
{!readOnly && (
<div style={{ display: 'flex', gap: 4 }}>
{inv.status === 'pending' && (
<button
className="mini-btn"
title="ارسال مجدد"
disabled={resendInvMut.isPending}
onClick={() => resendInvMut.mutate(inv.uuid)}
>
<ArrowPathIcon style={{ width: 13, height: 13 }} />
</button>
)}
{inv.status !== 'removed' && inv.status !== 'accepted' && (
<button
className="mini-btn"
title="تعلیق"
disabled={changeInvStatusMut.isPending}
onClick={() => changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
>
<NoSymbolIcon style={{ width: 13, height: 13 }} />
</button>
)}
<button
className="mini-btn danger"
title="حذف"
disabled={deleteInvMut.isPending}
onClick={() => deleteInvMut.mutate(inv.uuid)}
>
<TrashIcon style={{ width: 13, height: 13 }} />
</button>
</div>
)}
</div>
</div>
);
})}
</div>
)
)}
</div>
{/* Detach Doctor From Clinic Confirm */}
<ConfirmDialog
open={detachDoctorConfirm !== null}
title="جداسازی پزشک از کلینیک"
message={`آیا پزشک "${detachDoctorConfirm?.name ?? ''}" از این کلینیک جدا شود؟ این کار فقط ارتباط پزشک با این کلینیک را حذف می‌کند.`}
confirmLabel="جداسازی"
danger
loading={detachDoctorMut.isPending}
onConfirm={() => {
if (detachDoctorConfirm) detachDoctorMut.mutate(detachDoctorConfirm.uuid);
setDetachDoctorConfirm(null);
}}
onCancel={() => setDetachDoctorConfirm(null)}
/>
{/* Invite doctor modal */}
{inviteOpen && clinicUuid && (
<InviteDoctorModal
clinicUuid={clinicUuid}
onClose={() => setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); setDoctorsTab('invitations'); }}
/>
)}
</>
);
}
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { SearchHeaderP } from '../../pages/subscriptionIcons';
import { useAuthStore } from '../../stores/authStore';
/**
* Settings sub-navigation for the subscription page — item list and order copied
@@ -12,28 +13,32 @@ import { SearchHeaderP } from '../../pages/subscriptionIcons';
* This list is intentionally separate from SETTINGS_MENU (SettingsLayout) so the
* other settings pages are not affected.
*/
type NavItem = { key: string; label: string; to?: string };
/** `roles`: when set, the item is only shown to those roles (omit = every role). */
type NavItem = { key: string; label: string; to?: string; roles?: string[] };
const NAV_ITEMS: NavItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription' },
{ key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial' },
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings' },
{ key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing' },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', to: '/admin/discounts' },
{ key: 'tags', label: 'تگ ها', to: '/admin/tags-settings' },
{ key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet' },
{ key: 'clinic', label: 'مدیریت مطب', to: '/admin/my-clinic' },
{ key: 'secretary', label: 'مدیریت منشی', to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', to: '/admin/staff' },
{ key: 'account', label: 'حساب کاربری', to: '/admin/account-settings' },
{ key: 'security', label: 'تنظیمات' },
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription' },
{ key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial' },
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },
{ key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing' },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', to: '/admin/discounts', roles: ['doctor', 'clinic'] },
{ key: 'tags', label: 'تگ ها', to: '/admin/tags-settings' },
{ key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet' },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
{ key: 'secretary', label: 'مدیریت منشی', to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', to: '/admin/staff' },
{ key: 'account', label: 'حساب کاربری', to: '/admin/account-settings' },
{ key: 'security', label: 'تنظیمات' },
];
export default function PurchaseSubscriptionSidebar({ active }: { active: string }) {
const [query, setQuery] = useState('');
const primaryRole = useAuthStore((s) => s.primaryRole);
const items = useMemo(
() => NAV_ITEMS.filter((i) => i.label.includes(query.trim())),
[query],
() => NAV_ITEMS
.filter((i) => !i.roles || (primaryRole != null && i.roles.includes(primaryRole)))
.filter((i) => i.label.includes(query.trim())),
[query, primaryRole],
);
return (
@@ -34,10 +34,17 @@ describe('SettingsLayout', () => {
expect(screen.queryByText('مدیریت پزشک')).not.toBeInTheDocument();
});
it('shows the same menu to every role (ungated, mirroring tauri)', () => {
// the desktop sidebar is not role-gated: مدیریت مطب shows even for a doctor
it('role-gates the desktop sidebar: a doctor does not see the clinic-doctors tab', () => {
renderWithProviders(<SettingsLayout active="subscription"><div /></SettingsLayout>);
expect(screen.getByText('مدیریت مطب')).toBeInTheDocument();
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
// the removed 'مدیریت مطب' tab must be gone for everyone
expect(screen.queryByText('مدیریت مطب')).not.toBeInTheDocument();
});
it('shows the clinic-doctors tab to a clinic owner', () => {
useAuthStore.setState({ primaryRole: 'clinic' });
renderWithProviders(<SettingsLayout active="clinic-doctors"><div /></SettingsLayout>);
expect(screen.getByText('پزشکان کلینیک').closest('a')).toHaveAttribute('href', '/admin/settings/clinic-doctors');
});
it('filters the menu by the search query', () => {
@@ -49,8 +56,10 @@ describe('SettingsLayout', () => {
it('menuForRole still role-gates the mobile settings list', () => {
// SettingsMenuPage (mobile) keeps using menuForRole / SETTINGS_MENU
expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic');
expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic-doctors');
expect(menuForRole('clinic').map((i) => i.key)).not.toContain('doctor');
expect(menuForRole('clinic').map((i) => i.key)).not.toContain('appointment');
// a plain doctor must not get the clinic-doctors management tab
expect(menuForRole('doctor').map((i) => i.key)).not.toContain('clinic-doctors');
});
});
@@ -23,7 +23,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' },
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] },
{ key: 'clinic', label: 'مدیریت مطب', icon: BuildingOffice2Icon, to: '/admin/my-clinic', roles: ['clinic'] },
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial' },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff' },
+1 -1
View File
@@ -203,7 +203,7 @@ function buildSections(
if (primaryRole === "clinic") {
const clinicTo = dbUuid
? `/admin/clinics/${dbUuid}`
: "/admin/my-clinic";
: "/admin/settings/clinic-doctors";
return [
{
label: "عمومی",
+4 -255
View File
@@ -9,19 +9,18 @@ import {
ArrowRightIcon, PencilIcon, TrashIcon,
BuildingOffice2Icon, PhoneIcon, MapPinIcon, XMarkIcon,
PlusIcon, CameraIcon, ChevronDownIcon,
EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon,
} from '@heroicons/react/24/outline';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { ClinicDetail } from '../types';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
// Fix leaflet icons
@@ -38,27 +37,6 @@ const MAX_GALLERY_IMAGES = 5;
// ── Types ──────────────────────────────────────────────────────────────────
interface ClinicInvitation {
uuid: string;
mobile: string;
invited_name: string | null;
invited_specialty: string | null;
status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed';
token_used: boolean;
invited_at: number;
expires_at: number;
responded_at: number | null;
doctor: { uuid: string; name: string } | null;
}
interface ClinicDoctorItem {
id: string; uuid: string; name: string;
gender: string | null; degree: string | null;
img: { url: string }[];
specialties: { id: string; name: string }[];
active: boolean;
}
interface ClinicAddress {
id: string; uuid: string;
name: string | null; address: string | null;
@@ -460,17 +438,6 @@ function EditModal({ clinic, onClose, onSaved }: {
);
}
// ── Invitation status badge ────────────────────────────────────────────────
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
pending: { label: 'در انتظار', cls: 'amber' },
accepted: { label: 'پذیرفته‌شده', cls: 'green' },
rejected: { label: 'رد شده', cls: 'gray' },
suspended: { label: 'تعلیق', cls: 'violet' },
removed: { label: 'حذف‌شده', cls: 'gray' },
};
// ── Main Page ──────────────────────────────────────────────────────────────
export default function ClinicDetailPage() {
@@ -486,8 +453,6 @@ export default function ClinicDetailPage() {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
const logoInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null);
const [logoUploading, setLogoUploading] = useState(false);
@@ -499,18 +464,6 @@ export default function ClinicDetailPage() {
enabled: !!uuid,
});
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', uuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${uuid}`),
enabled: !!uuid,
});
const invitationsQ = useQuery({
queryKey: ['clinic-invitations', uuid],
queryFn: () => api.get<PaginatedResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${uuid}/invitations?limit=50`),
enabled: !!uuid,
});
const clinicAddressesQ = useQuery({
queryKey: ['clinic-addresses', uuid],
queryFn: () => api.get<ApiResponse<ClinicAddress[]>>(`/api/v1/clinic/${uuid}/addresses`),
@@ -522,7 +475,6 @@ export default function ClinicDetailPage() {
const [editingClinicAddr, setEditingClinicAddr] = useState<ClinicAddress | null>(null);
const [deleteAddrConfirm, setDeleteAddrConfirm] = useState<ClinicAddress | null>(null);
const [deleteImageConfirm, setDeleteImageConfirm] = useState<string | null>(null);
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
const emptyAddrForm = { name: '', address: '', telephone: '', province_id: null as number | null, city_id: null as number | null, latitude: null as number | null, longitude: null as number | null };
const [addrForm, setAddrForm] = useState(emptyAddrForm);
@@ -586,13 +538,6 @@ export default function ClinicDetailPage() {
return (raw as any)?.data ?? raw;
}, [data]);
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const openEdit = () => setEditOpen(true);
const toggleMut = useMutation({
@@ -611,36 +556,6 @@ export default function ClinicDetailPage() {
onError: (e: Error) => toast.error(e.message),
});
const resendInvMut = useMutation({
mutationFn: (invUuid: string) => api.post<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}),
onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const changeInvStatusMut = useMutation({
mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }),
onSuccess: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const deleteInvMut = useMutation({
mutationFn: (invUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/invitation/${invUuid}`),
onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const detachDoctorMut = useMutation({
mutationFn: (doctorUuid: string) =>
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${uuid}/doctor/${doctorUuid}`),
onSuccess: () => {
toast.success('پزشک از کلینیک جدا شد');
qc.invalidateQueries({ queryKey: ['clinic-doctors', uuid] });
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
},
onError: (e: Error) => toast.error(e.message),
});
const handleLogoUpload = async (file: File) => {
setLogoUploading(true);
try {
@@ -844,150 +759,8 @@ export default function ClinicDetailPage() {
)}
</div>
{/* Doctors + Invitations card */}
<div className="card card-pad">
{/* Card header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="seg">
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
پزشکان ({formatNumber(doctorList.length)})
</button>
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
دعوتنامهها ({formatNumber(invitationList.length)})
</button>
</div>
{!isReadOnly && (
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
</button>
)}
</div>
{/* Doctors tab */}
{doctorsTab === 'doctors' && (
doctorList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
</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>
<button
className="mini-btn"
title="مشاهده پروفایل"
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
</div>
</div>
);
})}
</div>
)
)}
{/* Invitations tab */}
{doctorsTab === 'invitations' && (
invitationList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<EnvelopeIcon style={{ width: 30, height: 30 }} />
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{invitationList.map(inv => {
const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' };
const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at;
return (
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{inv.invited_name ?? inv.mobile}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>{inv.mobile}</span>
{inv.invited_specialty && (
<span className="muted" style={{ fontSize: 11 }}>{inv.invited_specialty}</span>
)}
{inv.doctor && (
<button
className="badge blue"
style={{ fontSize: 11, cursor: 'pointer', border: 'none', background: 'none', padding: 0 }}
onClick={() => navigate(`/admin/doctors/${inv.doctor!.uuid}`)}
>
<span className="bdot" />{inv.doctor.name}
</button>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<span className={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
</span>
{!isReadOnly && (
<div style={{ display: 'flex', gap: 4 }}>
{inv.status === 'pending' && (
<button
className="mini-btn"
title="ارسال مجدد"
disabled={resendInvMut.isPending}
onClick={() => resendInvMut.mutate(inv.uuid)}
>
<ArrowPathIcon style={{ width: 13, height: 13 }} />
</button>
)}
{inv.status !== 'removed' && inv.status !== 'accepted' && (
<button
className="mini-btn"
title="تعلیق"
disabled={changeInvStatusMut.isPending}
onClick={() => changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
>
<NoSymbolIcon style={{ width: 13, height: 13 }} />
</button>
)}
<button
className="mini-btn danger"
title="حذف"
disabled={deleteInvMut.isPending}
onClick={() => deleteInvMut.mutate(inv.uuid)}
>
<TrashIcon style={{ width: 13, height: 13 }} />
</button>
</div>
)}
</div>
</div>
);
})}
</div>
)
)}
</div>
{/* Doctors + Invitations — shared manager */}
<ClinicDoctorsManager clinicUuid={uuid!} readOnly={isReadOnly} />
{/* Gallery */}
<div className="card card-pad">
@@ -1287,21 +1060,6 @@ export default function ClinicDetailPage() {
onCancel={() => setDeleteImageConfirm(null)}
/>
{/* Detach Doctor From Clinic Confirm */}
<ConfirmDialog
open={detachDoctorConfirm !== null}
title="جداسازی پزشک از کلینیک"
message={`آیا پزشک "${detachDoctorConfirm?.name ?? ''}" از این کلینیک جدا شود؟ این کار فقط ارتباط پزشک با این کلینیک را حذف می‌کند.`}
confirmLabel="جداسازی"
danger
loading={detachDoctorMut.isPending}
onConfirm={() => {
if (detachDoctorConfirm) detachDoctorMut.mutate(detachDoctorConfirm.uuid);
setDetachDoctorConfirm(null);
}}
onCancel={() => setDetachDoctorConfirm(null)}
/>
{/* Edit modal — self-portals to escape transform context */}
{editOpen && (
<EditModal
@@ -1311,15 +1069,6 @@ export default function ClinicDetailPage() {
/>
)}
{/* Invite doctor modal */}
{inviteOpen && uuid && (
<InviteDoctorModal
clinicUuid={uuid}
onClose={() => setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
/>
)}
{/* Delete confirm */}
<ConfirmDialog
open={deleteOpen}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { PencilIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
/**
* ClinicDoctorsPage — the clinic-owner settings tab for managing the clinic's
* doctors (list, invite, detach) and pending invitations. Renders inside the
* settings shell instead of jumping to the standalone admin ClinicDetailPage.
* Clinic-info editing (name, gallery, addresses, specialties) stays on the full
* detail page, reachable via the "ویرایش اطلاعات کلینیک" link.
*/
function ClinicDoctorsContent() {
const { dbUuid, fetchMe } = useAuthStore();
useEffect(() => {
if (!dbUuid) fetchMe();
}, [dbUuid, fetchMe]);
if (!dbUuid) {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
</div>
);
}
return (
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
<div className="card-title-row">
<div>
<h1 className="section-title">پزشکان کلینیک</h1>
<div className="muted">مدیریت پزشکان و دعوتنامههای کلینیک</div>
</div>
<Link className="btn ghost sm" to={`/admin/clinics/${dbUuid}`}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش اطلاعات کلینیک
</Link>
</div>
<ClinicDoctorsManager clinicUuid={dbUuid} />
</div>
);
}
export default function ClinicDoctorsPage() {
return (
<SettingsLayout active="clinic-doctors">
<ClinicDoctorsContent />
</SettingsLayout>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import SettingsLayout from '../components/layout/SettingsLayout';
function MyClinicPageContent() {
const { dbUuid, fetchMe } = useAuthStore();
const navigate = useNavigate();
useEffect(() => {
if (dbUuid) {
navigate(`/admin/clinics/${dbUuid}`, { replace: true });
} else {
fetchMe().then(() => {
const uuid = useAuthStore.getState().dbUuid;
if (uuid) navigate(`/admin/clinics/${uuid}`, { replace: true });
});
}
}, [dbUuid, fetchMe, navigate]);
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
</div>
);
}
export default function MyClinicPage() {
return (
<SettingsLayout active="clinic">
<MyClinicPageContent />
</SettingsLayout>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ describe('SettingsMenuPage', () => {
expect(screen.getByText(item.label)).toBeInTheDocument();
}
// clinic-only section is not shown to a doctor
expect(screen.queryByText('مدیریت مطب')).not.toBeInTheDocument();
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
});
it('links every section to its route', () => {
+2 -1
View File
@@ -316,7 +316,7 @@ Get doctors associated with a clinic.
Detach a doctor from a clinic. This removes the clinic↔doctor link only (the `clinic_doctors` association); it does **not** delete the doctor or change the doctor's own `active` appointment flag.
**Permission:** `ROLE_ADMIN`
**Permission:** `AUTH` — the caller must be `ROLE_ADMIN` **or** the owner of this clinic (`ROLE_CLINIC` whose user owns `clinicUuid`). Any other authenticated user gets `403`.
### Path Parameters
| Param | Type | Description |
@@ -332,6 +332,7 @@ Detach a doctor from a clinic. This removes the clinic↔doctor link only (the `
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_ACCESS_DENIED` | 403 | Caller is neither an admin nor the clinic owner |
| `ERR_VALIDATION_002` | 404 | Clinic not found |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found, or doctor not linked to this clinic |
+15 -3
View File
@@ -341,7 +341,7 @@ class ClinicController extends BaseController
#[OA\Delete(
path: '/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}',
summary: 'Detach a doctor from a clinic (admin only)',
summary: 'Detach a doctor from a clinic (admin or the clinic owner)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'clinicUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')),
@@ -349,18 +349,23 @@ class ClinicController extends BaseController
],
responses: [
new OA\Response(response: 200, description: 'Doctor detached from clinic'),
new OA\Response(response: 403, description: 'Not the clinic owner'),
new OA\Response(response: 404, description: 'Clinic or doctor not found'),
]
)]
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function detachDoctor(string $clinicUuid, string $doctorUuid): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function detachDoctor(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if (!$this->canManageClinic($clinic, $user)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی مجاز نیست', 403);
}
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
@@ -376,6 +381,13 @@ class ClinicController extends BaseController
return $this->success(['message' => 'پزشک از کلینیک جدا شد']);
}
/** ادمین یا مالکِ همان کلینیک اجازه‌ی مدیریت پزشکان را دارد. */
private function canManageClinic(Clinic $clinic, User $user): bool
{
return $user->hasRole('ROLE_ADMIN')
|| ($user->hasRole('ROLE_CLINIC') && $clinic->getUser()->getId() === $user->getId());
}
#[OA\Post(
path: '/file/upload/clinic_pro/clinic/field_image_clinic',
summary: 'Upload a clinic gallery image',
@@ -0,0 +1,78 @@
<?php
namespace App\Tests\Clinic;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* Detaching a doctor from a clinic is allowed for an admin OR the clinic owner.
* Any other authenticated user (incl. a foreign clinic owner or a plain doctor)
* must be rejected with 403.
*/
class DetachDoctorPermissionTest extends ApiTestCase
{
/** @return array{0: Clinic, 1: Doctor} */
private function makeClinicWithDoctor(): array
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$this->em->persist($clinic);
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمایشی');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$this->em->flush();
return [$clinic, $doctor];
}
private function detachUri(Clinic $clinic, Doctor $doctor): string
{
return '/api/v1/admin/clinic/' . $clinic->getUuid() . '/doctor/' . $doctor->getUuid();
}
public function testClinicOwnerCanDetachOwnDoctor(): void
{
[$clinic, $doctor] = $this->makeClinicWithDoctor();
$owner = $clinic->getUser();
$body = $this->authJson('DELETE', $this->detachUri($clinic, $doctor), $owner);
$this->assertSame(200, $this->responseCode());
$this->assertTrue($body['success']);
}
public function testAdminCanDetachAnyDoctor(): void
{
[$clinic, $doctor] = $this->makeClinicWithDoctor();
$admin = $this->createUser(['ROLE_ADMIN']);
$body = $this->authJson('DELETE', $this->detachUri($clinic, $doctor), $admin);
$this->assertSame(200, $this->responseCode());
$this->assertTrue($body['success']);
}
public function testForeignClinicOwnerIsForbidden(): void
{
[$clinic, $doctor] = $this->makeClinicWithDoctor();
$intruder = $this->createUser(['ROLE_CLINIC']);
$body = $this->authJson('DELETE', $this->detachUri($clinic, $doctor), $intruder);
$this->assertSame(403, $this->responseCode());
$this->assertFalse($body['success']);
$this->assertSame('ERR_ACCESS_DENIED', $body['errors'][0]['code']);
}
public function testPlainDoctorIsForbidden(): void
{
[$clinic, $doctor] = $this->makeClinicWithDoctor();
$otherDoctor = $this->createUser(['ROLE_DOCTOR']);
$this->authJson('DELETE', $this->detachUri($clinic, $doctor), $otherDoctor);
$this->assertSame(403, $this->responseCode());
}
}