feat: enhance doctor address management by including clinic addresses and enforcing required fields
This commit is contained in:
@@ -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)
|
||||
@@ -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
@@ -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 | ❌ |
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user