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:
@@ -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 .` برای بهروز نگهداشتن گراف (طبق قانون پروژه).
|
||||
@@ -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' },
|
||||
|
||||
@@ -203,7 +203,7 @@ function buildSections(
|
||||
if (primaryRole === "clinic") {
|
||||
const clinicTo = dbUuid
|
||||
? `/admin/clinics/${dbUuid}`
|
||||
: "/admin/my-clinic";
|
||||
: "/admin/settings/clinic-doctors";
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user