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.
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
# پرامپت: غیرفعال کردن دکتر توسط صاحب کلینیک
|
||||
|
||||
## هدف
|
||||
|
||||
صاحب کلینیک بتواند یک دکتر را از کلینیک خودش **غیرفعال** کند (نه حذف — تا سوابق حفظ شوند).
|
||||
از لحظه غیرفعال شدن، تمام نوبتهای آینده (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}` (فیلدهای جدید)
|
||||
@@ -0,0 +1,418 @@
|
||||
# پرامپت: مدیریت آدرس کلینیک و location_id در برنامه هفتگی
|
||||
|
||||
## هدف
|
||||
|
||||
مشکل فعلی: `location_id` در برنامه هفتگی (`weekly_schedules.setting`) به `doctor_addresses.id` اشاره میکند،
|
||||
اما این آدرسها فقط شخصی هستند (به `doctor_id` وابستهاند) و هیچ ارتباطی با کلینیک ندارند.
|
||||
|
||||
**خواسته:**
|
||||
۱. کلینیک باید بتواند آدرس خودش را ثبت و مدیریت کند (آدرس کلینیک، نه آدرس شخصی دکتر)
|
||||
۲. وقتی دکتری به کلینیک اضافه شد، بتواند آدرس آن کلینیک را به عنوان `location_id` در برنامهاش انتخاب کند
|
||||
۳. آدرسهای شخصی دکتر هم همچنان قابل استفاده باشند
|
||||
۴. endpoint فعلی `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` که یک workaround بود باید حذف شود
|
||||
|
||||
---
|
||||
|
||||
## زمینه فنی موجود
|
||||
|
||||
### جدول `doctor_addresses` (فعلی):
|
||||
```sql
|
||||
id, uuid, name, address, telephone, latitude, longitude,
|
||||
created_at, updated_at,
|
||||
doctor_id INT NOT NULL, -- FK به doctors.id (CASCADE)
|
||||
city_id INT NULL,
|
||||
province_id INT NULL
|
||||
```
|
||||
→ فقط برای آدرسهای شخصی دکتر — clinic_id ندارد
|
||||
|
||||
### Entity `DoctorAddress`:
|
||||
- constructor: `__construct(Doctor $doctor)` — فقط برای دکتر
|
||||
- `toArray()` → `{ id, uuid, name, address, telephone, map, city, province }`
|
||||
|
||||
### Endpoint موجود (workaround که حذف میشود):
|
||||
- `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` در `DoctorController.php`
|
||||
- این endpoint آدرس کلینیک را کپی میکند به عنوان آدرس شخصی دکتر — **باید حذف شود**
|
||||
|
||||
### Frontend موجود (`DoctorDetailPage.tsx`):
|
||||
- `addresses` از `doctor.address` میآید (فقط آدرسهای شخصی دکتر)
|
||||
- `location_id` dropdown در `SessionEditor` از همین `addresses` ساخته میشود
|
||||
- warning «مکان مطب الزامی است» اگر `location_id = null` در session فعال باشد
|
||||
- interface: `AddressData { id: string; name: string | null; address: string | null; telephone: string | null; map: {...}; city: {...}; province: {...} }`
|
||||
|
||||
### API endpoint موجود برای آدرس دکتر:
|
||||
- `POST /api/v1/clinic-pro/doctor-address` — ایجاد آدرس شخصی دکتر
|
||||
- `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` — لیست آدرسهای دکتر
|
||||
- `PATCH /api/v1/clinic-pro/doctor-address/{id}` — ویرایش
|
||||
- `DELETE /api/v1/clinic-pro/doctor-address/{id}` — حذف
|
||||
|
||||
---
|
||||
|
||||
## قابلیتها
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۱ — Migration: تغییر ساختار `doctor_addresses`
|
||||
|
||||
سه تغییر در یک migration file:
|
||||
|
||||
```php
|
||||
// ۱. اضافه کردن type
|
||||
$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN type VARCHAR(10) NOT NULL DEFAULT 'personal'");
|
||||
|
||||
// ۲. clinic_id nullable FK
|
||||
$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN clinic_id INT NULL");
|
||||
$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT FK_doctor_addr_clinic FOREIGN KEY (clinic_id) REFERENCES clinics(id) ON DELETE CASCADE");
|
||||
$this->addSql("CREATE INDEX idx_doctor_addr_clinic ON doctor_addresses (clinic_id)");
|
||||
|
||||
// ۳. doctor_id را nullable کن (آدرسهای کلینیک doctor ندارند)
|
||||
$this->addSql("ALTER TABLE doctor_addresses MODIFY COLUMN doctor_id INT NULL");
|
||||
|
||||
// قانون: حداقل یکی از doctor_id یا clinic_id باید مقدار داشته باشد
|
||||
$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT chk_addr_owner CHECK (doctor_id IS NOT NULL OR clinic_id IS NOT NULL)");
|
||||
```
|
||||
|
||||
**مقادیر `type`:**
|
||||
- `personal` — آدرس شخصی دکتر (doctor_id NOT NULL, clinic_id NULL)
|
||||
- `clinic` — آدرس کلینیک (clinic_id NOT NULL, doctor_id NULL)
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۲ — آپدیت Entity `DoctorAddress`
|
||||
|
||||
**فایل:** `src/Doctor/Entity/DoctorAddress.php`
|
||||
|
||||
تغییرات:
|
||||
- `doctor_id` → nullable (`?int $doctorId` + join nullable)
|
||||
- اضافه کردن `clinic_id` (nullable int)
|
||||
- اضافه کردن `type` string (`personal` | `clinic`)
|
||||
- constructor: دو حالت — برای دکتر یا برای کلینیک
|
||||
|
||||
```php
|
||||
// فیلدهای جدید
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $type = 'personal';
|
||||
|
||||
#[ORM\Column(name: 'clinic_id', type: 'integer', nullable: true)]
|
||||
private ?int $clinicId = null;
|
||||
|
||||
// doctor nullable شود
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Doctor $doctor = null;
|
||||
```
|
||||
|
||||
Constructor باید هر دو حالت را پشتیبانی کند:
|
||||
```php
|
||||
public static function forDoctor(Doctor $doctor): self {
|
||||
$a = new self();
|
||||
$a->type = 'personal';
|
||||
$a->doctor = $doctor;
|
||||
return $a;
|
||||
}
|
||||
|
||||
public static function forClinic(int $clinicId): self {
|
||||
$a = new self();
|
||||
$a->type = 'clinic';
|
||||
$a->clinicId = $clinicId;
|
||||
return $a;
|
||||
}
|
||||
```
|
||||
|
||||
`toArray()` آپدیت شود: فیلد `type` اضافه شود:
|
||||
```php
|
||||
'type' => $this->type,
|
||||
'clinic_id' => $this->clinicId,
|
||||
```
|
||||
|
||||
> ⚠️ Constructor قدیمی `__construct(Doctor $doctor)` تغییر میکند. همه جاهایی که `new DoctorAddress($doctor)` دارند باید به `DoctorAddress::forDoctor($doctor)` تغییر کنند:
|
||||
> - `src/Doctor/Controller/DoctorController.php` متد `createAddress` و `createAddressFromClinic`
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۳ — حذف endpoint قدیمی و آپدیت DoctorController
|
||||
|
||||
**فایل:** `src/Doctor/Controller/DoctorController.php`
|
||||
|
||||
۱. متد `createAddressFromClinic` (route: `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`) را کامل حذف کن.
|
||||
|
||||
۲. در `createAddress`: `new DoctorAddress($doctor)` → `DoctorAddress::forDoctor($doctor)`
|
||||
|
||||
۳. در `updateAddress` (PATCH): چک کن آدرس از نوع `personal` باشد — آدرسهای `clinic` type را فقط صاحب کلینیک میتواند ویرایش کند (در قابلیت ۴ هندل میشود)
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۴ — CRUD آدرس کلینیک
|
||||
|
||||
**فایل:** `src/Clinic/Controller/ClinicController.php`
|
||||
|
||||
#### `POST /api/v1/clinic/{clinicUuid}/address` — ایجاد آدرس کلینیک
|
||||
|
||||
Auth: صاحب کلینیک یا admin
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"name": "شعبه مرکزی",
|
||||
"address": "تهران، خیابان ولیعصر...",
|
||||
"telephone": "02112345678",
|
||||
"latitude": 35.699,
|
||||
"longitude": 51.337,
|
||||
"city_id": 123,
|
||||
"province_id": 7
|
||||
}
|
||||
```
|
||||
|
||||
منطق:
|
||||
1. کلینیک را پیدا کن با `clinicUuid` → 404 اگر نبود
|
||||
2. بررسی owner → 403 اگر دسترسی نداشت
|
||||
3. `DoctorAddress::forClinic($clinic->getId())` بساز
|
||||
4. فیلدها را ست کن (name, address, telephone, lat/lng, city, province)
|
||||
5. ذخیره و برگردان
|
||||
|
||||
Response: `{ success, data: { id, uuid, type: 'clinic', name, address, ... } }`
|
||||
|
||||
#### `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}` — ویرایش
|
||||
|
||||
- پیدا کردن `DoctorAddress` با `uuid` و `clinic_id = clinic.id`
|
||||
- بررسی ownership
|
||||
- آپدیت فیلدها
|
||||
|
||||
#### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}` — حذف
|
||||
|
||||
- اگر فقط یک آدرس مانده → 409 با پیام «کلینیک باید حداقل یک آدرس داشته باشد»
|
||||
- در غیر این صورت حذف کن
|
||||
|
||||
#### `GET /api/v1/clinic/{clinicUuid}/addresses` — لیست آدرسها
|
||||
|
||||
- برگرداندن همه `doctor_addresses` که `clinic_id = clinic.id`
|
||||
- Auth: public (برای نمایش در صفحه کلینیک برای بیماران)
|
||||
|
||||
> ⚠️ برای find با uuid نیاز به متد `findByUuidAndClinic(string $uuid, int $clinicId)` در `DoctorAddressRepository` است.
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۵ — Endpoint آدرسهای در دسترس دکتر
|
||||
|
||||
**فایل:** `src/Appointment/Controller/AppointmentSettingsController.php`
|
||||
|
||||
**Route:** `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
|
||||
|
||||
**Auth:** IS_AUTHENTICATED_FULLY (دکتر یا admin)
|
||||
|
||||
**منطق:**
|
||||
```php
|
||||
// ۱. آدرسهای شخصی دکتر
|
||||
$personal = $addressRepo->findBy(['doctor' => $doctor, 'type' => 'personal']);
|
||||
|
||||
// ۲. آدرسهای کلینیکهایی که این دکتر عضوشان است
|
||||
$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinicRepo->findByDoctor($doctor));
|
||||
$clinicAddresses = $addressRepo->findBy(['clinicId' => $clinicIds, 'type' => 'clinic']);
|
||||
|
||||
// ۳. ادغام با label مناسب
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "5",
|
||||
"uuid": "...",
|
||||
"type": "personal",
|
||||
"name": "مطب شخصی",
|
||||
"address": "...",
|
||||
"clinic_name": null
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"uuid": "...",
|
||||
"type": "clinic",
|
||||
"name": "شعبه مرکزی",
|
||||
"address": "...",
|
||||
"clinic_name": "کلینیک نور"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> `clinic_name` برای آدرسهای `clinic` type از جدول `clinics` میآید — با یک query join بگیر.
|
||||
|
||||
**Repository method لازم:**
|
||||
```php
|
||||
// در DoctorAddressRepository
|
||||
public function findAvailableForDoctor(Doctor $doctor, array $clinicIds): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a');
|
||||
return $qb
|
||||
->where('(a.doctor = :doctor AND a.type = :personal)')
|
||||
->orWhere('(a.clinicId IN (:clinicIds) AND a.type = :clinic)')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('personal', 'personal')
|
||||
->setParameter('clinicIds', empty($clinicIds) ? [0] : $clinicIds)
|
||||
->setParameter('clinic', 'clinic')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
```
|
||||
|
||||
**Security:** اضافه کردن به `public_endpoints` در `security.yaml`:
|
||||
```yaml
|
||||
pattern: ^/(api/v1/appointment-settings/available-locations/|...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۶ — Frontend: `DoctorDetailPage.tsx`
|
||||
|
||||
**فایل:** `assets/admin/pages/DoctorDetailPage.tsx`
|
||||
|
||||
#### آپدیت interface:
|
||||
```ts
|
||||
interface AddressData {
|
||||
id: string;
|
||||
uuid: string;
|
||||
type: 'personal' | 'clinic';
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
telephone: string | null;
|
||||
map: { latitude: string | null; longitude: string | null };
|
||||
city: { id: string; name: string } | null;
|
||||
province: { id: string; name: string } | null;
|
||||
clinic_name: string | null; // جدید
|
||||
}
|
||||
```
|
||||
|
||||
#### آپدیت بارگذاری آدرسها در `ScheduleSection`:
|
||||
|
||||
فعلاً: `addresses = doctor.address ?? []` (فقط شخصی)
|
||||
|
||||
جدید: آدرسها از `available-locations` endpoint بارگذاری شوند:
|
||||
```ts
|
||||
const locationsQ = useQuery({
|
||||
queryKey: ['available-locations', doctorUuid],
|
||||
queryFn: () => api.get<ApiResponse<AddressData[]>>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`),
|
||||
enabled: !!doctorUuid,
|
||||
});
|
||||
const availableLocations = locationsQ.data?.data ?? [];
|
||||
```
|
||||
|
||||
این query در `ScheduleSection` component باشد و `addresses` prop از parent حذف شود.
|
||||
|
||||
#### آپدیت `SessionEditor` — dropdown لوکیشن:
|
||||
|
||||
نمایش label مناسب با type:
|
||||
```tsx
|
||||
<select value={session.location_id ?? ''}
|
||||
onChange={e => upd('location_id', e.target.value ? Number(e.target.value) : null)}>
|
||||
<option value="">انتخاب کنید</option>
|
||||
{/* آدرسهای شخصی */}
|
||||
{addresses.filter(a => a.type === 'personal').length > 0 && (
|
||||
<optgroup label="مطب شخصی">
|
||||
{addresses.filter(a => a.type === 'personal').map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name ?? a.address ?? `مطب ${a.id}`}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{/* آدرسهای کلینیک */}
|
||||
{addresses.filter(a => a.type === 'clinic').length > 0 && (
|
||||
<optgroup label="کلینیکها">
|
||||
{addresses.filter(a => a.type === 'clinic').map(a => (
|
||||
<option key={a.id} value={a.id}>{a.clinic_name ? `${a.clinic_name} — ${a.name ?? a.address}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قابلیت ۷ — Frontend: مدیریت آدرس کلینیک در `ClinicDetailPage.tsx`
|
||||
|
||||
**فایل:** `assets/admin/pages/ClinicDetailPage.tsx`
|
||||
|
||||
#### Interface جدید:
|
||||
```ts
|
||||
interface ClinicAddress {
|
||||
id: string; uuid: string;
|
||||
name: string | null; address: string | null;
|
||||
telephone: string | null;
|
||||
map: { latitude: string | null; longitude: string | null };
|
||||
city: { id: string; name: string } | null;
|
||||
province: { id: string; name: string } | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### بارگذاری آدرسهای کلینیک:
|
||||
```ts
|
||||
const addressesQ = useQuery({
|
||||
queryKey: ['clinic-addresses', uuid],
|
||||
queryFn: () => api.get<ApiResponse<ClinicAddress[]>>(`/api/v1/clinic/${uuid}/addresses`),
|
||||
});
|
||||
const clinicAddresses = addressesQ.data?.data ?? [];
|
||||
```
|
||||
|
||||
#### UI (فقط برای clinic owner):
|
||||
- سکشن «آدرسهای کلینیک» با دکمه «افزودن آدرس»
|
||||
- هر آدرس: نام + آدرس + تلفن + دکمههای ویرایش/حذف
|
||||
- اگر آدرسی ندارد: warning «کلینیک هنوز آدرسی ثبت نکرده — دکتران نمیتوانند این کلینیک را به عنوان لوکیشن انتخاب کنند»
|
||||
- Modal برای add/edit با فیلدهای: name, address, telephone, city_id, province_id
|
||||
- mutationهای `addAddressMutation`, `editAddressMutation`, `deleteAddressMutation`
|
||||
|
||||
---
|
||||
|
||||
## ترتیب اجرا
|
||||
|
||||
1. Migration: آپدیت `doctor_addresses` (type + clinic_id + nullable doctor_id)
|
||||
2. Entity: آپدیت `DoctorAddress` (static factory methods, فیلدهای جدید)
|
||||
3. Controller: آپدیت `DoctorController` (حذف `from-clinic` workaround، آپدیت `createAddress`)
|
||||
4. Controller: اضافه کردن CRUD آدرس کلینیک به `ClinicController`
|
||||
5. Repository: `findAvailableForDoctor` و `findByUuidAndClinic` به `DoctorAddressRepository`
|
||||
6. Controller: endpoint `available-locations` به `AppointmentSettingsController`
|
||||
7. Security: آپدیت `security.yaml` برای `available-locations` public
|
||||
8. تست backend
|
||||
9. Frontend: آپدیت `DoctorDetailPage.tsx` (ScheduleSection، SessionEditor)
|
||||
10. Frontend: آپدیت `ClinicDetailPage.tsx` (مدیریت آدرس)
|
||||
11. تست frontend
|
||||
12. مستندسازی `docs/api/clinic.md` و `docs/api/appointment-settings.md`
|
||||
|
||||
---
|
||||
|
||||
## تست هر مرحله
|
||||
|
||||
```bash
|
||||
# migration
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
# syntax
|
||||
ddev exec php -l src/Doctor/Entity/DoctorAddress.php
|
||||
ddev exec php -l src/Doctor/Controller/DoctorController.php
|
||||
ddev exec php -l src/Clinic/Controller/ClinicController.php
|
||||
ddev exec php -l src/Appointment/Controller/AppointmentSettingsController.php
|
||||
|
||||
# cache + routes
|
||||
ddev exec php bin/console cache:clear
|
||||
ddev exec php bin/console debug:router | grep -E "clinic.*address|available-location"
|
||||
|
||||
# frontend
|
||||
ddev exec yarn dev
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## مستندسازی
|
||||
|
||||
### `docs/api/clinic.md`:
|
||||
- `POST /api/v1/clinic/{clinicUuid}/address`
|
||||
- `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
- `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
- `GET /api/v1/clinic/{clinicUuid}/addresses`
|
||||
- حذف endpoint: `POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`
|
||||
|
||||
### `docs/api/appointment-settings.md`:
|
||||
- `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
|
||||
@@ -57,6 +57,16 @@ interface ClinicDoctorItem {
|
||||
specialties: { id: string; name: string }[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface ClinicAddress {
|
||||
id: string; uuid: string;
|
||||
name: string | null; address: string | null;
|
||||
telephone: string | null;
|
||||
map: { latitude: string | null; longitude: string | null };
|
||||
city: { id: string; name: string } | null;
|
||||
province: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface Opt { id: number; name: string; }
|
||||
interface OptUuid { id: number; uuid: string; name: string; }
|
||||
|
||||
@@ -514,6 +524,9 @@ export default function ClinicDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
const isOwner = primaryRole === 'clinic' && dbUuid === uuid;
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
@@ -542,6 +555,46 @@ export default function ClinicDetailPage() {
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const clinicAddressesQ = useQuery({
|
||||
queryKey: ['clinic-addresses', uuid],
|
||||
queryFn: () => api.get<ApiResponse<ClinicAddress[]>>(`/api/v1/clinic/${uuid}/addresses`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
const clinicAddresses: ClinicAddress[] = clinicAddressesQ.data?.data ?? [];
|
||||
|
||||
const [addrFormOpen, setAddrFormOpen] = useState(false);
|
||||
const [editingClinicAddr, setEditingClinicAddr] = useState<ClinicAddress | null>(null);
|
||||
const [deleteAddrConfirm, setDeleteAddrConfirm] = useState<ClinicAddress | null>(null);
|
||||
|
||||
const [addrForm, setAddrForm] = useState({ name: '', address: '', telephone: '' });
|
||||
|
||||
const saveAddrMutation = useMutation({
|
||||
mutationFn: (payload: typeof addrForm) => {
|
||||
if (editingClinicAddr) {
|
||||
return api.patch<ApiResponse<ClinicAddress>>(`/api/v1/clinic/${uuid}/address/${editingClinicAddr.uuid}`, payload);
|
||||
}
|
||||
return api.post<ApiResponse<ClinicAddress>>(`/api/v1/clinic/${uuid}/address`, payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(editingClinicAddr ? 'آدرس ویرایش شد' : 'آدرس اضافه شد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
|
||||
setAddrFormOpen(false);
|
||||
setEditingClinicAddr(null);
|
||||
setAddrForm({ name: '', address: '', telephone: '' });
|
||||
},
|
||||
onError: () => toast.error('خطا در ذخیره آدرس'),
|
||||
});
|
||||
|
||||
const deleteAddrMutation = useMutation({
|
||||
mutationFn: (addrUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/clinic/${uuid}/address/${addrUuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('آدرس حذف شد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
|
||||
setDeleteAddrConfirm(null);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.message ?? 'خطا در حذف آدرس'),
|
||||
});
|
||||
|
||||
const clinic: ClinicDetail | undefined = useMemo(() => {
|
||||
const raw = data?.data;
|
||||
return (raw as any)?.data ?? raw;
|
||||
@@ -985,9 +1038,134 @@ export default function ClinicDetailPage() {
|
||||
<NotificationMobileCard target="clinic" />
|
||||
)}
|
||||
|
||||
{/* Clinic Addresses Section */}
|
||||
{(isOwner || primaryRole === 'admin') && (
|
||||
<div className="card">
|
||||
<div className="toolbar" style={{ padding: '12px 16px' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
آدرسهای کلینیک ({formatNumber(clinicAddresses.length)})
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button className="btn primary sm" onClick={() => {
|
||||
setEditingClinicAddr(null);
|
||||
setAddrForm({ name: '', address: '', telephone: '' });
|
||||
setAddrFormOpen(true);
|
||||
}}>
|
||||
<PlusIcon style={{ width: 14, height: 14 }} />
|
||||
افزودن آدرس
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clinicAddresses.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '24px 16px' }}>
|
||||
<MapPinIcon style={{ width: 28, height: 28 }} />
|
||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
||||
هنوز آدرسی ثبت نشده — دکتران نمیتوانند این کلینیک را به عنوان لوکیشن انتخاب کنند
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{clinicAddresses.map((addr, idx) => (
|
||||
<div key={addr.uuid} style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
padding: '12px 16px',
|
||||
borderTop: idx === 0 ? '1px solid var(--border)' : undefined,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<MapPinIcon style={{ width: 18, height: 18, color: 'var(--primary)', flexShrink: 0, marginTop: 2 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{addr.name && <div style={{ fontWeight: 600, fontSize: 13 }}>{addr.name}</div>}
|
||||
{addr.address && <div className="muted" style={{ fontSize: 12, marginTop: 2 }}>{addr.address}</div>}
|
||||
{addr.telephone && (
|
||||
<div style={{ fontSize: 12, marginTop: 2, color: 'var(--text-2)' }}>
|
||||
<PhoneIcon style={{ width: 12, height: 12, display: 'inline', marginLeft: 4 }} />
|
||||
{addr.telephone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => {
|
||||
setEditingClinicAddr(addr);
|
||||
setAddrForm({ name: addr.name ?? '', address: addr.address ?? '', telephone: addr.telephone ?? '' });
|
||||
setAddrFormOpen(true);
|
||||
}}>
|
||||
<PencilIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }}
|
||||
onClick={() => setDeleteAddrConfirm(addr)}>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clinic Address Form Modal */}
|
||||
{addrFormOpen && (
|
||||
<div className="modal-backdrop" onClick={() => setAddrFormOpen(false)}>
|
||||
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<span>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</span>
|
||||
<button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}>
|
||||
<XMarkIcon style={{ width: 18, height: 18 }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label className="field-label">نام شعبه / عنوان</label>
|
||||
<div className="field">
|
||||
<input type="text" placeholder="مثال: شعبه مرکزی"
|
||||
value={addrForm.name}
|
||||
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">آدرس</label>
|
||||
<div className="field">
|
||||
<input type="text" placeholder="آدرس کامل"
|
||||
value={addrForm.address}
|
||||
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">تلفن</label>
|
||||
<div className="field">
|
||||
<input type="text" placeholder="مثال: 02112345678"
|
||||
value={addrForm.telephone}
|
||||
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn ghost" onClick={() => setAddrFormOpen(false)}>انصراف</button>
|
||||
<button className="btn primary" disabled={saveAddrMutation.isPending}
|
||||
onClick={() => saveAddrMutation.mutate(addrForm)}>
|
||||
{saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Address Confirm */}
|
||||
<ConfirmDialog
|
||||
open={deleteAddrConfirm !== null}
|
||||
title="حذف آدرس"
|
||||
message={`آیا از حذف آدرس "${deleteAddrConfirm?.name ?? deleteAddrConfirm?.address ?? ''}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
onConfirm={() => deleteAddrConfirm && deleteAddrMutation.mutate(deleteAddrConfirm.uuid)}
|
||||
onCancel={() => setDeleteAddrConfirm(null)}
|
||||
/>
|
||||
|
||||
{/* Edit modal — portal to escape Leaflet transform context */}
|
||||
{editOpen && createPortal(
|
||||
<EditModal
|
||||
|
||||
@@ -57,7 +57,11 @@ interface CityOpt { id: number; uuid: string; name: string; }
|
||||
interface ImageFileData { fid: number; uuid: string; url: string; filename: string; filemime: string; filesize: number; }
|
||||
|
||||
interface AddressData {
|
||||
id: string; uuid: string; name: string | null; address: string | null;
|
||||
id: string; uuid: string;
|
||||
type: 'personal' | 'clinic';
|
||||
clinic_id: string | null;
|
||||
clinic_name: string | null;
|
||||
name: string | null; address: string | null;
|
||||
telephone: string | null;
|
||||
map: { latitude: string | null; longitude: string | null };
|
||||
city: { id: string; name: string } | null;
|
||||
@@ -1061,9 +1065,22 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
|
||||
<select value={session.location_id ?? ''}
|
||||
onChange={e => upd('location_id', e.target.value ? Number(e.target.value) : null)}>
|
||||
<option value="">انتخاب کنید</option>
|
||||
{addresses.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name ?? a.address ?? `مطب ${a.id}`}</option>
|
||||
))}
|
||||
{addresses.filter(a => a.type === 'personal').length > 0 && (
|
||||
<optgroup label="مطب شخصی">
|
||||
{addresses.filter(a => a.type === 'personal').map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name ?? a.address ?? `مطب ${a.id}`}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{addresses.filter(a => a.type === 'clinic').length > 0 && (
|
||||
<optgroup label="کلینیکها">
|
||||
{addresses.filter(a => a.type === 'clinic').map(a => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.clinic_name ? `${a.clinic_name}${a.name ? ` — ${a.name}` : ''}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1736,8 +1753,17 @@ const SCHEDULE_TABS = [
|
||||
{ id: 'holidays' as const, label: 'تعطیلات' },
|
||||
];
|
||||
|
||||
function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
|
||||
function ScheduleSection({ doctorUuid }: { doctorUuid: string }) {
|
||||
const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly');
|
||||
|
||||
const locationsQ = useQuery({
|
||||
queryKey: ['available-locations', doctorUuid],
|
||||
queryFn: () => api.get<ApiResponse<AddressData[]>>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`),
|
||||
enabled: !!doctorUuid,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const availableLocations: AddressData[] = locationsQ.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="cp-card p-6">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">برنامه کاری</h2>
|
||||
@@ -1753,8 +1779,8 @@ function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addres
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={addresses} />}
|
||||
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={addresses} />}
|
||||
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={availableLocations} />}
|
||||
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={availableLocations} />}
|
||||
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
|
||||
</div>
|
||||
);
|
||||
@@ -2136,7 +2162,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uuid && <ScheduleSection doctorUuid={uuid} addresses={doctor.address ?? []} />}
|
||||
{uuid && <ScheduleSection doctorUuid={uuid} />}
|
||||
|
||||
{doctor.clinics && doctor.clinics.length > 0 && (
|
||||
<div className="cp-card p-6">
|
||||
|
||||
@@ -33,7 +33,7 @@ security:
|
||||
provider: api_doc_provider
|
||||
|
||||
public_endpoints:
|
||||
pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/comments/|api/v1/rate/|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$)
|
||||
pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/available-locations/|api/v1/comments/|api/v1/rate/|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$)
|
||||
stateless: true
|
||||
security: false
|
||||
|
||||
@@ -77,6 +77,8 @@ security:
|
||||
methods: [GET]
|
||||
roles: PUBLIC_ACCESS
|
||||
- { path: ^/api/v1/clinic/doctor-list/, roles: PUBLIC_ACCESS }
|
||||
- { path: '^/api/v1/clinic/[^/]+/addresses$', roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/appointment-settings/available-locations/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/clinic-pro/doctor-addresses/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/clinics$, roles: PUBLIC_ACCESS }
|
||||
- path: '^/api/v1/clinic/[^/]+$'
|
||||
|
||||
@@ -538,3 +538,61 @@ The `SlotCalculatorService` calculates available slots in this priority order:
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Locations
|
||||
|
||||
### `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
|
||||
|
||||
**Permission:** Public
|
||||
|
||||
Returns all locations a doctor can assign as `location_id` in their schedule sessions. Includes both the doctor's personal addresses and the addresses of all clinics they belong to.
|
||||
|
||||
#### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "5",
|
||||
"uuid": "...",
|
||||
"type": "personal",
|
||||
"clinic_id": null,
|
||||
"clinic_name": null,
|
||||
"name": "مطب شخصی",
|
||||
"address": "تهران، ...",
|
||||
"telephone": "09121234567",
|
||||
"map": { "latitude": "35.7", "longitude": "51.4" },
|
||||
"city": { "id": "1", "name": "تهران" },
|
||||
"province": { "id": "8", "name": "تهران" }
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"uuid": "...",
|
||||
"type": "clinic",
|
||||
"clinic_id": "3",
|
||||
"clinic_name": "کلینیک نور",
|
||||
"name": "شعبه مرکزی",
|
||||
"address": "تهران، خیابان ولیعصر...",
|
||||
"telephone": "02112345678",
|
||||
"map": { "latitude": "35.699", "longitude": "51.337" },
|
||||
"city": { "id": "1", "name": "تهران" },
|
||||
"province": { "id": "8", "name": "تهران" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `type` | `personal` = doctor's own address; `clinic` = clinic address |
|
||||
| `clinic_id` | ID of the clinic (only for type=clinic) |
|
||||
| `clinic_name` | Name of the clinic (only for type=clinic) |
|
||||
|
||||
> Use `id` as the `location_id` value in weekly schedule sessions or date override sessions.
|
||||
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
||||
|
||||
@@ -308,3 +308,111 @@ Upload clinic gallery image.
|
||||
|------|------|-------------|
|
||||
| `ERR_FILE_001` | 422 | Invalid file type |
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
|
||||
---
|
||||
|
||||
## Clinic Address Management
|
||||
|
||||
### `GET /api/v1/clinic/{clinicUuid}/addresses`
|
||||
|
||||
**Permission:** Public
|
||||
|
||||
Returns all addresses registered for a clinic (type=clinic entries).
|
||||
|
||||
#### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "12",
|
||||
"uuid": "abc-123",
|
||||
"type": "clinic",
|
||||
"clinic_id": "5",
|
||||
"name": "شعبه مرکزی",
|
||||
"address": "تهران، خیابان ولیعصر...",
|
||||
"telephone": "02112345678",
|
||||
"map": { "latitude": "35.699", "longitude": "51.337" },
|
||||
"city": { "id": "1", "name": "تهران" },
|
||||
"province": { "id": "8", "name": "تهران" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/clinic/{clinicUuid}/address`
|
||||
|
||||
**Permission:** Clinic owner or `ROLE_ADMIN`
|
||||
|
||||
Creates a new address for the clinic. The address will appear in `available-locations` for doctors belonging to this clinic.
|
||||
|
||||
#### Request
|
||||
```json
|
||||
{
|
||||
"name": "شعبه مرکزی",
|
||||
"address": "تهران، خیابان ولیعصر...",
|
||||
"telephone": "02112345678",
|
||||
"latitude": 35.699,
|
||||
"longitude": 51.337,
|
||||
"city_id": 123,
|
||||
"province_id": 7
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required |
|
||||
|-------|------|----------|
|
||||
| `name` | string | ❌ |
|
||||
| `address` | string | ❌ |
|
||||
| `telephone` | string | ❌ |
|
||||
| `latitude` | float | ❌ |
|
||||
| `longitude` | float | ❌ |
|
||||
| `city_id` | integer | ❌ |
|
||||
| `province_id` | integer | ❌ |
|
||||
|
||||
#### Response `201`
|
||||
```json
|
||||
{ "success": true, "data": { "id": "12", "uuid": "...", "type": "clinic", ... } }
|
||||
```
|
||||
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_006` | 403 | Not the clinic owner |
|
||||
| `ERR_VALIDATION_002` | 404 | Clinic not found |
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
|
||||
**Permission:** Clinic owner or `ROLE_ADMIN`
|
||||
|
||||
Updates an existing clinic address. Same body fields as POST (all optional).
|
||||
|
||||
#### Response `200`
|
||||
```json
|
||||
{ "success": true, "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
|
||||
**Permission:** Clinic owner or `ROLE_ADMIN`
|
||||
|
||||
Deletes a clinic address.
|
||||
|
||||
> A clinic must retain at least one address — attempting to delete the last address returns `409`.
|
||||
|
||||
#### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_CONFLICT_001` | 409 | Cannot delete the last address |
|
||||
| `ERR_VALIDATION_002` | 404 | Address or clinic not found |
|
||||
| `ERR_AUTH_006` | 403 | Not the clinic owner |
|
||||
|
||||
---
|
||||
|
||||
### Removed endpoint
|
||||
`POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` — **removed**. Use clinic address management endpoints instead.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260612132848 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add type, clinic_id to doctor_addresses; make doctor_id nullable to support clinic addresses';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN type VARCHAR(10) NOT NULL DEFAULT 'personal'");
|
||||
$this->addSql("ALTER TABLE doctor_addresses ADD COLUMN clinic_id INT NULL");
|
||||
$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT FK_doctor_addr_clinic FOREIGN KEY (clinic_id) REFERENCES clinics(id) ON DELETE CASCADE");
|
||||
$this->addSql("CREATE INDEX idx_doctor_addr_clinic ON doctor_addresses (clinic_id)");
|
||||
$this->addSql("ALTER TABLE doctor_addresses MODIFY COLUMN doctor_id INT NULL");
|
||||
$this->addSql("ALTER TABLE doctor_addresses ADD CONSTRAINT chk_addr_owner CHECK (doctor_id IS NOT NULL OR clinic_id IS NOT NULL)");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql("ALTER TABLE doctor_addresses DROP CONSTRAINT chk_addr_owner");
|
||||
$this->addSql("ALTER TABLE doctor_addresses MODIFY COLUMN doctor_id INT NOT NULL");
|
||||
$this->addSql("DROP INDEX idx_doctor_addr_clinic ON doctor_addresses");
|
||||
$this->addSql("ALTER TABLE doctor_addresses DROP FOREIGN KEY FK_doctor_addr_clinic");
|
||||
$this->addSql("ALTER TABLE doctor_addresses DROP COLUMN clinic_id");
|
||||
$this->addSql("ALTER TABLE doctor_addresses DROP COLUMN type");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ use App\Appointment\Repository\DateOverrideRepository;
|
||||
use App\Appointment\Repository\HolidayRepository;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
@@ -26,6 +30,8 @@ class AppointmentSettingsController extends BaseController
|
||||
private readonly DateOverrideRepository $overrideRepo,
|
||||
private readonly HolidayRepository $holidayRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
) {}
|
||||
|
||||
// ── Weekly Schedule ───────────────────────────────────────────────────────
|
||||
@@ -313,4 +319,32 @@ class AppointmentSettingsController extends BaseController
|
||||
|
||||
return $this->success(['data' => $holiday->toArray()]);
|
||||
}
|
||||
|
||||
// ── Available Locations ───────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/available-locations/{doctorUuid}', methods: ['GET'])]
|
||||
public function availableLocations(string $doctorUuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$clinics = $this->clinicRepo->findByDoctor($doctor);
|
||||
$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinics);
|
||||
$clinicMap = [];
|
||||
foreach ($clinics as $clinic) {
|
||||
$clinicMap[$clinic->getId()] = $clinic->getName();
|
||||
}
|
||||
|
||||
$addresses = $this->addressRepo->findAvailableForDoctor($doctor, $clinicIds);
|
||||
|
||||
$result = array_map(function (DoctorAddress $a) use ($clinicMap): array {
|
||||
$data = $a->toArray();
|
||||
$data['clinic_name'] = $a->getClinicId() !== null ? ($clinicMap[$a->getClinicId()] ?? null) : null;
|
||||
return $data;
|
||||
}, $addresses);
|
||||
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\DoctorService\Repository\DoctorServiceRepository;
|
||||
use App\Insurance\Repository\InsuranceRepository;
|
||||
@@ -28,16 +30,17 @@ use Symfony\Component\Uid\Uuid;
|
||||
class ClinicController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly SpecialtyRepository $specialtyRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly SpecialtyRepository $specialtyRepo,
|
||||
private readonly DoctorServiceRepository $serviceRepo,
|
||||
private readonly InsuranceRepository $insuranceRepo,
|
||||
private readonly ProvinceRepository $provinceRepo,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
private readonly InsuranceRepository $insuranceRepo,
|
||||
private readonly ProvinceRepository $provinceRepo,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
#[OA\Post(
|
||||
@@ -535,4 +538,109 @@ class ClinicController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clinic Addresses ─────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/clinic/{clinicUuid}/addresses', methods: ['GET'])]
|
||||
public function listAddresses(string $clinicUuid): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$addresses = $this->addressRepo->findBy(['clinicId' => $clinic->getId()]);
|
||||
|
||||
return $this->success(['data' => array_map(fn(DoctorAddress $a) => $a->toArray(), $addresses)]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/{clinicUuid}/address', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function createAddress(string $clinicUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$this->hydrateClinicAddress($address, $data);
|
||||
$this->addressRepo->save($address);
|
||||
|
||||
return $this->success(['data' => $address->toArray()], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function updateAddress(string $clinicUuid, string $addressUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$address = $this->addressRepo->findByUuidAndClinic($addressUuid, $clinic->getId());
|
||||
if ($address === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$this->hydrateClinicAddress($address, $data);
|
||||
$this->addressRepo->save($address);
|
||||
|
||||
return $this->success(['data' => $address->toArray()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function deleteAddress(string $clinicUuid, string $addressUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$address = $this->addressRepo->findByUuidAndClinic($addressUuid, $clinic->getId());
|
||||
if ($address === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
|
||||
}
|
||||
|
||||
$remaining = $this->addressRepo->countByClinic($clinic->getId());
|
||||
if ($remaining <= 1) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'کلینیک باید حداقل یک آدرس داشته باشد', 409);
|
||||
}
|
||||
|
||||
$this->addressRepo->remove($address);
|
||||
|
||||
return $this->success(['message' => 'آدرس با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
private function hydrateClinicAddress(DoctorAddress $address, array $data): void
|
||||
{
|
||||
if (array_key_exists('name', $data)) $address->setName($data['name']);
|
||||
if (array_key_exists('address', $data)) $address->setAddress($data['address']);
|
||||
if (array_key_exists('telephone', $data)) $address->setTelephone($data['telephone']);
|
||||
if (array_key_exists('latitude', $data)) $address->setLatitude($data['latitude'] !== null ? (float) $data['latitude'] : null);
|
||||
if (array_key_exists('longitude', $data)) $address->setLongitude($data['longitude'] !== null ? (float) $data['longitude'] : null);
|
||||
|
||||
if (array_key_exists('city_id', $data)) {
|
||||
$address->setCity($data['city_id'] !== null ? $this->cityRepo->find((int) $data['city_id']) : null);
|
||||
}
|
||||
if (array_key_exists('province_id', $data)) {
|
||||
$address->setProvince($data['province_id'] !== null ? $this->provinceRepo->find((int) $data['province_id']) : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +469,7 @@ class DoctorController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
$address = new DoctorAddress($doctor);
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$this->hydrateAddress($address, $data);
|
||||
$this->addressRepo->save($address);
|
||||
|
||||
@@ -554,7 +554,11 @@ class DoctorController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($address->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
if ($address->getType() !== DoctorAddress::TYPE_PERSONAL) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک ویرایش میشود', 403);
|
||||
}
|
||||
|
||||
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
@@ -599,7 +603,11 @@ class DoctorController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($address->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
if ($address->getType() !== DoctorAddress::TYPE_PERSONAL) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک حذف میشود', 403);
|
||||
}
|
||||
|
||||
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
@@ -724,52 +732,4 @@ class DoctorController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function createAddressFromClinic(string $clinicUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر میتواند آدرس اضافه کند', 403);
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
// Verify doctor belongs to this clinic
|
||||
$clinics = $this->clinicRepo->findByDoctor($doctor);
|
||||
$belongs = array_filter($clinics, fn(Clinic $c) => $c->getUuid() === $clinicUuid);
|
||||
if (empty($belongs)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'این دکتر عضو این کلینیک نیست', 403);
|
||||
}
|
||||
|
||||
// Prevent duplicate: if this doctor already has an address for this clinic (same name), skip
|
||||
foreach ($doctor->getAddresses() as $existing) {
|
||||
if ($existing->getName() === $clinic->getName()) {
|
||||
return $this->success(['data' => $existing->toArray()]);
|
||||
}
|
||||
}
|
||||
|
||||
$address = new DoctorAddress($doctor);
|
||||
$address->setName($clinic->getName());
|
||||
$address->setAddress($clinic->getAddress());
|
||||
$address->setTelephone($clinic->getTelephone());
|
||||
$address->setLatitude($clinic->getLatitude());
|
||||
$address->setLongitude($clinic->getLongitude());
|
||||
|
||||
if ($clinic->getCityId() !== null) {
|
||||
$city = $this->cityRepo->find($clinic->getCityId());
|
||||
$address->setCity($city);
|
||||
}
|
||||
if ($clinic->getProvinceId() !== null) {
|
||||
$province = $this->provinceRepo->find($clinic->getProvinceId());
|
||||
$address->setProvince($province);
|
||||
}
|
||||
|
||||
$this->addressRepo->save($address);
|
||||
|
||||
return $this->success(['data' => $address->toArray()], 201);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ use Symfony\Component\Uid\Uuid;
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctor_addresses')]
|
||||
#[ORM\Index(columns: ['doctor_id'], name: 'idx_doctor_addresses_doctor')]
|
||||
#[ORM\Index(columns: ['clinic_id'], name: 'idx_doctor_addr_clinic')]
|
||||
class DoctorAddress
|
||||
{
|
||||
public const TYPE_PERSONAL = 'personal';
|
||||
public const TYPE_CLINIC = 'clinic';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -21,8 +25,14 @@ class DoctorAddress
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Doctor $doctor = null;
|
||||
|
||||
#[ORM\Column(name: 'clinic_id', type: 'integer', nullable: true)]
|
||||
private ?int $clinicId = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $type = self::TYPE_PERSONAL;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $name = null;
|
||||
@@ -53,23 +63,40 @@ class DoctorAddress
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor)
|
||||
private function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getName(): ?string { return $this->name; }
|
||||
public function getAddress(): ?string { return $this->address; }
|
||||
public static function forDoctor(Doctor $doctor): self
|
||||
{
|
||||
$a = new self();
|
||||
$a->type = self::TYPE_PERSONAL;
|
||||
$a->doctor = $doctor;
|
||||
return $a;
|
||||
}
|
||||
|
||||
public static function forClinic(int $clinicId): self
|
||||
{
|
||||
$a = new self();
|
||||
$a->type = self::TYPE_CLINIC;
|
||||
$a->clinicId = $clinicId;
|
||||
return $a;
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): ?Doctor { return $this->doctor; }
|
||||
public function getClinicId(): ?int { return $this->clinicId; }
|
||||
public function getType(): string { return $this->type; }
|
||||
public function getName(): ?string { return $this->name; }
|
||||
public function getAddress(): ?string { return $this->address; }
|
||||
public function getTelephone(): ?string { return $this->telephone; }
|
||||
public function getLatitude(): ?float { return $this->latitude; }
|
||||
public function getLongitude(): ?float { return $this->longitude; }
|
||||
public function getCity(): ?City { return $this->city; }
|
||||
public function getLatitude(): ?float { return $this->latitude; }
|
||||
public function getLongitude(): ?float { return $this->longitude; }
|
||||
public function getCity(): ?City { return $this->city; }
|
||||
public function getProvince(): ?Province { return $this->province; }
|
||||
|
||||
public function setName(?string $v): self { $this->name = $v; return $this; }
|
||||
@@ -85,20 +112,22 @@ class DoctorAddress
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'map' => [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'type' => $this->type,
|
||||
'clinic_id' => $this->clinicId,
|
||||
'name' => $this->name,
|
||||
'map' => [
|
||||
'latitude' => $this->latitude !== null ? (string) $this->latitude : null,
|
||||
'longitude' => $this->longitude !== null ? (string) $this->longitude : null,
|
||||
],
|
||||
'address' => $this->address,
|
||||
'telephone' => $this->telephone,
|
||||
'city' => $this->city !== null ? [
|
||||
'address' => $this->address,
|
||||
'telephone' => $this->telephone,
|
||||
'city' => $this->city !== null ? [
|
||||
'id' => (string) $this->city->getId(),
|
||||
'name' => $this->city->getName(),
|
||||
] : null,
|
||||
'province' => $this->province !== null ? [
|
||||
'province' => $this->province !== null ? [
|
||||
'id' => (string) $this->province->getId(),
|
||||
'name' => $this->province->getName(),
|
||||
] : null,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Doctor\Repository;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -28,4 +29,48 @@ class DoctorAddressRepository extends ServiceEntityRepository
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function findByUuidAndClinic(string $uuid, int $clinicId): ?DoctorAddress
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->where('a.uuid = :uuid')
|
||||
->andWhere('a.clinicId = :clinicId')
|
||||
->setParameter('uuid', $uuid)
|
||||
->setParameter('clinicId', $clinicId)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function countByClinic(int $clinicId): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
->where('a.clinicId = :clinicId')
|
||||
->setParameter('clinicId', $clinicId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function findAvailableForDoctor(Doctor $doctor, array $clinicIds): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a');
|
||||
$qb->where(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('a.doctor', ':doctor'),
|
||||
$qb->expr()->eq('a.type', ':personal')
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->in('a.clinicId', ':clinicIds'),
|
||||
$qb->expr()->eq('a.type', ':clinic')
|
||||
)
|
||||
)
|
||||
)
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('personal', DoctorAddress::TYPE_PERSONAL)
|
||||
->setParameter('clinicIds', empty($clinicIds) ? [0] : $clinicIds)
|
||||
->setParameter('clinic', DoctorAddress::TYPE_CLINIC);
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user