Files
clinicpro/.claude/prompt/clinic-appointment-settings-tabs.md
hamed e4ddd38f0c feat: add per-doctor permissions management in clinics
- 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.
2026-07-18 09:44:13 +03:30

16 KiB
Raw Permalink Blame History

یکسان‌سازی تنظیمات نوبت‌دهی + تب پزشکان در پنل کلینیک

زمینه

تنظیمات نوبت‌دهی امروز فقط برای پزشکِ مستقل در دسترس است. مالک کلینیک نمی‌تواند نوبت‌دهی پزشکان کلینیکش را تنظیم کند: مسیر /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:

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:

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:

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:

if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
  return <Navigate to="/admin/dashboard" replace />;
}

ورودی منو فقط برای پزشکPurchaseSubscriptionSidebar.tsx:22 و SettingsLayout.tsx (هر دو باید ویرایش شوند):

{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },

وظایف

۱. بک‌اند — یک helper واحد به‌جای ۱۴ شرط تکراری

در AppointmentSettingsController یک متد خصوصی اضافه کن و هر ۱۴ شرط را با آن جایگزین کن:

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-54serviceModeHasNoBookable (:56-60validateSessionsHaveLocation (: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:

// لیست پزشکان کلینیک → تب‌ها → همان 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:

<Route path="settings/appointment-settings"
       element={<RoleRoute roles={['clinic']}><ClinicAppointmentSettingsPage /></RoleRoute>} />

مسیر موجود appointment-settings (:232، roles={['doctor']} blockClinicScope) دست‌نخورده بماند — آن پنل شخصی پزشک است و باید دقیقاً مثل امروز کار کند.

هر دو منو باید ورودی بگیرند (تعریفشان موازی و جداست):

  • SettingsLayout.tsx:22-35SETTINGS_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 جابه‌جا می‌کند.