Files
clinicpro/.claude/prompt/clinic-doctor-deactivate.md
T
hamed 0333b24071 feat: enhance DoctorAddress entity to support clinic addresses and types
- Added `clinic_id` and `type` fields to `DoctorAddress` entity to differentiate between personal and clinic addresses.
- Updated constructor to support creation of addresses for both doctors and clinics.
- Modified repository methods to handle new address types and added methods for counting and finding addresses by clinic.
- Implemented migration to update the database schema accordingly.
- Removed deprecated endpoint for creating addresses from clinics and updated related controller methods.
- Added new endpoints for managing clinic addresses, including CRUD operations.
- Updated frontend components to handle new address types and display accordingly.
2026-06-12 13:39:37 +03:30

356 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# پرامپت: غیرفعال کردن دکتر توسط صاحب کلینیک
## هدف
صاحب کلینیک بتواند یک دکتر را از کلینیک خودش **غیرفعال** کند (نه حذف — تا سوابق حفظ شوند).
از لحظه غیرفعال شدن، تمام نوبت‌های آینده (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}` (فیلدهای جدید)