diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx index ca1cc95b..b54a4ae7 100644 --- a/assets/admin/pages/CategoriesPage.tsx +++ b/assets/admin/pages/CategoriesPage.tsx @@ -494,7 +494,7 @@ function CitiesTab() { { key: 'id', sortable: true, header: 'شناسه', render: (c) => {c.id} }, { key: 'name', header: 'نام', render: (c) => {c.name} }, { key: 'site_name', header: 'نام سایت', render: (c) => c.site_name ? {c.site_name} : }, - { key: 'province_id', header: 'استان', render: (c) => c.province_id ? {provinceMap[c.province_id] ?? `#${c.province_id}`} : }, + { key: 'province_id', header: 'استان', render: (c) => c.province_id ? {c.province_name ?? provinceMap[c.province_id] ?? `#${c.province_id}`} : }, { key: 'representation_id', header: 'نماینده', render: (c) => c.representation_id ? {representationMap[c.representation_id] ?? `#${c.representation_id}`} : }, { key: 'domain', header: 'دامنه', render: (c) => c.domain ? {c.domain} : }, { key: 'weight', header: 'ترتیب', render: (c) => {c.weight} }, @@ -504,7 +504,7 @@ function CitiesTab() { return ( <> { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/cities/export" exportFile="city.json" bundle="cities" entityLabel="شهرها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-cities'] })} /> - columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در شهرها..." emptyMessage="هیچ شهری یافت نشد" + columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو با نام شهر، استان، نام سایت یا دامنه..." emptyMessage="هیچ شهری یافت نشد" actions={(c) => ( <> @@ -668,7 +668,7 @@ function SpecialtiesTab() { { key: 'id', sortable: true, header: 'شناسه', render: (s) => {s.id} }, { key: 'name', header: 'نام', render: (s) => {s.name} }, { key: 'slug', header: 'slug', render: (s) => {s.slug} }, - { key: 'parent_id', header: 'والد', render: (s) => s.parent_id ? {parentMap[s.parent_id] ?? `#${s.parent_id}`} : }, + { key: 'parent_id', header: 'والد', render: (s) => s.parent_id ? {s.parent_name ?? parentMap[s.parent_id] ?? `#${s.parent_id}`} : }, { key: 'weight', header: 'ترتیب', render: (s) => {s.weight} }, { key: 'status', header: 'وضعیت', render: (s) => }, ]; @@ -676,7 +676,7 @@ function SpecialtiesTab() { return ( <> { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/specialties/export" exportFile="specialties.json" bundle="specialties" entityLabel="تخصص‌ها" onImported={() => { qc.invalidateQueries({ queryKey: ['admin-specialties'] }); qc.invalidateQueries({ queryKey: ['admin-specialties-roots'] }); }} /> - columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تخصص‌ها..." emptyMessage="هیچ تخصصی یافت نشد" + columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو با نام تخصص، تخصص والد یا slug..." emptyMessage="هیچ تخصصی یافت نشد" actions={(s) => ( <> @@ -793,7 +793,7 @@ function DoctorServicesTab() { const columns: Column[] = [ { key: 'id', sortable: true, header: 'شناسه', render: (s) => {s.id} }, { key: 'name', header: 'نام', render: (s) => {s.name} }, - { key: 'specialty_id', header: 'تخصص', render: (s) => s.specialty_id ? {specialtyMap[s.specialty_id] ?? `#${s.specialty_id}`} : }, + { key: 'specialty_id', header: 'تخصص', render: (s) => s.specialty_id ? {s.specialty_name ?? specialtyMap[s.specialty_id] ?? `#${s.specialty_id}`} : }, { key: 'weight', header: 'ترتیب', render: (s) => {s.weight} }, { key: 'status', header: 'وضعیت', render: (s) => }, ]; @@ -801,7 +801,7 @@ function DoctorServicesTab() { return ( <> { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/doctor_services/export" exportFile="doctor-services.json" bundle="doctor_services" entityLabel="خدمات پزشک" onImported={() => qc.invalidateQueries({ queryKey: ['admin-doctor-services'] })} /> - columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در خدمات..." emptyMessage="هیچ خدمتی یافت نشد" + columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو با نام خدمت، تخصص مرتبط یا slug..." emptyMessage="هیچ خدمتی یافت نشد" actions={(s) => ( <> @@ -1066,7 +1066,7 @@ function TagsTab() { return ( <> { reset({ status: '1' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/tags/export" exportFile="tags.json" bundle="tags" entityLabel="تگ‌ها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-tags'] })} /> - columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تگ‌ها..." emptyMessage="هیچ تگی یافت نشد" + columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو با نام تگ یا slug..." emptyMessage="هیچ تگی یافت نشد" actions={(t) => ( <> diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index d55d857a..0635ebf0 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -397,6 +397,7 @@ export interface City { status: number; weight: number; province_id: number | null; + province_name?: string | null; representation_id?: number | null; contact_phone?: string | null; email?: string | null; @@ -417,6 +418,8 @@ export interface SpecialtyFull { status: number; weight: number; parent_id: number | null; + /** فقط در لیست ادمین برمی‌گردد. */ + parent_name?: string | null; } export interface DoctorService { @@ -427,6 +430,8 @@ export interface DoctorService { status: number; weight: number; specialty_id: number | null; + /** فقط در لیست ادمین برمی‌گردد. */ + specialty_name?: string | null; } export interface Insurance { diff --git a/docs/api/doctor-service.md b/docs/api/doctor-service.md index ecfe1c5e..a539ebc5 100644 --- a/docs/api/doctor-service.md +++ b/docs/api/doctor-service.md @@ -53,17 +53,22 @@ List all services with pagination (admin view). |-------|------|----------|-------------| | `page` | integer | ❌ | Default: 1 | | `limit` | integer | ❌ | Default: 20 | -| `search` | string | ❌ | Search in name | +| `search` | string | ❌ | Matches `name`, `slug`, **or the related specialty's name** (case-insensitive `LIKE`) | | `specialty_id` | integer | ❌ | Filter by specialty | ### Response `200` ```json { "success": true, - "data": [ ... ], + "data": [ + { "id": 7, "uuid": "…", "name": "بوتاکس", "slug": "botox", "status": 1, "weight": 0, + "specialty_id": 3, "specialty_name": "پوست و مو" } + ], "meta": { "totalRecords": 30, "totalPages": 2, "currentPage": 1 } } ``` +- `specialty_name` is **admin-list only** — the public `GET /api/v1/doctor-services` does not return it. +- Services without a specialty are still listed (the join is a `LEFT JOIN`). ### Errors | Code | HTTP | Description | diff --git a/docs/api/location.md b/docs/api/location.md index 5a70c542..1e20e343 100644 --- a/docs/api/location.md +++ b/docs/api/location.md @@ -88,11 +88,11 @@ List all cities with pagination (admin view). |-------|------|----------|-------------| | `page` | integer | ❌ | Default: 1 | | `limit` | integer | ❌ | Default: 20 | -| `search` | string | ❌ | Search in name | +| `search` | string | ❌ | Matches `name`, `site_name`, `domain`, **or the parent province's name** (case-insensitive `LIKE`) | | `province_id` | integer | ❌ | Filter by province | ### Response `200` -Paginated city list. +Paginated city list. Each row already carries `province_name` alongside `province_id`. --- diff --git a/docs/api/specialty.md b/docs/api/specialty.md index 200834a5..05e45a94 100644 --- a/docs/api/specialty.md +++ b/docs/api/specialty.md @@ -86,16 +86,23 @@ List all specialties with pagination (admin view — includes inactive). |-------|------|----------|-------------| | `page` | integer | ❌ | Default: 1 | | `limit` | integer | ❌ | Default: 20 | -| `search` | string | ❌ | Search in name | +| `search` | string | ❌ | Matches `name`, `slug`, **or the parent specialty's name** (case-insensitive `LIKE`) | +| `sort` / `order` | string | ❌ | `sort=id` with `order=asc\|desc`; otherwise ordered by weight then name | ### Response `200` ```json { "success": true, - "data": [ ... ], + "data": [ + { "id": 12, "uuid": "…", "name": "لیزر درمانی", "slug": "laser", "status": 1, "weight": 0, + "parent_id": 3, "parent_name": "پوست و مو" } + ], "meta": { "totalRecords": 50, "totalPages": 3, "currentPage": 1 } } ``` +- `parent_name` is **admin-list only** — the public `GET /api/v1/specialties` does not return it. + It exists because searching by parent can return a child whose parent is not on the current page, + so the client cannot resolve the name from its own list. ### Errors | Code | HTTP | Description | diff --git a/docs/api/tag.md b/docs/api/tag.md index bb3c85bf..19ade7fe 100644 --- a/docs/api/tag.md +++ b/docs/api/tag.md @@ -36,7 +36,7 @@ List all tags with pagination (admin view — includes inactive). |-------|------|----------|-------------| | `page` | integer | ❌ | Default: 1 | | `limit` | integer | ❌ | Default: 20 | -| `search` | string | ❌ | Search in name | +| `search` | string | ❌ | Matches `name` or `slug` (case-insensitive `LIKE`). Tags have no parent. | ### Response `200` ```json diff --git a/src/DoctorService/Controller/DoctorServiceController.php b/src/DoctorService/Controller/DoctorServiceController.php index 631f6565..e5bde252 100644 --- a/src/DoctorService/Controller/DoctorServiceController.php +++ b/src/DoctorService/Controller/DoctorServiceController.php @@ -104,15 +104,19 @@ class DoctorServiceController extends BaseController $search = trim((string) $request->query->get('search', '')); $specialtyId = $request->query->get('specialty_id'); - $qb = $this->repo->createQueryBuilder('ds'); + $qb = $this->repo->createQueryBuilder('ds') + ->leftJoin('ds.specialty', 'sp') + ->addSelect('sp'); if ($request->query->get('sort') === 'id') { $qb->orderBy('ds.id', strtoupper((string) $request->query->get('order')) === 'DESC' ? 'DESC' : 'ASC'); } else { $qb->orderBy('ds.weight', 'ASC')->addOrderBy('ds.name', 'ASC'); } + // نام تخصصِ والد هم جستجو می‌شود: خدمات را معمولاً ذیل تخصص می‌جویند. if ($search !== '') { - $qb->andWhere('ds.name LIKE :s')->setParameter('s', '%' . $search . '%'); + $qb->andWhere('ds.name LIKE :s OR ds.slug LIKE :s OR sp.name LIKE :s') + ->setParameter('s', '%' . $search . '%'); } if ($specialtyId !== null && $specialtyId !== '') { $qb->andWhere('ds.specialty = :sp')->setParameter('sp', (int) $specialtyId); @@ -121,8 +125,13 @@ class DoctorServiceController extends BaseController $total = (clone $qb)->select('COUNT(ds.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult(); + // specialty_name فقط در لیست ادمین می‌آید — تخصص اینجا با join هیدریت شده و + // جستجو ممکن است خدمتی را برگرداند که تخصصش در صفحهٔ جاری نیست. return $this->paginated( - array_map(fn(DoctorService $ds) => $ds->toArray(), $rows), + array_map( + static fn(DoctorService $ds) => $ds->toArray() + ['specialty_name' => $ds->getSpecialty()?->getName()], + $rows + ), (int) $total, $page, $limit ); } diff --git a/src/Location/Controller/LocationController.php b/src/Location/Controller/LocationController.php index 9b1b911b..2669a0c3 100644 --- a/src/Location/Controller/LocationController.php +++ b/src/Location/Controller/LocationController.php @@ -200,8 +200,10 @@ class LocationController extends BaseController $qb->orderBy('c.weight', 'ASC')->addOrderBy('c.name', 'ASC'); } + // نام استانِ والد هم جستجو می‌شود، به‌علاوهٔ نام سایت و دامنه که شناسهٔ عملی شهرند. if ($search !== '') { - $qb->andWhere('c.name LIKE :s')->setParameter('s', '%' . $search . '%'); + $qb->andWhere('c.name LIKE :s OR c.siteName LIKE :s OR c.domain LIKE :s OR p.name LIKE :s') + ->setParameter('s', '%' . $search . '%'); } if ($provinceId !== null && $provinceId !== '') { $qb->andWhere('c.province = :province')->setParameter('province', (int) $provinceId); diff --git a/src/Specialty/Controller/SpecialtyController.php b/src/Specialty/Controller/SpecialtyController.php index c1cb1443..3653cf59 100644 --- a/src/Specialty/Controller/SpecialtyController.php +++ b/src/Specialty/Controller/SpecialtyController.php @@ -138,15 +138,22 @@ class SpecialtyController extends BaseController $qb->orderBy('s.weight', 'ASC')->addOrderBy('s.name', 'ASC'); } + // نام والد هم جستجو می‌شود: زیرتخصص‌ها را معمولاً با نام تخصص مادر می‌جویند. if ($search !== '') { - $qb->andWhere('s.name LIKE :s')->setParameter('s', '%' . $search . '%'); + $qb->andWhere('s.name LIKE :s OR s.slug LIKE :s OR p.name LIKE :s') + ->setParameter('s', '%' . $search . '%'); } $total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult(); $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult(); + // parent_name فقط در لیست ادمین می‌آید — والد اینجا با join هیدریت شده و + // جستجو ممکن است فرزندی را برگرداند که والدش در صفحهٔ جاری نیست. return $this->paginated( - array_map(fn(Specialty $s) => $s->toArray(), $rows), + array_map( + static fn(Specialty $s) => $s->toArray() + ['parent_name' => $s->getParent()?->getName()], + $rows + ), (int) $total, $page, $limit ); } diff --git a/src/Tag/Controller/TagController.php b/src/Tag/Controller/TagController.php index b30dfbab..80f98fcc 100644 --- a/src/Tag/Controller/TagController.php +++ b/src/Tag/Controller/TagController.php @@ -93,7 +93,7 @@ class TagController extends BaseController $qb->orderBy('t.name', 'ASC'); } if ($search !== '') { - $qb->andWhere('t.name LIKE :s')->setParameter('s', '%' . $search . '%'); + $qb->andWhere('t.name LIKE :s OR t.slug LIKE :s')->setParameter('s', '%' . $search . '%'); } $total = (clone $qb)->select('COUNT(t.id)')->getQuery()->getSingleScalarResult(); diff --git a/tests/Category/AdminCategorySearchTest.php b/tests/Category/AdminCategorySearchTest.php new file mode 100644 index 00000000..815b186f --- /dev/null +++ b/tests/Category/AdminCategorySearchTest.php @@ -0,0 +1,194 @@ + */ + private function idsOf(array $body): array + { + return array_map(static fn(array $r) => (int) $r['id'], $body['data']); + } + + private function createdId(array $body): int + { + return (int) $body['data']['data']['id']; + } + + // ── specialties ─────────────────────────────────────────────────────────── + + public function testSpecialtySearchByParentNameReturnsChildren(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + $marker = 'پوستآزمون' . uniqid(); + + $parentId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => $marker]) + ); + $childId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => 'لیزر درمانی ' . uniqid(), 'parent_id' => $parentId]) + ); + $unrelatedId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => 'قلب' . uniqid()]) + ); + + $body = $this->authJson('GET', '/api/v1/admin/specialties?limit=100&search=' . urlencode($marker), $admin); + + self::assertSame(200, $this->responseCode()); + $ids = $this->idsOf($body); + self::assertContains($childId, $ids, 'فرزند باید با نام والد پیدا شود'); + self::assertContains($parentId, $ids, 'خودِ والد هم باید بماند'); + self::assertNotContains($unrelatedId, $ids); + } + + public function testSpecialtyRowCarriesParentName(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + $marker = 'والدآزمون' . uniqid(); + + $parentId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => $marker]) + ); + $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => 'زیرشاخه ' . uniqid(), 'parent_id' => $parentId]); + + $body = $this->authJson('GET', '/api/v1/admin/specialties?limit=100&search=' . urlencode($marker), $admin); + + $child = null; + foreach ($body['data'] as $row) { + if ((int) ($row['parent_id'] ?? 0) === $parentId) $child = $row; + } + + self::assertNotNull($child); + self::assertSame($marker, $child['parent_name']); + } + + public function testSpecialtySearchBySlugStillMatches(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + $slug = 'slug-test-' . uniqid(); + + $id = $this->createdId( + $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => 'نام دلخواه ' . uniqid(), 'slug' => $slug]) + ); + + $body = $this->authJson('GET', '/api/v1/admin/specialties?limit=100&search=' . urlencode($slug), $admin); + + self::assertContains($id, $this->idsOf($body)); + } + + // ── doctor services ─────────────────────────────────────────────────────── + + public function testDoctorServiceSearchBySpecialtyName(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + $marker = 'تخصصآزمون' . uniqid(); + + $specialtyId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => $marker]) + ); + $serviceId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/doctor-service', $admin, ['name' => 'بوتاکس ' . uniqid(), 'specialty_id' => $specialtyId]) + ); + $unrelatedId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/doctor-service', $admin, ['name' => 'اکو' . uniqid()]) + ); + + $body = $this->authJson('GET', '/api/v1/admin/doctor-services?limit=100&search=' . urlencode($marker), $admin); + + self::assertSame(200, $this->responseCode()); + $ids = $this->idsOf($body); + self::assertContains($serviceId, $ids); + self::assertNotContains($unrelatedId, $ids); + + $row = null; + foreach ($body['data'] as $r) { + if ((int) $r['id'] === $serviceId) $row = $r; + } + self::assertSame($marker, $row['specialty_name']); + } + + public function testDoctorServiceWithoutSpecialtyIsStillListed(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + $marker = 'خدمتتنها' . uniqid(); + + $id = $this->createdId( + $this->authJson('POST', '/api/v1/admin/doctor-service', $admin, ['name' => $marker]) + ); + + $body = $this->authJson('GET', '/api/v1/admin/doctor-services?limit=100&search=' . urlencode($marker), $admin); + + $ids = $this->idsOf($body); + self::assertContains($id, $ids, 'LEFT JOIN نباید ردیفِ بدون تخصص را حذف کند'); + self::assertSame(1, $body['meta']['totalRecords']); + } + + // ── cities ──────────────────────────────────────────────────────────────── + + public function testCitySearchByProvinceName(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + $marker = 'استانآزمون' . uniqid(); + + $provinceId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/province', $admin, ['name' => $marker]) + ); + $cityId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/city', $admin, ['name' => 'شهر یک ' . uniqid(), 'province_id' => $provinceId]) + ); + $unrelatedId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/city', $admin, ['name' => 'شهر دو' . uniqid()]) + ); + + $body = $this->authJson('GET', '/api/v1/admin/cities?limit=100&search=' . urlencode($marker), $admin); + + self::assertSame(200, $this->responseCode()); + $ids = $this->idsOf($body); + self::assertContains($cityId, $ids); + self::assertNotContains($unrelatedId, $ids); + } + + public function testCitySearchByDomain(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + $domain = 'search-test-' . uniqid() . '.example'; + + $cityId = $this->createdId( + $this->authJson('POST', '/api/v1/admin/city', $admin, ['name' => 'شهر دامنه‌دار ' . uniqid(), 'domain' => $domain]) + ); + + $body = $this->authJson('GET', '/api/v1/admin/cities?limit=100&search=' . urlencode($domain), $admin); + + self::assertContains($cityId, $this->idsOf($body)); + } + + // ── boundaries ──────────────────────────────────────────────────────────── + + public function testUnknownTermReturnsEmptyList(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + + $body = $this->authJson('GET', '/api/v1/admin/specialties?search=' . urlencode('چنینچیزینیست' . uniqid()), $admin); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']); + self::assertSame(0, $body['meta']['totalRecords']); + } + + public function testNonAdminForbidden(): void + { + $user = $this->createUser(['ROLE_USER']); + $this->authJson('GET', '/api/v1/admin/specialties?search=x', $user); + self::assertSame(403, $this->responseCode()); + } +}