- Implement DoctorPermissionsModal for managing doctor permissions in clinics. - Create usePermissions hook to handle user permissions context. - Add migration for clinic_doctor_permissions table with default permissions. - Develop ClinicDoctorPermissionController for handling permissions API. - Create ClinicDoctorPermission entity to manage permissions data. - Implement ClinicDoctorPermissionRepository for database interactions. - Add ClinicDoctorPermissionChecker for permission validation logic. - Write tests for clinic doctor permissions functionality.
204 lines
16 KiB
Markdown
204 lines
16 KiB
Markdown
# یکسانسازی تنظیمات نوبتدهی + تب پزشکان در پنل کلینیک
|
||
|
||
## زمینه
|
||
|
||
تنظیمات نوبتدهی امروز فقط برای پزشکِ مستقل در دسترس است. مالک کلینیک نمیتواند نوبتدهی پزشکان کلینیکش را تنظیم کند: مسیر `/admin/appointment-settings` با `RoleRoute roles={['doctor']}` بسته است، هر ۱۴ endpoint در `AppointmentSettingsController` شرط یکسانِ «پزشک == کاربر جاری یا ادمین» دارند، و هیچ ورودی منویی برای نقش `clinic` وجود ندارد.
|
||
|
||
خبر خوب: زیرساخت تقریباً کامل است. هر ۱۴ endpoint از قبل uuid پزشک را از path یا body میگیرند، و کامپوننت `ScheduleSection` هم `doctorUuid` را بهصورت prop میگیرد. یعنی برای «مالک کلینیک تنظیمات پزشک X را مدیریت کند» فقط سه چیز مانع است: شرط هویت در بکاند، گارد route، و نبود ورودی منو.
|
||
|
||
> پیشنیاز: `clinic-doctor-permissions.md` (Entity و چکر مجوز از آنجا میآید). اول آن را اجرا کن.
|
||
|
||
## مشکل / هدف
|
||
|
||
۱. **پنل شخصی پزشک** باید دقیقاً مثل پزشک مستقل کار کند — هیچ تفاوتی در ساختار و امکانات.
|
||
۲. **پنل کلینیک** در «تنظیمات → نوبتدهی» باید برای هر پزشک یک تب داشته باشد و با انتخاب تب، تنظیمات همان پزشک را نشان دهد.
|
||
۳. **یک پیادهسازی واحد** — نه دو نسخه موازی. هر دو حالت باید همان کامپوننت را رندر کنند.
|
||
۴. تغییرات هر پزشک فقط روی خودش اثر بگذارد.
|
||
|
||
## فایلهای مرتبط
|
||
|
||
| فایل | نقش |
|
||
|------|-----|
|
||
| `src/Appointment/Controller/AppointmentSettingsController.php` | هر ۱۴ endpoint تنظیمات نوبتدهی |
|
||
| `src/Appointment/Entity/WeeklySchedule.php` | `OneToOne` Doctor، unique روی `doctor_id` |
|
||
| `src/Appointment/Entity/DateOverride.php` | `ManyToOne` Doctor، unique روی `(doctor_id, date)` |
|
||
| `src/Appointment/Entity/Holiday.php` | `ManyToOne` Doctor |
|
||
| `assets/admin/pages/DoctorDetailPage.tsx:2058` | `ScheduleSection` — پیادهسازی واقعی، داخل یک فایل صفحه |
|
||
| `assets/admin/pages/DoctorDetailPage.tsx:1252, 1772, 1944` | `WeeklyScheduleTab` / `DateOverridesTab` / `HolidaysTab` |
|
||
| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه پزشک مستقل (۳۶ خط، فقط پوسته) |
|
||
| `assets/admin/components/FreeVisitPrice.tsx` | قیمت ویزیت — **بدون پارامتر پزشک، فقط JWT-scoped** |
|
||
| `assets/admin/App.tsx:232` | route `appointment-settings` با `roles={['doctor']} blockClinicScope` |
|
||
| `assets/admin/components/layout/SettingsLayout.tsx:22-35` | `SETTINGS_MENU` (منوی موبایل) |
|
||
| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx:22` | منوی دسکتاپ تنظیمات — تعریف موازی و جدا |
|
||
| `docs/api/appointment-settings.md` | مستند API |
|
||
|
||
## وضعیت فعلی
|
||
|
||
**شرط هویت، ۱۴ بار کپی شده** — `src/Appointment/Controller/AppointmentSettingsController.php:75-77` و مشابهش در `:123-125`، `:199-201`، `:406-408`:
|
||
|
||
```php
|
||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||
}
|
||
```
|
||
|
||
نسخههای فرزند: `$schedule->getDoctor()->getUser()->getId() !== $user->getId()`، و همین برای `$override` و `$holiday`.
|
||
|
||
`ROLE_CLINIC` در کل این کنترلر یک بار هم استفاده نشده. `ClinicRepository` تزریق شده (`:36`) ولی فقط در `availableLocations` (`:410`) برای لیست آدرسها به کار میرود، نه برای مجوز.
|
||
|
||
**صفحه پزشک مستقل، uuid را از authStore میگیرد** — `assets/admin/pages/AppointmentSettingsPage.tsx:12-14, 31`:
|
||
|
||
```tsx
|
||
const doctorUuid = useAuthStore((s) => s.doctorUuid);
|
||
const dbUuid = useAuthStore((s) => s.dbUuid);
|
||
const uuid = doctorUuid ?? dbUuid ?? undefined;
|
||
...
|
||
<ScheduleSection doctorUuid={uuid} />
|
||
```
|
||
|
||
**کامپوننت اصلی از قبل پارامتری است** — `DoctorDetailPage.tsx:2062-2064` و `:1263, 1309-1310`:
|
||
|
||
```tsx
|
||
queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`)
|
||
...
|
||
? api.patch(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
|
||
: api.post('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta });
|
||
```
|
||
|
||
**گارد route پزشکِ در scope کلینیک را بیرون میاندازد** — `assets/admin/App.tsx:117-123`:
|
||
|
||
```tsx
|
||
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
|
||
return <Navigate to="/admin/dashboard" replace />;
|
||
}
|
||
```
|
||
|
||
**ورودی منو فقط برای پزشک** — `PurchaseSubscriptionSidebar.tsx:22` و `SettingsLayout.tsx` (هر دو باید ویرایش شوند):
|
||
|
||
```tsx
|
||
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },
|
||
```
|
||
|
||
## وظایف
|
||
|
||
### ۱. بکاند — یک helper واحد بهجای ۱۴ شرط تکراری
|
||
|
||
در `AppointmentSettingsController` یک متد خصوصی اضافه کن و **هر ۱۴ شرط را با آن جایگزین کن**:
|
||
|
||
```php
|
||
private function assertDoctorAccess(Doctor $doctor, User $user): void
|
||
{
|
||
if ($user->hasRole('ROLE_ADMIN')) {
|
||
return;
|
||
}
|
||
if ($doctor->getUser()->getId() === $user->getId()) {
|
||
return;
|
||
}
|
||
// مالک کلینیکی که این پزشک عضو آن است
|
||
$clinic = $this->clinicRepo->findByUser($user);
|
||
if ($clinic !== null && $clinic->hasDoctor($doctor)) {
|
||
return;
|
||
}
|
||
throw new AppException(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||
}
|
||
```
|
||
|
||
نکات:
|
||
- `Clinic::hasDoctor()` از قبل در `src/Clinic/Entity/Clinic.php:156` وجود دارد.
|
||
- برای endpointهای فرزند (`{uuid}` = uuid برنامه/override/holiday) همان helper را با `$schedule->getDoctor()` صدا بزن.
|
||
- اگر پرامپت مجوزها اجرا شده، برای پزشکِ عضو کلینیک هم مسیر بده: اگر `$user` خودش پزشکِ عضو همان کلینیک است، `ClinicDoctorPermissionChecker::can($user, $clinic, 'appointment_settings', 'update')` را چک کن. برای متدهای GET با `'view'`.
|
||
- منطق اعتبارسنجی موجود دست نخورد: `assertModeImmutable` (`:48-54`)، `serviceModeHasNoBookable` (`:56-60`)، `validateSessionsHaveLocation` (`:432-442`).
|
||
|
||
### ۲. استخراج `ScheduleSection` به فایل مستقل
|
||
|
||
امروز `ScheduleSection` داخل `assets/admin/pages/DoctorDetailPage.tsx` (خط ۲۰۵۸) تعریف و از آنجا export میشود. برای اینکه «یک ساختار واحد» واقعاً یک ماژول باشد و صفحهای از صفحه دیگر import نکند:
|
||
|
||
- `assets/admin/components/schedule/ScheduleSection.tsx` بساز و `ScheduleSection` + `WeeklyScheduleTab` (`:1252`) + `DateOverridesTab` (`:1772`) + `DateOverrideModal` (`:1632`) + `HolidaysTab` (`:1944`) + `HolidayModal` (`:1871`) و helperهای مربوطه (`BookingMeta` `:101-114`، `calcSlotCount` `:338`، `SessionEditor` `:1118`، `SlotEditor` `:1077`) را به آن منتقل کن.
|
||
- `DoctorDetailPage.tsx` و `AppointmentSettingsPage.tsx` هر دو از همان فایل import کنند.
|
||
- **هیچ تغییری در منطق نده** — این مرحله صرفاً جابهجایی است. بعد از انتقال، `tsc` و `yarn dev` باید بدون خطا رد شوند و رفتار صفحه پزشک مستقل عیناً همان باشد.
|
||
|
||
### ۳. صفحه تنظیمات نوبتدهی کلینیک با تب پزشکان
|
||
|
||
`assets/admin/pages/ClinicAppointmentSettingsPage.tsx`:
|
||
|
||
```tsx
|
||
// لیست پزشکان کلینیک → تبها → همان ScheduleSection با doctorUuid انتخابشده
|
||
const doctorsQ = useQuery({
|
||
queryKey: ['clinic-doctors', clinicUuid],
|
||
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
|
||
enabled: !!clinicUuid,
|
||
});
|
||
const doctorList = (doctorsQ.data?.data as any)?.data ?? doctorsQ.data?.data ?? [];
|
||
const [activeUuid, setActiveUuid] = useState<string | null>(null);
|
||
const selected = activeUuid ?? doctorList[0]?.uuid ?? null;
|
||
|
||
<SettingsLayout active="appointment">
|
||
<div className="seg"> {/* همان الگوی تب در ClinicDoctorsManager */}
|
||
{doctorList.map(d => (
|
||
<button key={d.uuid} className={selected === d.uuid ? 'active' : ''} onClick={() => setActiveUuid(d.uuid)}>
|
||
{d.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{selected && <ScheduleSection key={selected} doctorUuid={selected} />}
|
||
</SettingsLayout>
|
||
```
|
||
|
||
نکات حیاتی:
|
||
- `key={selected}` روی `ScheduleSection` **الزامی است** — بدون آن، state داخلی تب (برنامه هفتگی در حال ویرایش) بین پزشکها نشت میکند و ممکن است تنظیمات پزشک A روی B ذخیره شود. این دقیقاً همان چیزی است که خواسته «تغییرات هر پزشک فقط روی خودش» را نقض میکند.
|
||
- `clinicUuid` را مثل `ClinicDoctorsPage.tsx` از context نوع `clinic` بگیر، نه مستقیم از `dbUuid` (کاربری که هم پزشک است هم مالک کلینیک، `dbUuid`اش ممکن است uuid پزشک باشد و همه فراخوانیها ۴۰۴ شوند).
|
||
- حالت خالی: کلینیک بدون پزشک → پیام «هیچ پزشکی به این کلینیک متصل نیست» + لینک به `/admin/settings/clinic-doctors`.
|
||
- اگر تعداد پزشکان زیاد شد، تبها باید افقی اسکرول شوند نه شکسته.
|
||
|
||
### ۴. Route و منو
|
||
|
||
`assets/admin/App.tsx`:
|
||
|
||
```tsx
|
||
<Route path="settings/appointment-settings"
|
||
element={<RoleRoute roles={['clinic']}><ClinicAppointmentSettingsPage /></RoleRoute>} />
|
||
```
|
||
|
||
مسیر موجود `appointment-settings` (`:232`، `roles={['doctor']} blockClinicScope`) دستنخورده بماند — آن پنل شخصی پزشک است و باید دقیقاً مثل امروز کار کند.
|
||
|
||
**هر دو منو** باید ورودی بگیرند (تعریفشان موازی و جداست):
|
||
- `SettingsLayout.tsx:22-35` → `SETTINGS_MENU`: یک آیتم با `roles: ['clinic']` و مقصد `/admin/settings/appointment-settings`. آیتم فعلی `roles: ['doctor']` دستنخورده بماند.
|
||
- `PurchaseSubscriptionSidebar.tsx:22` → همان.
|
||
|
||
هر دو آیتم `key: 'appointment'` داشته باشند تا `active="appointment"` در `SettingsLayout` برای هر دو کار کند.
|
||
|
||
### ۵. تکلیف `FreeVisitPrice`
|
||
|
||
`assets/admin/components/FreeVisitPrice.tsx` روی `/api/v1/insurance-pricing` کار میکند و **هیچ پارامتر پزشکی نمیگیرد** — فقط از JWT scope میگیرد. اگر آن را داخل تب کلینیک رندر کنی، مالک کلینیک قیمت ویزیتِ خودش را ویرایش میکند نه پزشک انتخابشده. یکی از دو کار را بکن و در گزارش صریح بگو کدام:
|
||
|
||
- **الف)** به endpointهای `/api/v1/insurance-pricing` پارامتر اختیاری `doctor_uuid` اضافه کن (با همان `assertDoctorAccess`) و `FreeVisitPrice` را prop-محور کن. سازگاری عقبرو حفظ شود: بدون `doctor_uuid` رفتار امروز.
|
||
- **ب)** فعلاً `FreeVisitPrice` را از تب کلینیک حذف کن و در همانجا یادداشت بگذار.
|
||
|
||
گزینه (الف) ارجح است چون خواسته «هیچ تفاوتی بین دو حالت نباشد» است، ولی اگر انتخاب شد باید مستند `docs/api/insurance.md` هم بهروز شود.
|
||
|
||
### ۶. تست
|
||
|
||
`tests/Appointment/ClinicOwnerScheduleAccessTest.php`:
|
||
- مالک کلینیک برنامه هفتگی پزشکِ عضو را میخواند و PATCH میکند → ۲۰۰
|
||
- مالک کلینیک روی پزشکی که عضو کلینیکش نیست → ۴۰۳
|
||
- پزشک روی برنامه خودش → ۲۰۰ (رگرسیون: رفتار قبلی نشکند)
|
||
- پزشک روی برنامه پزشک دیگر → ۴۰۳
|
||
- `ROLE_ADMIN` روی هر پزشکی → ۲۰۰
|
||
- ذخیره برنامه پزشک A، `WeeklySchedule` پزشک B دستنخورده میماند (شرط «فقط روی همان پزشک اثر بگذارد»)
|
||
- همین ماتریس برای `date-override` و `holidays`
|
||
|
||
`docs/api/appointment-settings.md` را بهروز کن: قاعده جدید دسترسی (مالک/عضو کلینیک) در هر ۱۴ endpoint، و کد خطای ۴۰۳.
|
||
|
||
## نکات مهم
|
||
|
||
- `WeeklySchedule` روی `doctor_id` قید `unique` دارد (`Entity:12`) و `OneToOne` است — یعنی هر پزشک دقیقاً یک برنامه دارد و منطق upsert است. اگر تبها `doctorUuid` را درست پاس ندهند، PATCH روی برنامه پزشک اشتباه مینشیند و دادهی واقعی از بین میرود. این پرخطرترین بخش این تسک است.
|
||
- `DateOverride` قید `unique(doctor_id, date)` دارد؛ در حالت تب، تداخل تاریخ بین پزشکان معنا ندارد ولی خطای unique را باید به پیام فارسی معنادار تبدیل کنی نه ۵۰۰.
|
||
- در booking mode سرویسی، `countBookableByEntity('doctor', $doctor->getId())` (کنترلر `:59`) hard-code روی `'doctor'` است؛ کاتالوگ سرویس خود کلینیک این شرط را برآورده نمیکند. اگر پزشکِ عضو کلینیک سرویس شخصی ندارد، حالت سرویسی برایش قابل فعالسازی نیست — این را در UI با پیام فارسی روشن کن، نه با خطای خام.
|
||
- `booking_mode` بعد از اولین ذخیره قفل میشود (`assertModeImmutable` `:48-54` و `modeLocked` در `WeeklyScheduleTab:1282`) — این رفتار در تب کلینیک هم باید دقیقاً همان باشد.
|
||
- هر session فعال باید `location_id` داشته باشد (`validateSessionsHaveLocation` `:432-442`)؛ آدرسهای در دسترس از `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` میآید که خودش از `ClinicRepository` تغذیه میشود — برای پزشکِ عضو کلینیک، آدرسهای کلینیک باید در لیست باشند.
|
||
- همه controllerها از `BaseController`؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`.
|
||
- تاریخها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`.
|
||
- Form: React Hook Form + Zod؛ server state: TanStack Query v5؛ برای هر select از `SearchableSelect` استفاده کن نه `<select>` بومی.
|
||
- CSS: از کلاسهای موجود (`seg`، `card`، `btn primary sm`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL.
|
||
- بعد از انتقال کامپوننتها حتماً `ddev exec npx tsc --noEmit` و `ddev exec yarn dev` را اجرا کن — این refactor حجم زیادی import جابهجا میکند.
|