From 1a8b3bfa536ccf755531094884e77ae4b7c0c01c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 12 Jul 2026 13:00:49 +0330 Subject: [PATCH] feat(representation): expose doctor representation details and add counts in admin views --- .../doctor-representation-visibility.md | 144 ++++++++++++++++++ assets/admin/pages/DoctorDetailPage.tsx | 8 + assets/admin/pages/DoctorsPage.tsx | 19 +++ .../admin/pages/RepresentationDetailPage.tsx | 56 +++++++ assets/admin/pages/RepresentationsPage.tsx | 2 + assets/admin/types/index.ts | 2 + docs/api/admin.md | 46 +++++- docs/api/doctor.md | 5 +- src/Admin/Controller/AdminApiController.php | 76 ++++++++- src/Doctor/Controller/DoctorController.php | 12 +- 10 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 .claude/prompt/doctor-representation-visibility.md diff --git a/.claude/prompt/doctor-representation-visibility.md b/.claude/prompt/doctor-representation-visibility.md new file mode 100644 index 00000000..cf7b15e6 --- /dev/null +++ b/.claude/prompt/doctor-representation-visibility.md @@ -0,0 +1,144 @@ +# نمایش تعلق دکتر به نماینده + صفحه‌ی نماینده با تعداد دکتر و نوبت + +## زمینه + +هر دکتر از طریق ستون `doctors.representation_id` (FK به `representations.id`) به یک نماینده وصل می‌شود؛ این ستون **از قبل وجود دارد** و هنگام ثبتِ دکتر توسط نماینده مقداردهی می‌شود (`RepresentationActionController::registerDoctor` → `setRepresentationId`). اما این تعلق **هیچ‌جا در پنل ادمین دیده نمی‌شود**: نه در لیست دکترها، نه در پروفایل دکتر، و صفحه‌ی نماینده‌ها تعداد دکتر و نوبت نماینده را نشان نمی‌دهد. هدف: مرئی‌کردن این رابطه در ادمین. **نیازی به migration نیست** — فقط expose و UI. + +## مشکل / هدف + +1. در لیست و پروفایل دکترِ ادمین، مشخص باشد دکتر به کدام نماینده تعلق دارد (نام + لینک به نماینده). +2. در صفحه‌ی نماینده‌ها (`RepresentationsPage`) هر نماینده ستون «تعداد پزشکان» و «تعداد نوبت» داشته باشد. +3. در جزئیات نماینده (`RepresentationDetailPage`) بشود پزشکان زیرمجموعه و نوبت‌های آن نماینده را دید. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Doctor/Entity/Doctor.php` | `representationId` (ستون L76-77، getter L217)؛ `toDetailArray()` L530 — الان نماینده را برنمی‌گرداند | +| `src/Doctor/Controller/DoctorController.php` | `show()` L149-173 — `GET /api/v1/doctor/{uuid}`؛ پنل ادمینِ پروفایل دکتر از همین می‌خواند (`DoctorDetailPage.tsx:2363`) | +| `src/Admin/Controller/AdminApiController.php` | `doctorsList` (SELECT L350-359)؛ `representations` (L1016-1082) | +| `src/Representation/Repository/RepresentationRepository.php` | برای resolve نامِ نماینده از روی id | +| `assets/admin/pages/DoctorsPage.tsx` | لیست دکتر ادمین (fetch L144) | +| `assets/admin/pages/DoctorDetailPage.tsx` | پروفایل دکتر ادمین (fetch L2358-2364) | +| `assets/admin/pages/RepresentationsPage.tsx` | لیست نماینده‌ها (fetch L51-56) | +| `assets/admin/pages/RepresentationDetailPage.tsx` | جزئیات نماینده (fetch `/api/v1/representation/{uuid}` L42) | +| `docs/api/admin.md`, `docs/api/doctor.md` | مستندسازی | + +## وضعیت فعلی + +**الگوی aggregateِ آماده (برای adaptation)** — شمارش دکتر نماینده (`RepresentationActionController.php:606`): +```php +$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE representation_id = ?', [$repId]); +``` +نوبت‌های نماینده (`RepresentationActionController.php:673` تقریبی): `JOIN a.doctor d WHERE d.representationId = :repId`. + +**لیست دکتر ادمین** الان نماینده را select نمی‌کند (`AdminApiController.php:350-359`): +```php +$rows = $conn->fetchAllAssociative( + "SELECT d.id, d.uuid, d.name, ..., d.owner_status, d.source, + u.mobile_number as user_mobile, u.email + FROM doctors d JOIN users u ON u.id = d.user_id + WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset", $params); +``` + +**لیست نماینده‌ها** بدون تعداد دکتر/نوبت (`AdminApiController.php:1063-1078`): +```php +return [ + 'id' => (int) $r['id'], ..., + 'commission_percent' => (float) $r['commissionPercent'], + 'wallet_balance' => 0, + 'is_active' => (bool) $r['active'], + 'created_at' => date('c', (int) $r['createdAt']), +]; +``` + +**پروفایل دکترِ ادمین** از `toDetailArray()` تغذیه می‌شود که `representation` ندارد. + +## وظایف + +### ۱. Backend — expose نماینده روی جزئیات دکتر (`show()`) + +در `DoctorController::show()` بعد از ساخت آرایه‌ی دکتر، نماینده را از روی `representation_id` resolve کن و merge کن (مثل الگوی `clinics` که همان‌جا merge می‌شود). `null` اگر دکتر نماینده ندارد (مثل ایمپورت‌های IRIMC): +```php +$repId = $doctor->getRepresentationId(); +$rep = $repId ? $this->representationRepo->find($repId) : null; +$data = array_merge($doctor->toDetailArray($schedule), [ + 'clinics' => $clinicData, + 'representation' => $rep ? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()] : null, +]); +``` +`RepresentationRepository` را به constructor تزریق کن (اگر نیست). پاسخ همچنان double-nested است؛ شکل را نشکن. + +### ۲. Backend — لیست دکتر ادمین: افزودن نماینده + +در `AdminApiController::doctorsList` کوئری را `LEFT JOIN representations` کن و دو ستون اضافه کن: +```php +"SELECT d.id, ..., d.owner_status, d.source, + d.representation_id, rep.full_name AS representation_name, + u.mobile_number as user_mobile, u.email + FROM doctors d JOIN users u ON u.id = d.user_id + LEFT JOIN representations rep ON rep.id = d.representation_id + WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset" +``` +و در `array_map` خروجی: +```php +'representation_id' => $d['representation_id'] !== null ? (int) $d['representation_id'] : null, +'representation_name' => $d['representation_name'] ?? null, +``` + +### ۳. Backend — لیست نماینده‌ها: تعداد دکتر + نوبت + +در `AdminApiController::representations` بعد از گرفتن `$rows` و `$repIds`، دو شمارش گروهی با یک کوئری هرکدام بگیر (نه N+1): +```php +$docCounts = []; // representation_id => count +$apptCounts = []; // representation_id => count +if ($repIds !== []) { + foreach ($this->em->getConnection()->fetchAllAssociative( + 'SELECT representation_id, COUNT(*) c FROM doctors + WHERE representation_id IN (?) GROUP BY representation_id', + [$repIds], [\Doctrine\DBAL\ArrayParameterType::INTEGER]) as $row) { + $docCounts[(int) $row['representation_id']] = (int) $row['c']; + } + foreach ($this->em->getConnection()->fetchAllAssociative( + 'SELECT d.representation_id, COUNT(*) c FROM appointments a + JOIN doctors d ON d.id = a.doctor_id + WHERE d.representation_id IN (?) GROUP BY d.representation_id', + [$repIds], [\Doctrine\DBAL\ArrayParameterType::INTEGER]) as $row) { + $apptCounts[(int) $row['representation_id']] = (int) $row['c']; + } +} +``` +و در آیتم خروجی: +```php +'doctor_count' => $docCounts[(int) $r['id']] ?? 0, +'appointment_count' => $apptCounts[(int) $r['id']] ?? 0, +``` + +### ۴. Backend — endpoint پزشکان یک نماینده (برای صفحه‌ی جزئیات) + +یک endpoint ادمین اضافه کن: `GET /api/v1/admin/representations/{uuid}/doctors` (paginated) که پزشکان `representation_id = rep.id` را برمی‌گرداند (همان الگوی `d.representation_id = :repId`). `$this->paginated(...)`. برای نوبت‌ها می‌توانی از `appointment_count` تجمیعی وظیفه‌ی ۳ در خود صفحه استفاده کنی، یا در صورت نیاز endpoint مشابه `.../appointments` بساز. + +### ۵. Frontend — ستون «نماینده» در لیست و کارت در پروفایل دکتر + +- `DoctorsPage.tsx`: به `AdminDoctor` تایپ `representation_id`/`representation_name` اضافه کن و یک ستون «نماینده» در `` نشان بده؛ اگر null → «بدون نماینده». (فقط حالت ادمین؛ نقش representation خودش زیرمجموعه است.) +- `DoctorDetailPage.tsx`: یک بخش/کارت «نماینده» با نام و لینک به `/representations/${representation.uuid}` از `data.representation` بساز؛ اگر null → «این پزشک به نماینده‌ای متصل نیست». + +### ۶. Frontend — ستون‌های نماینده + بخش پزشکان/نوبت‌ها + +- `RepresentationsPage.tsx`: دو ستون «تعداد پزشکان» (`doctor_count`) و «تعداد نوبت» (`appointment_count`) در جدول. +- `RepresentationDetailPage.tsx`: یک بخش «پزشکان این نماینده» با `useQuery` روی `/api/v1/admin/representations/${uuid}/doctors` (paginated: items از `data?.data`) و نمایش `appointment_count`/`doctor_count`. + +### ۷. مستندسازی + +- `docs/api/admin.md`: فیلدهای جدید `doctor_count`/`appointment_count` در پاسخ `representations`؛ فیلدهای `representation_id`/`representation_name` در `doctors`؛ endpoint جدید `GET /api/v1/admin/representations/{uuid}/doctors`. +- `docs/api/doctor.md`: فیلد `representation` در پاسخ single (`show`). + +## نکات مهم + +- **بدون migration**: `representation_id` از قبل روی `doctors` هست (Doctor.php:76). فقط expose. +- **ایمپورت‌های IRIMC** `representation_id = NULL` دارند (فقط `managed_by` ست می‌شود، `DoctorImportService.php:106`) — در UI «بدون نماینده» نشان بده؛ **auto-assign نکن** (خارج از دامنه‌ی این تسک). +- **پاسخ‌ها**: paginated → items از `data?.data`، total از `data?.meta?.totalRecords`؛ single (`show`) → double-nested `data?.data?.data`. همه‌ی endpointها از `BaseController` (`success`/`paginated`). +- شمارش‌ها گروهی (`GROUP BY ... IN (?)`) باشند تا N+1 نشود؛ همان الگوی `cityNames` موجود در `representations` (AdminApiController.php:1048). +- `show()` عمومی است و توسط `nobat724_front` هم مصرف می‌شود؛ افزودن `representation` مشکلی ایجاد نمی‌کند (سایت آن را نادیده می‌گیرد) ولی شکل پاسخ را تغییر نده. +- تاریخ‌ها Unix timestamp؛ نمایش با `formatDate()` شمسی. +- تست: `ddev exec php -l ...`، `ddev exec php bin/console cache:clear`، `ddev exec yarn dev`، `ddev exec php bin/console debug:router | grep representations`. diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 0db95596..a84b0c4d 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -57,6 +57,7 @@ interface DoctorDetail { state: { id: string; name: string }[]; city: { id: string; name: string }[]; clinics: { id: string; uuid: string; name: string; address: string | null; telephone: string | null }[]; + representation: { id: number; uuid: string; full_name: string | null } | null; } interface SpecialtyOpt { id: number; uuid: string; name: string; parent_id: number | null; } @@ -2699,6 +2700,13 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil 0 ? String(doctor.experience) : null} /> } /> + navigate(`/admin/representations/${doctor.representation!.uuid}`)}>{doctor.representation.full_name ?? 'نماینده'} + : بدون نماینده} + /> diff --git a/assets/admin/pages/DoctorsPage.tsx b/assets/admin/pages/DoctorsPage.tsx index 210dd650..beb008a0 100644 --- a/assets/admin/pages/DoctorsPage.tsx +++ b/assets/admin/pages/DoctorsPage.tsx @@ -30,6 +30,9 @@ interface AdminDoctor { owner_status?: string; source?: string; rate: number; + representation_id?: number | null; + representation_uuid?: string | null; + representation_name?: string | null; specialties: { id: number; name: string }[]; profile_image: string | null; created_at: string; @@ -294,6 +297,7 @@ export default function DoctorsPage() { درجه امتیاز موبایل + {!isRepresentation && نماینده} وضعیت @@ -348,6 +352,21 @@ export default function DoctorsPage() { {doc.mobile ?? '—'} + {!isRepresentation && ( + + {doc.representation_id ? ( + { e.stopPropagation(); if (doc.representation_uuid) navigate(`/admin/representations/${doc.representation_uuid}`); }} + > + {doc.representation_name ?? 'نماینده'} + + ) : ( + بدون نماینده + )} + + )} {doc.owner_status === 'unclaimed' && بدون‌مالک} {doc.owner_status === 'pending_transfer' && در انتظار انتقال} diff --git a/assets/admin/pages/RepresentationDetailPage.tsx b/assets/admin/pages/RepresentationDetailPage.tsx index d5200b46..4ab6726b 100644 --- a/assets/admin/pages/RepresentationDetailPage.tsx +++ b/assets/admin/pages/RepresentationDetailPage.tsx @@ -13,6 +13,18 @@ import ConfirmDialog from '../components/ui/ConfirmDialog'; import Modal from '../components/ui/Modal'; import SearchableSelect from '../components/ui/SearchableSelect'; +interface RepDoctor { + uuid: string; + id: number; + name: string; + gender: string | null; + medical_code: string | null; + is_active: boolean; + owner_status?: string; + appointment_count: number; + created_at: string; +} + function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { return (
@@ -45,6 +57,14 @@ export default function RepresentationDetailPage() { const rep: Representation | undefined = (data?.data as any)?.data ?? data?.data; + const doctorsQuery = useQuery({ + queryKey: ['representation-doctors', uuid], + queryFn: () => api.get>(`/api/v1/admin/representations/${uuid}/doctors?limit=100`), + enabled: !!uuid, + }); + const repDoctors: RepDoctor[] = doctorsQuery.data?.data ?? []; + const repDoctorsTotal = doctorsQuery.data?.meta?.totalRecords ?? repDoctors.length; + const updateMutation = useMutation({ mutationFn: (d: Partial) => api.patch>(`/api/v1/representation/${uuid}`, d), @@ -214,6 +234,42 @@ export default function RepresentationDetailPage() {
+ {/* Doctors of this representation */} +
+
+

پزشکان این نماینده

+ {formatNumber(repDoctorsTotal)} پزشک +
+ {doctorsQuery.isLoading ? ( +

در حال بارگذاری…

+ ) : repDoctors.length === 0 ? ( +

این نماینده پزشکی ندارد

+ ) : ( +
+ + + + + + + + + + + {repDoctors.map((d) => ( + navigate(`/admin/doctors/${d.uuid}`)}> + + + + + + ))} + +
نامکد نظامتعداد نوبتوضعیت
{d.name}{d.medical_code ?? '—'}{formatNumber(d.appointment_count)}
+
+ )} +
+ {/* Edit Modal */} setEditOpen(false)} diff --git a/assets/admin/pages/RepresentationsPage.tsx b/assets/admin/pages/RepresentationsPage.tsx index 54d10568..8fc64330 100644 --- a/assets/admin/pages/RepresentationsPage.tsx +++ b/assets/admin/pages/RepresentationsPage.tsx @@ -104,6 +104,8 @@ export default function RepresentationsPage() { { key: 'domain', header: 'دامنه', render: (r) => r.domain ? {r.domain} : '—' }, { key: 'city', header: 'شهرها', render: (r) => r.city ?? '—' }, { key: 'commission_percent', header: 'کمیسیون', render: (r) => `${formatNumber(r.commission_percent)}٪` }, + { key: 'doctor_count', header: 'تعداد پزشکان', render: (r) => formatNumber(r.doctor_count ?? 0) }, + { key: 'appointment_count', header: 'تعداد نوبت', render: (r) => formatNumber(r.appointment_count ?? 0) }, { key: 'wallet_balance', header: 'موجودی کیف‌پول', render: (r) => formatRial(r.wallet_balance ?? 0) }, { key: 'is_active', header: 'وضعیت', render: (r) => }, { key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) }, diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 23efe8f3..2c810cb8 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -157,6 +157,8 @@ export interface Representation { commission_percent: number; bank_account: { card?: string; bank_name?: string; iban?: string } | null; wallet_balance?: number; + doctor_count?: number; + appointment_count?: number; active?: boolean; is_active?: boolean; created_at: string; diff --git a/docs/api/admin.md b/docs/api/admin.md index 7a0edd9b..dd940fb9 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -357,13 +357,18 @@ List all doctors with pagination. "degree": "متخصص", "gender": "male", "doctor_rate": 4.5, - "active_doctor_appointment": true + "active_doctor_appointment": true, + "representation_id": 12, + "representation_uuid": "9c1...", + "representation_name": "علی محمدی" } ], "meta": { "totalRecords": 92, "totalPages": 5, "currentPage": 1 } } ``` +> `representation_id`/`representation_uuid`/`representation_name` نماینده‌ی مالکِ پزشک‌اند؛ برای پزشکانِ بدون نماینده (مثل ایمپورت‌های IRIMC) هر سه `null`. + --- ### GET `/api/v1/admin/doctors/stats` @@ -761,18 +766,57 @@ Paginated representation list. Each item: "city": "یزد، تهران", "commission_percent": 10.0, "wallet_balance": 0, + "doctor_count": 14, + "appointment_count": 231, "is_active": true, "created_at": "2026-06-18T..." } ``` > `city_id` = اولین شهر (BC)؛ `city` = نام شهرها با «،». `is_global=true` یعنی نماینده سراسری (badge در پنل). +> `doctor_count` = تعداد پزشکانِ `representation_id = r.id`؛ `appointment_count` = تعداد نوبت‌های آن پزشکان. هر دو با کوئری گروهی محاسبه می‌شوند (بدون N+1). + > `mobile_number` falls back to the linked user's mobile when the representation's own `mobile_number` column is empty. > **Deactivation, not deletion:** the admin panel deactivates a representation via `PATCH /api/v1/representation/{uuid}` with `{ "active": false }` rather than calling `DELETE`. --- +### GET `/api/v1/admin/representations/{uuid}/doctors` + +پزشکان زیرمجموعه‌ی یک نماینده (paginated). + +**Permission:** `ROLE_ADMIN` + +### Query Parameters +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `page` | integer | ❌ | Default: 1 | +| `limit` | integer | ❌ | Default: 15 (max 100) | + +### Response `200` +Paginated. Each item: +```json +{ + "uuid": "...", + "id": 45, + "name": "دکتر علی احمدی", + "gender": "man", + "medical_code": "12345", + "is_active": true, + "owner_status": "claimed", + "appointment_count": 17, + "created_at": "2026-06-18T..." +} +``` + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_NOT_FOUND_001` | 404 | نماینده یافت نشد | + +--- + ## Secretary Management ### GET `/api/v1/admin/secretaries` diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 5c3a7fd9..6fb0fd35 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -116,13 +116,16 @@ Get doctor detail with clinics. "address": [], "state": [], "city": [], - "clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }] + "clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }], + "representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" } } } } ``` > ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data` +> +> ℹ️ `representation` نماینده‌ی مالکِ پزشک است؛ برای پزشکِ بدون نماینده `null`. ### Errors | Code | HTTP | Description | diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index dfad0cca..b067ee10 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -333,6 +333,10 @@ class AdminApiController extends BaseController $where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)'; $params['specId'] = $specId; } + // پزشکانِ بدون نماینده — برای انتخاب و اتصال به یک نماینده. + if ($request->query->get('unassigned') === '1') { + $where[] = 'd.representation_id IS NULL'; + } $whereStr = implode(' AND ', $where); $orderBy = match ($sort) { @@ -352,8 +356,10 @@ class AdminApiController extends BaseController d.mobile_number as doctor_mobile, d.active_doctor_appointment, d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at, d.owner_status, d.source, + d.representation_id, rep.uuid AS representation_uuid, rep.full_name AS representation_name, u.mobile_number as user_mobile, u.email FROM doctors d JOIN users u ON u.id = d.user_id + LEFT JOIN representations rep ON rep.id = d.representation_id WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset", $params ); @@ -386,6 +392,9 @@ class AdminApiController extends BaseController 'owner_status' => $d['owner_status'], 'source' => $d['source'], 'rate' => (float) $d['doctor_rate'], + 'representation_id' => $d['representation_id'] !== null ? (int) $d['representation_id'] : null, + 'representation_uuid' => $d['representation_uuid'] ?? null, + 'representation_name' => $d['representation_name'] ?? null, 'specialties' => $specMap[(int) $d['id']] ?? [], 'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null, 'created_at' => date('c', (int) $d['created_at']), @@ -1058,7 +1067,30 @@ class AdminApiController extends BaseController } } - $items = array_map(function (array $r) use ($cityNames) { + // تعداد پزشکان و نوبت‌های هر نماینده در دو کوئری گروهی (بدون N+1). + $docCounts = []; + $apptCounts = []; + if ($repIds !== []) { + foreach ($this->em->getConnection()->fetchAllAssociative( + 'SELECT representation_id, COUNT(*) c FROM doctors + WHERE representation_id IN (?) GROUP BY representation_id', + [$repIds], + [\Doctrine\DBAL\ArrayParameterType::INTEGER], + ) as $row) { + $docCounts[(int) $row['representation_id']] = (int) $row['c']; + } + foreach ($this->em->getConnection()->fetchAllAssociative( + 'SELECT d.representation_id, COUNT(*) c FROM appointments a + JOIN doctors d ON d.id = a.doctor_id + WHERE d.representation_id IN (?) GROUP BY d.representation_id', + [$repIds], + [\Doctrine\DBAL\ArrayParameterType::INTEGER], + ) as $row) { + $apptCounts[(int) $row['representation_id']] = (int) $row['c']; + } + } + + $items = array_map(function (array $r) use ($cityNames, $docCounts, $apptCounts) { $cities = $cityNames[(int) $r['id']] ?? []; return [ 'id' => (int) $r['id'], @@ -1073,6 +1105,8 @@ class AdminApiController extends BaseController 'city' => $cities !== [] ? implode('، ', array_column($cities, 'name')) : null, 'commission_percent' => (float) $r['commissionPercent'], 'wallet_balance' => 0, + 'doctor_count' => $docCounts[(int) $r['id']] ?? 0, + 'appointment_count' => $apptCounts[(int) $r['id']] ?? 0, 'is_active' => (bool) $r['active'], 'created_at' => date('c', (int) $r['createdAt']), ]; @@ -1081,6 +1115,46 @@ class AdminApiController extends BaseController return $this->paginated($items, (int) $total, $page, $limit); } + #[Route('/api/v1/admin/representations/{uuid}/doctors', methods: ['GET'])] + public function representationDoctors(string $uuid, Request $request, RepresentationRepository $repRepo): JsonResponse + { + $rep = $repRepo->findByUuid($uuid); + if ($rep === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404); + } + + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 15))); + $repId = $rep->getId(); + + $conn = $this->em->getConnection(); + $total = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE representation_id = ?', [$repId]); + + $offset = ($page - 1) * $limit; + $rows = $conn->fetchAllAssociative( + "SELECT d.id, d.uuid, d.name, d.gender, d.medical_system_code, + d.active_doctor_appointment, d.owner_status, d.created_at, + (SELECT COUNT(*) FROM appointments a WHERE a.doctor_id = d.id) AS appointment_count + FROM doctors d + WHERE d.representation_id = ? ORDER BY d.created_at DESC LIMIT $limit OFFSET $offset", + [$repId] + ); + + $items = array_map(fn(array $d): array => [ + 'uuid' => $d['uuid'], + 'id' => (int) $d['id'], + 'name' => $d['name'], + 'gender' => $d['gender'], + 'medical_code' => $d['medical_system_code'], + 'is_active' => (bool) $d['active_doctor_appointment'], + 'owner_status' => $d['owner_status'], + 'appointment_count' => (int) $d['appointment_count'], + 'created_at' => date('c', (int) $d['created_at']), + ], $rows); + + return $this->paginated($items, $total, $page, $limit); + } + // ── Financial Breakdowns ────────────────────────────────────────────────── #[OA\Get( diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 6d69956e..9022368c 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -44,6 +44,7 @@ class DoctorController extends BaseController private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo, private readonly TenantInsuranceCleanupService $insuranceCleanup, private readonly \App\Representation\Service\DomainContextResolver $domainResolver, + private readonly \App\Representation\Repository\RepresentationRepository $representationRepo, private readonly string $projectDir, ) {} @@ -169,8 +170,17 @@ class DoctorController extends BaseController ], ], $clinics); + $repId = $doctor->getRepresentationId(); + $rep = $repId !== null ? $this->representationRepo->find($repId) : null; + $representation = $rep !== null + ? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()] + : null; + $schedule = $this->scheduleRepo->findByDoctor($doctor); - return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), ['clinics' => $clinicData])]); + return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), [ + 'clinics' => $clinicData, + 'representation' => $representation, + ])]); } #[Route('/api/v1/clinic/my-doctor/{doctorUuid}', methods: ['GET'])]