feat: enhance doctor address management by including clinic addresses and enforcing required fields

This commit is contained in:
hamed
2026-06-13 18:30:06 +03:30
parent c1c57df4ff
commit d05c070661
5 changed files with 289 additions and 21 deletions
@@ -0,0 +1,214 @@
# دسترسی پزشک به آدرس‌های کلینیک‌های عضو + اجباری کردن فیلدهای آدرس
## زمینه
وقتی یک پزشک عضو یک کلینیک می‌شود، آدرس‌های آن کلینیک باید در پروفایل پزشک (بخش «آدرس‌های مطب») نمایش داده شود. این آدرس‌ها با `type: 'clinic'` در entity `DoctorAddress` ذخیره می‌شوند و `clinic_id` دارند، اما در پاسخ API فعلی `clinic_name` برگردانده نمی‌شود در حالی که frontend آن را از `a.clinic_name` می‌خواند.
همچنین پزشک باید بتواند آدرس‌های کلینیک‌هایی که عضو آن‌هاست را ببیند — در حال حاضر `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` فقط آدرس‌های مستقیم Doctor entity را برمی‌گرداند و آدرس‌های کلینیک‌های وابسته را شامل نمی‌شود.
علاوه بر این، در `AddressModal` (افزودن آدرس جدید در پروفایل پزشک)، فیلدهای `telephone`، `address`، `province_id` و `city_id` اختیاری هستند و باید اجباری شوند.
## مشکل / هدف
سه هدف مجزا:
**۱.** آدرس‌های کلینیک‌های عضو پزشک باید در لیست آدرس‌های پزشک نمایش داده شوند
**۲.** `DoctorAddress::toArray()` باید `clinic_name` را نیز برگرداند تا frontend بتواند آن را نشان دهد
**۳.** در `AddressModal`، فیلدهای تلفن، آدرس کامل، شهر و استان باید اجباری (required) باشند
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Doctor/Entity/DoctorAddress.php` | entity آدرس — متد `toArray()` باید `clinic_name` اضافه کند |
| `src/Doctor/Controller/DoctorController.php` | `listAddresses()` — خط 671 — باید آدرس‌های کلینیک‌ها را هم شامل شود |
| `src/Clinic/Repository/ClinicRepository.php` | دارای `findByDoctor(Doctor $doctor): array` |
| `src/Clinic/Controller/ClinicController.php` | `listAddresses()` — خط 544 — PUBLIC، آدرس‌های کلینیک را برمی‌گرداند |
| `assets/admin/pages/DoctorDetailPage.tsx` | `addrSchema` در خط 772 — `AddressModal` — فرم افزودن آدرس |
| `docs/api/doctor.md` | مستندات endpoint پزشک |
## وضعیت فعلی
### Backend — DoctorAddress::toArray() (فایل: `src/Doctor/Entity/DoctorAddress.php`, خط 112)
```php
public function toArray(): array
{
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
'type' => $this->type,
'clinic_id' => $this->clinicId, // ← فقط ID، بدون نام
'name' => $this->name,
// ...
];
}
```
### Backend — listAddresses در DoctorController (خط 671)
```php
#[Route('/api/v1/clinic-pro/doctor-addresses/{doctorId}', methods: ['GET'])]
public function listAddresses(int $doctorId): JsonResponse
{
$doctor = $this->doctorRepo->find($doctorId);
// ...
$addresses = array_map(fn(DoctorAddress $a) => $a->toArray(), $doctor->getAddresses()->toArray());
// ← فقط آدرس‌های مستقیم Doctor entity — آدرس‌های کلینیک‌های عضو شامل نمی‌شود
return $this->success(['data' => $addresses]);
}
```
### Frontend — addrSchema در DoctorDetailPage.tsx (خط 772)
```tsx
const addrSchema = z.object({
name: z.string().optional(),
address: z.string().optional(), // ← باید required باشد
telephone: z.string().max(20).optional(), // ← باید required باشد
province_id: z.number().nullable().optional(), // ← باید required باشد
city_id: z.number().nullable().optional(), // ← باید required باشد
latitude: z.string().optional(),
longitude: z.string().optional(),
});
```
### Frontend — SessionEditor (خط 1082-1085)
```tsx
...addresses.filter(a => a.type === 'clinic').map(a => ({
label: `🏨 ${a.clinic_name ? `${a.clinic_name}${a.name ? `${a.name}` : ''}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}`,
}))
// ← a.clinic_name را استفاده می‌کند اما toArray() این فیلد را برنمی‌گرداند
```
## وضعیت ClinicRepository
```php
// src/Clinic/Repository/ClinicRepository.php — خط 25
public function findByDoctor(Doctor $doctor): array
{
return $this->createQueryBuilder('c')
->innerJoin('c.doctors', 'd')
->where('d.id = :doctorId')
->setParameter('doctorId', $doctor->getId())
->getQuery()
->getResult();
}
```
متد `findByDoctor()` وجود دارد — از آن استفاده کن.
## وظایف
### ۱. Backend — اضافه کردن `clinic_name` به `DoctorAddress::toArray()`
در `src/Doctor/Entity/DoctorAddress.php`، `DoctorAddress` entity دارای فیلد `clinicId` (integer) است اما ارتباط مستقیم به entity `Clinic` ندارد. بنابراین `clinic_name` باید از طریق injection خارجی به toArray اضافه شود.
**روش پیشنهادی:** متد `toArray()` را با پارامتر اختیاری گسترش بده:
```php
public function toArray(?string $clinicName = null): array
{
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
'type' => $this->type,
'clinic_id' => $this->clinicId,
'clinic_name' => $clinicName, // ← جدید
'name' => $this->name,
// ... بقیه فیلدها بدون تغییر
];
}
```
### ۲. Backend — آدرس‌های کلینیک‌های عضو را در `listAddresses` شامل کن
در `src/Doctor/Controller/DoctorController.php`، متد `listAddresses` (خط 671):
1. `ClinicRepository` را به constructor اضافه کن (اگر از قبل وجود ندارد، بررسی کن)
2. آدرس‌های کلینیک‌های عضو پزشک را دریافت کن
3. آن‌ها را به لیست آدرس‌های پزشک اضافه کن
```php
public function listAddresses(int $doctorId): JsonResponse
{
$doctor = $this->doctorRepo->find($doctorId);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
// آدرس‌های شخصی پزشک
$personalAddresses = array_map(
fn(DoctorAddress $a) => $a->toArray(),
$doctor->getAddresses()->toArray()
);
// آدرس‌های کلینیک‌هایی که پزشک عضو آن‌هاست
$clinicAddresses = [];
$clinics = $this->clinicRepo->findByDoctor($doctor);
foreach ($clinics as $clinic) {
$clinicAddrs = $this->addressRepo->findBy(['clinicId' => $clinic->getId()]);
foreach ($clinicAddrs as $addr) {
$clinicAddresses[] = $addr->toArray($clinic->getName());
}
}
return $this->success(['data' => array_merge($personalAddresses, $clinicAddresses)]);
}
```
**نکته:** `$this->addressRepo` باید از نوع `DoctorAddressRepository` باشد (همان که در ClinicController استفاده می‌شود). بررسی کن که DoctorController این repo را دارد یا نه — اگر ندارد به constructor اضافه کن.
**نکته:** `$this->clinicRepo` از نوع `ClinicRepository` — بررسی کن DoctorController این dependency را دارد.
### ۳. Frontend — اجباری کردن فیلدها در `addrSchema`
در `assets/admin/pages/DoctorDetailPage.tsx`، خط 772، schema را به این شکل تغییر بده:
```tsx
const addrSchema = z.object({
name: z.string().optional(),
address: z.string().min(1, 'آدرس کامل اجباری است'),
telephone: z.string().min(1, 'تلفن اجباری است').max(20),
province_id: z.number({ required_error: 'استان اجباری است' }),
city_id: z.number({ required_error: 'شهر اجباری است' }),
latitude: z.string().optional(),
longitude: z.string().optional(),
});
```
همچنین لیبل فیلدهای اجباری در form باید علامت ستاره (`*`) داشته باشند — فیلدهای `address`، `telephone`، `استان`، `شهر`.
در JSX، لیبل‌های مربوطه را به این شکل تغییر بده:
```tsx
// مثال:
<label>تلفن <span className="text-red-500">*</span></label>
<label>آدرس کامل <span className="text-red-500">*</span></label>
<label>استان <span className="text-red-500">*</span></label>
<label>شهر <span className="text-red-500">*</span></label>
```
و اضافه کردن نمایش خطا در زیر فیلدها با `formState: { errors }` از `useForm`:
```tsx
const { register, handleSubmit, watch, setValue, reset, formState: { errors } } = useForm<AddrForm>({
resolver: zodResolver(addrSchema),
});
// ...
{errors.telephone && <p className="text-xs text-red-500 mt-1">{errors.telephone.message}</p>}
{errors.address && <p className="text-xs text-red-500 mt-1">{errors.address.message}</p>}
```
برای `province_id` و `city_id` که از `SearchableSelect` استفاده می‌کنند، error در زیر select نمایش بده.
## نکات مهم
- **Migration لازم نیست** — هیچ schema تغییر نمی‌کند، فقط response format و validation
- **`DoctorAddress::toArray(?string $clinicName = null)`** — پارامتر optional است، همه فراخوانی‌های فعلی بدون تغییر کار می‌کنند
- **DoctorController constructor** — بررسی کن آیا `ClinicRepository` و `DoctorAddressRepository` از قبل inject شده‌اند
- **ClinicController** دارای `$this->addressRepo` از نوع `DoctorAddressRepository` — در DoctorController باید نام variable یکسان باشد (بررسی کن)
- **frontend** در خط 1084: `a.clinic_name` — بعد از اضافه شدن این فیلد به response، این بخش کار می‌کند
- `GET /api/v1/clinic-pro/doctor-addresses/{doctorId}` PUBLIC است — نیازی به auth guard جدید نیست
- بعد از تغییر controller: `ddev exec php bin/console cache:clear`
- بعد از تغییر frontend: `ddev exec yarn dev` و TypeScript check
- مستندات: `docs/api/doctor.md` را با تغییرات endpoint `listAddresses` به‌روزرسانی کن (اضافه شدن clinic addresses به response)
+26 -7
View File
@@ -771,12 +771,19 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
const addrSchema = z.object({
name: z.string().optional(),
address: z.string().optional(),
telephone: z.string().max(20).optional(),
address: z.string().min(1, 'آدرس کامل اجباری است'),
telephone: z.string().min(1, 'تلفن اجباری است').max(20),
province_id: z.number().nullable().optional(),
city_id: z.number().nullable().optional(),
latitude: z.string().optional(),
longitude: z.string().optional(),
}).superRefine((data, ctx) => {
if (!data.province_id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'استان اجباری است', path: ['province_id'] });
}
if (!data.city_id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'شهر اجباری است', path: ['city_id'] });
}
});
type AddrForm = z.infer<typeof addrSchema>;
@@ -785,7 +792,7 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
existing: AddressData | null; doctorUuid: string;
onSaved: () => void;
}) {
const { register, handleSubmit, watch, setValue, reset } = useForm<AddrForm>({
const { register, handleSubmit, watch, setValue, reset, formState: { errors } } = useForm<AddrForm>({
resolver: zodResolver(addrSchema),
});
const provinceId = watch('province_id');
@@ -864,21 +871,29 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
<input type="text" className="input" placeholder="مثال: کلینیک مهر" {...register('name')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تلفن</label>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
تلفن <span className="text-red-500">*</span>
</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="021..." {...register('telephone')} />
{errors.telephone && <p className="text-xs text-red-500 mt-1">{errors.telephone.message}</p>}
</div>
</div>
{/* Row 2: Full address */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">آدرس کامل</label>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
آدرس کامل <span className="text-red-500">*</span>
</label>
<textarea rows={2} className="cp-input resize-none" placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
{errors.address && <p className="text-xs text-red-500 mt-1">{errors.address.message}</p>}
</div>
{/* Row 3: Province + City side by side */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">استان</label>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
استان <span className="text-red-500">*</span>
</label>
<SearchableSelect
options={provinces.map(p => ({ value: p.id, label: p.name }))}
value={provinceId ?? null}
@@ -889,9 +904,12 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
setMapFlyTarget(null);
}}
/>
{errors.province_id && <p className="text-xs text-red-500 mt-1">{errors.province_id.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">شهر</label>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
شهر <span className="text-red-500">*</span>
</label>
<SearchableSelect
options={cities.map(c => ({ value: c.id, label: c.name }))}
value={cityId ?? null}
@@ -906,6 +924,7 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
}
}}
/>
{errors.city_id && <p className="text-xs text-red-500 mt-1">{errors.city_id.message}</p>}
</div>
</div>
+30 -6
View File
@@ -274,7 +274,7 @@ Upload doctor profile image.
## GET `/api/v1/clinic-pro/doctor-addresses/{doctorId}`
Get all practice addresses for a doctor.
Get all practice addresses for a doctor, including addresses of clinics the doctor is a member of.
**Permission:** `PUBLIC`
@@ -289,17 +289,37 @@ Get all practice addresses for a doctor.
"success": true,
"data": [
{
"id": 1,
"id": "1",
"uuid": "...",
"type": "personal",
"clinic_id": null,
"clinic_name": null,
"name": "مطب تهران",
"address": "تهران، خیابان...",
"telephone": "02112345678",
"latitude": 35.6892,
"longitude": 51.3890
"map": { "latitude": "35.6892", "longitude": "51.3890" },
"city": { "id": "1", "name": "تهران" },
"province": { "id": "1", "name": "تهران" }
},
{
"id": "5",
"uuid": "...",
"type": "clinic",
"clinic_id": 12,
"clinic_name": "کلینیک الوند",
"name": null,
"address": "اصفهان، خیابان...",
"telephone": "03112345678",
"map": { "latitude": null, "longitude": null },
"city": { "id": "3", "name": "اصفهان" },
"province": { "id": "2", "name": "اصفهان" }
}
]
}
```
> **نکته:** آدرس‌های با `type: "clinic"` از کلینیک‌هایی که پزشک عضو آن‌هاست می‌آیند و `clinic_name` نام کلینیک را نشان می‌دهد.
---
## POST `/api/v1/clinic-pro/doctor-address`
@@ -314,6 +334,8 @@ Add a new practice address.
"name": "مطب تهران",
"address": "تهران، خیابان ولیعصر",
"telephone": "02112345678",
"province_id": 1,
"city_id": 3,
"latitude": 35.6892,
"longitude": 51.3890
}
@@ -322,8 +344,10 @@ Add a new practice address.
| Field | Type | Required |
|-------|------|----------|
| `name` | string | ❌ |
| `address` | string | |
| `telephone` | string | |
| `address` | string | ✅ (frontend validation) |
| `telephone` | string | ✅ (frontend validation) |
| `province_id` | integer | ✅ (frontend validation) |
| `city_id` | integer | ✅ (frontend validation) |
| `latitude` | float | ❌ |
| `longitude` | float | ❌ |
+12 -2
View File
@@ -676,9 +676,19 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$addresses = array_map(fn(DoctorAddress $a) => $a->toArray(), $doctor->getAddresses()->toArray());
$personalAddresses = array_map(
fn(DoctorAddress $a) => $a->toArray(),
$doctor->getAddresses()->toArray()
);
return $this->success(['data' => $addresses]);
$clinicAddresses = [];
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
foreach ($this->addressRepo->findBy(['clinicId' => $clinic->getId()]) as $addr) {
$clinicAddresses[] = $addr->toArray($clinic->getName());
}
}
return $this->success(['data' => array_merge($personalAddresses, $clinicAddresses)]);
}
// ── Helpers ───────────────────────────────────────────────────────────────
+7 -6
View File
@@ -109,14 +109,15 @@ class DoctorAddress
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
public function toArray(?string $clinicName = null): array
{
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
'type' => $this->type,
'clinic_id' => $this->clinicId,
'name' => $this->name,
'id' => (string) $this->id,
'uuid' => $this->uuid,
'type' => $this->type,
'clinic_id' => $this->clinicId,
'clinic_name' => $clinicName,
'name' => $this->name,
'map' => [
'latitude' => $this->latitude !== null ? (string) $this->latitude : null,
'longitude' => $this->longitude !== null ? (string) $this->longitude : null,