# پرامپت: غیرفعال کردن دکتر توسط صاحب کلینیک ## هدف صاحب کلینیک بتواند یک دکتر را از کلینیک خودش **غیرفعال** کند (نه حذف — تا سوابق حفظ شوند). از لحظه غیرفعال شدن، تمام نوبت‌های آینده (pending/confirmed) آن دکتر **لغو** می‌شوند. صاحب کلینیک می‌تواند در آینده دکتر را مجدداً فعال کند. --- ## زمینه فنی موجود ### جدول `clinic_doctors` (join table فعلی): ```sql clinic_id INT (PK) doctor_id INT (PK) -- فاقد is_active یا deactivated_at ``` ### API موجود: - `GET /api/v1/clinic/doctor-list/{clinicUuid}` → لیست دکترهای کلینیک پیاده‌سازی: `$clinic->getDoctors()->toArray()` → `$d->toListArray()` فیلد `active` در response = `doctor.active_doctor_appointment` (**global** flag — نه clinic-specific) ### Frontend موجود: - **`ClinicDetailPage.tsx`**: نمایش لیست دکترها با badge active/inactive Interface: `{ id, uuid, name, gender, degree, img, specialties, active: boolean }` Route: `/admin/clinics/:uuid` دسترسی: admin + clinic owner (هر دو می‌توانند ببینند) ### احراز هویت clinic owner: ```php // در ClinicController.update: if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(..., 403); } ``` همین الگو را در endpoint های جدید استفاده کن. ### نوبت‌ها: - جدول `appointments` فقط `doctor_id` دارد — **clinic_id و address_id ندارد** - جدول `doctor_addresses` هم **clinic_id ندارد** — فقط `doctor_id`, `name`, `address`, `telephone`, `city_id`, `province_id` - `weekly_schedules.setting` (JSON) هر session دارای `location_id` است که به `doctor_addresses.id` اشاره دارد — اما این مقدار در appointment ذخیره نمی‌شود - **بنابراین در حال حاضر از طریق دیتابیس نمی‌توان فهمید هر نوبت در کدام کلینیک بوده** - استاتوس‌ها: `pending`, `confirmed`, `completed`, `cancelled_by_doctor`, `cancelled_by_user`, `expired`, `no_show` - کنسل کردن = تغییر status به `cancelled_by_doctor` --- ## قابلیت‌ها --- ### قابلیت ۱ — Migration: سه تغییر schema برای پیاده‌سازی لغو نوبت‌های مختص کلینیک، سه ستون در سه جدول مختلف لازم است: #### ۱-الف: جدول `clinic_doctors` ```sql is_active TINYINT(1) NOT NULL DEFAULT 1 deactivated_at INT(11) NULL ``` #### ۱-ب: جدول `doctor_addresses` ```sql clinic_id INT(11) NULL -- FK به clinics.id (اگر این آدرس متعلق به کلینیکی باشد) ``` #### ۱-ج: جدول `appointments` ```sql address_id INT(11) NULL -- FK به doctor_addresses.id (آدرس محل نوبت) ``` **چرا این سه تغییر؟** - `clinic_doctors.is_active` + `deactivated_at` → برای مدیریت وضعیت دکتر در کلینیک - `doctor_addresses.clinic_id` → برای دانستن کدام آدرس‌ها به این کلینیک تعلق دارند - `appointments.address_id` → برای دانستن نوبت در کدام آدرس (و در نتیجه کدام کلینیک) ثبت شده > ⚠️ هر سه migration باید **دستی** نوشته شوند (join table و فیلدهای extra با Doctrine Entity آپدیت نمی‌شوند به صورت خودکار). **Migration file (یک فایل، سه addSql):** ```php $this->addSql("ALTER TABLE clinic_doctors ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1"); $this->addSql("ALTER TABLE clinic_doctors ADD COLUMN deactivated_at INT(11) NULL"); $this->addSql("ALTER TABLE doctor_addresses ADD COLUMN clinic_id INT(11) NULL"); $this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT FK_doctor_addr_clinic FOREIGN KEY (clinic_id) REFERENCES clinics(id) ON DELETE SET NULL"); $this->addSql("ALTER TABLE appointments ADD COLUMN address_id INT(11) NULL"); $this->addSql("ALTER TABLE appointments ADD CONSTRAINT FK_appt_address FOREIGN KEY (address_id) REFERENCES doctor_addresses(id) ON DELETE SET NULL"); ``` **آپدیت Entity:** - در `DoctorAddress.php`: فیلد `private ?int $clinicId = null;` اضافه کن (با getter/setter) - در `Appointment.php`: فیلد `private ?int $addressId = null;` اضافه کن (با getter/setter) **اجرا:** ```bash ddev exec php bin/console doctrine:migrations:migrate --no-interaction ``` --- ### قابلیت ۲ — Backend: endpoint غیرفعال کردن دکتر **فایل:** `src/Clinic/Controller/ClinicController.php` **Route:** `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/deactivate` **Auth:** `IS_AUTHENTICATED_FULLY` + باید owner کلینیک باشد **منطق:** 1. کلینیک را با `clinicUuid` پیدا کن — اگر نبود: 404 2. بررسی owner: `$clinic->getUser()->getId() !== $user->getId()` → 403 3. دکتر را با `doctorUuid` پیدا کن — اگر نبود: 404 4. بررسی عضویت: دکتر باید در `clinic_doctors` این کلینیک باشد → اگر نبود: 404 5. بررسی: اگر قبلاً غیرفعال بود (`is_active = 0`) → `ERR_CONFLICT_001` / 409 6. با native SQL جدول join را آپدیت کن: ```sql UPDATE clinic_doctors SET is_active = 0, deactivated_at = :now WHERE clinic_id = :clinicId AND doctor_id = :doctorId ``` 7. **کنسل کردن نوبت‌های آینده مختص این کلینیک:** ```php // فقط نوبت‌هایی که address_id آن‌ها به آدرس‌های این کلینیک اشاره می‌کند $future = $appointmentRepo->findFutureActiveByDoctorAndClinic($doctor, $clinic, time()); foreach ($future as $appt) { $appt->transitionTo(Appointment::STATUS_CANCELLED_BY_DOCTOR); } $this->em->flush(); ``` 8. Response: `{ message: 'دکتر غیرفعال شد', cancelled_appointments: count }` **Repository method لازم (`AppointmentRepository`):** ```php public function findFutureActiveByDoctorAndClinic(Doctor $doctor, Clinic $clinic, int $now): array { // نوبت‌های آینده این دکتر که در آدرس‌های متعلق به این کلینیک رزرو شده‌اند return $this->createQueryBuilder('a') ->join(DoctorAddress::class, 'addr', 'WITH', 'a.addressId = addr.id') ->where('a.doctor = :doctor') ->andWhere('addr.clinicId = :clinicId') ->andWhere('a.slotStart > :now') ->andWhere('a.status IN (:statuses)') ->setParameter('doctor', $doctor) ->setParameter('clinicId', $clinic->getId()) ->setParameter('now', $now) ->setParameter('statuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED]) ->getQuery() ->getResult(); } ``` > ⚠️ چون `address_id` یک integer column است (نه ORM relation)، از native query استفاده کن اگر DQL مشکل داشت: > ```php > return $this->getEntityManager()->createNativeQuery( > "SELECT a.* FROM appointments a > JOIN doctor_addresses da ON da.id = a.address_id > WHERE a.doctor_id = :doctorId > AND da.clinic_id = :clinicId > AND a.slot_start > :now > AND a.status IN ('pending', 'confirmed')", > (new ResultSetMappingBuilder($this->getEntityManager()))->addRootEntityFromClassMetadata(Appointment::class, 'a') > )->setParameters(['doctorId' => $doctor->getId(), 'clinicId' => $clinic->getId(), 'now' => $now]) > ->getResult(); > ``` --- ### قابلیت ۲-ب — Backend: ذخیره `address_id` هنگام ثبت نوبت **فایل:** `src/Appointment/Controller/AppointmentController.php` برای اینکه بعداً بتوان نوبت‌ها را بر اساس کلینیک فیلتر کرد، باید `address_id` در هنگام booking ذخیره شود. **تغییر لازم در booking endpoint:** - Request body باید `address_id` (nullable integer) را بپذیرد - این مقدار از `location_id` در slot response می‌آید (فرانت‌اند آن را دارد) - هنگام ایجاد `Appointment`: `$appointment->setAddressId($request->get('address_id'))` > ⚠️ این تغییر باید با frontend booking flow هماهنگ باشد — `location_id` از هر slot به عنوان `address_id` ارسال شود. --- ### قابلیت ۳ — Backend: endpoint فعال کردن مجدد دکتر **Route:** `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/reactivate` **Auth:** `IS_AUTHENTICATED_FULLY` + owner **منطق:** 1. کلینیک + owner check + دکتر + عضویت (همان قابلیت ۲) 2. بررسی: اگر قبلاً فعال بود (`is_active = 1`) → `ERR_CONFLICT_001` / 409 3. آپدیت: ```sql UPDATE clinic_doctors SET is_active = 1, deactivated_at = NULL WHERE clinic_id = :clinicId AND doctor_id = :doctorId ``` 4. Response: `{ message: 'دکتر مجدداً فعال شد' }` --- ### قابلیت ۴ — Backend: آپدیت endpoint `doctor-list` **فایل:** `src/Clinic/Controller/ClinicController.php` — متد `doctorList` **مشکل فعلی:** `active` field از `doctor.active_doctor_appointment` می‌آید (global) — باید از `clinic_doctors.is_active` بیاید (clinic-specific). **راه‌حل:** Query را به native SQL تغییر بده: ```php $rows = $conn->fetchAllAssociative( "SELECT d.uuid, d.name, d.gender, d.degree, d.images, cd.is_active, cd.deactivated_at, GROUP_CONCAT(DISTINCT cs.name SEPARATOR '||') as specialty_names, GROUP_CONCAT(DISTINCT cs.id SEPARATOR '||') as specialty_ids FROM clinic_doctors cd JOIN doctors d ON d.id = cd.doctor_id JOIN clinics c ON c.id = cd.clinic_id LEFT JOIN doctor_specialties ds ON ds.doctor_id = d.id LEFT JOIN categories cs ON cs.id = ds.category_id WHERE c.uuid = :uuid GROUP BY d.id, cd.is_active, cd.deactivated_at", ['uuid' => $clinicUuid] ); ``` Response format (همان ساختار قبلی + فیلدهای جدید): ```json { "uuid": "...", "name": "دکتر احمدی", "gender": "male", "degree": "متخصص", "img": [], "specialties": [{ "id": "1", "name": "قلب" }], "active": true, "deactivated_at": null } ``` --- ### قابلیت ۵ — Frontend: دکمه غیرفعال/فعال در `ClinicDetailPage.tsx` **فایل:** `assets/admin/pages/ClinicDetailPage.tsx` **موارد لازم:** #### آپدیت interface: ```ts interface ClinicDoctorItem { id: string; uuid: string; name: string; gender: string | null; degree: string | null; img: { url: string }[]; specialties: { id: string; name: string }[]; active: boolean; deactivated_at: number | null; // جدید } ``` #### منطق نمایش دکمه: - دکمه فقط برای **clinic owner** نمایش داده شود: ```ts const authStore = useAuthStore(); const isOwner = authStore.context?.type === 'clinic' && authStore.dbUuid === uuid; // uuid از params ``` - اگر `isOwner = true`: کنار هر دکتر دکمه نمایش بده #### دکمه‌ها: - اگر `doc.active = true`: دکمه «غیرفعال کردن» (className: `btn sm soft`) با آیکون `NoSymbolIcon` با `ConfirmDialog` که متن هشدار داشته باشد: **«تمام نوبت‌های آینده این دکتر لغو خواهند شد»** - اگر `doc.active = false`: دکمه «فعال کردن» (className: `btn sm primary`) با آیکون `CheckCircleIcon` بدون confirm dialog #### API calls (با `useMutation`): ```ts const deactivateMutation = useMutation({ mutationFn: (doctorUuid: string) => api.patch(`/api/v1/clinic/${uuid}/doctor/${doctorUuid}/deactivate`, {}), onSuccess: (res) => { const count = res?.data?.cancelled_appointments ?? 0; toast.success(`دکتر غیرفعال شد${count ? ` — ${count} نوبت لغو شد` : ''}`); queryClient.invalidateQueries({ queryKey: ['clinic-doctors', uuid] }); }, onError: (err) => toast.error(err.message ?? 'خطا'), }); const reactivateMutation = useMutation({ mutationFn: (doctorUuid: string) => api.patch(`/api/v1/clinic/${uuid}/doctor/${doctorUuid}/reactivate`, {}), onSuccess: () => { toast.success('دکتر مجدداً فعال شد'); queryClient.invalidateQueries({ queryKey: ['clinic-doctors', uuid] }); }, onError: (err) => toast.error(err.message ?? 'خطا'), }); ``` #### UX نکات: - `ConfirmDialog` برای deactivate: عنوان «غیرفعال کردن دکتر»، متن «تمام نوبت‌های آینده این دکتر لغو می‌شوند. آیا مطمئن هستید؟» - badge دکتر غیرفعال: `className="badge gray"` + تاریخ غیرفعالی با `formatDate(String(doc.deactivated_at))` - import لازم: `NoSymbolIcon`, `CheckCircleIcon` از `@heroicons/react/24/outline` - `ConfirmDialog` از `../components/ui/ConfirmDialog` --- ## ترتیب اجرا 1. Migration دستی (یک فایل): `clinic_doctors.is_active/deactivated_at` + `doctor_addresses.clinic_id` + `appointments.address_id` 2. آپدیت Entity: `DoctorAddress.clinicId` و `Appointment.addressId` (getter/setter) 3. Backend: آپدیت booking endpoint تا `address_id` را بگیرد و ذخیره کند 4. Backend: `findFutureActiveByDoctorAndClinic` در `AppointmentRepository` 5. Backend: `deactivate` endpoint 6. Backend: `reactivate` endpoint 7. Backend: آپدیت `doctorList` به native SQL (شامل `is_active` از `clinic_doctors`) 8. تست backend 9. Frontend: آپدیت `ClinicDetailPage.tsx` (دکمه deactivate/reactivate) 10. تست frontend 11. مستندسازی `docs/api/clinic.md` --- ## تست هر مرحله ```bash # migration (دستی ساخته می‌شود — نه از طریق diff) ddev exec php bin/console doctrine:migrations:migrate --no-interaction # syntax ddev exec php -l src/Clinic/Controller/ClinicController.php ddev exec php -l src/Appointment/Repository/AppointmentRepository.php ddev exec php -l src/Doctor/Entity/DoctorAddress.php ddev exec php -l src/Appointment/Entity/Appointment.php ddev exec php -l src/Appointment/Controller/AppointmentController.php # cache + routes ddev exec php bin/console cache:clear ddev exec php bin/console debug:router | grep -E "deactivate|reactivate" # frontend ddev exec yarn dev ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -20 ``` --- ## مستندسازی به `docs/api/clinic.md` اضافه کن: ### `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/deactivate` ### `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/reactivate` ### آپدیت `GET /api/v1/clinic/doctor-list/{clinicUuid}` (فیلدهای جدید)