feat: add export functionality for JSON data in CategoriesPage and update TabActions component

This commit is contained in:
hamed
2026-06-21 15:37:24 +03:30
parent 1f9b69c121
commit 8b9130011a
4 changed files with 185 additions and 14 deletions
@@ -0,0 +1,129 @@
# فیکس نمایش دکتر در شهر از طریق کلینیک
## پروژه
`clinicpro` (backend)
## زمینه
دکتری که هیچ آدرس و city مستقیمی ندارد اما به یک کلینیک وصل است، در سایت عمومی (`nobat724_front`) در شهر آن کلینیک نمایش داده نمی‌شود. علت: query فیلتر city فقط روی `d.cities` (جدول `doctor_cities`) کار می‌کند و `Clinic.cityId` را نادیده می‌گیرد.
## مشکل
`DoctorRepository::findWithFilters()` فیلتر city:
```php
// clinicpro/src/Doctor/Repository/DoctorRepository.php خط ۵۶-۵۸
if (!empty($filters['city'])) {
$qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']);
}
```
`ci` از `leftJoin('d.cities', 'ci')` می‌آید — جدول `doctor_cities`. اگر دکتر در این جدول ردیفی نداشته باشد (یعنی هیچ city مستقیم ندارد)، حتی اگر عضو کلینیکی با همان city باشد، از نتایج حذف می‌شود.
## ساختار مرتبط
```php
// Doctor Entity
#[ORM\ManyToMany(targetEntity: City::class)]
#[ORM\JoinTable(name: 'doctor_cities')]
private Collection $cities; // جدول doctor_cities
// Clinic Entity
#[ORM\Column(name: 'city_id', type: 'integer', nullable: true)]
private ?int $cityId = null; // FK به categories.id
#[ORM\ManyToMany(targetEntity: Doctor::class)]
#[ORM\JoinTable(name: 'clinic_doctors')]
private Collection $doctors; // جدول clinic_doctors
```
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `clinicpro/src/Doctor/Repository/DoctorRepository.php` | `findWithFilters()` — باید تغییر کند |
| `clinicpro/src/Doctor/Controller/DoctorController.php` | صدا زننده `findWithFilters` |
| `clinicpro/src/Clinic/Entity/Clinic.php` | `cityId` و `doctors` collection |
| `clinicpro/src/Doctor/Entity/Doctor.php` | `cities` collection |
## وضعیت فعلی
```php
// DoctorRepository.php — findWithFilters()
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin('d.provinces', 'pr')
->leftJoin('d.cities', 'ci')
->distinct();
// ...
if (!empty($filters['city'])) {
$qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']);
}
```
## وظایف
### ۱. گسترش فیلتر city به clinic city
در `findWithFilters()` join کلینیک را اضافه کن و شرط city را تغییر بده تا هم `doctor_cities` هم `clinic.city_id` را پوشش دهد:
```php
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin('d.provinces', 'pr')
->leftJoin('d.cities', 'ci')
->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors') // ← اضافه
->distinct();
// ...
if (!empty($filters['city'])) {
$qb->andWhere('ci.id = :city OR cl.cityId = :city')
->setParameter('city', (int) $filters['city']);
}
```
همچنین فیلتر `state` را هم بررسی کن — اگر `d.provinces` هم خالی باشد، باید از استان کلینیک گرفته شود. اما برای این باگ، city اولویت دارد.
### ۲. بررسی فیلتر state مشابه
```php
// فعلی:
if (!empty($filters['state'])) {
$qb->andWhere('pr.id = :state')->setParameter('state', (int) $filters['state']);
}
// باید:
// اگر کلینیک دارد، province آن کلینیک را هم در نظر بگیر
// اما Clinic فقط cityId دارد، نه provinceId مستقیم
// → از category join برای گرفتن province شهر کلینیک استفاده کن یا فعلاً فقط city را فیکس کن
```
### ۳. import لازم
در بالای `DoctorRepository.php` اضافه کن:
```php
use App\Clinic\Entity\Clinic;
use Doctrine\ORM\Query\Expr\Join;
```
(اگر `Join` از قبل import نشده)
### ۴. تست
```bash
ddev exec php bin/console debug:router | grep "api/v1/doctors"
# endpoint: GET /api/v1/doctors?city=<city_id>
# تست: یک دکتر که فقط از طریق کلینیک در شهر X است را با ?city=X جستجو کن
# باید در نتایج ظاهر شود
```
## نکات مهم
- `Clinic.cityId` یک integer FK است به `categories.id` (جدول categories با bundle='city') — نه یک relation object. پس `cl.cityId = :city` درست است (نه `cl.city.id`).
- `leftJoin` برای کلینیک لازم است نه `innerJoin` — دکتری که اصلاً عضو هیچ کلینیکی نیست هم باید در نتایج بماند (اگر city مستقیم دارد).
- `OR cl.cityId = :city` ممکن است با `DISTINCT` مشکل ایجاد کند — اگر دکتر هم city مستقیم دارد هم عضو کلینیک در همان city است، `DISTINCT` آن را یک‌بار نمایش می‌دهد.
- migration لازم نیست — فقط query تغییر می‌کند.
- بعد از تغییر، `docs/api/doctor.md` را update کن (رفتار فیلتر city تغییر کرده).
+46 -9
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon,
BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon,
PhotoIcon, XMarkIcon,
PhotoIcon, XMarkIcon, ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -115,11 +115,48 @@ function SBadge({ status }: { status: number }) {
);
}
// ── Export helper ──────────────────────────────────────────────────────────────
async function exportJson(url: string, filename: string, token: string | null) {
const res = await fetch(url, token ? { headers: { Authorization: `Bearer ${token}` } } : {});
const json = await res.json();
const items = json?.data ?? json?.data?.data ?? [];
const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
// ── Tab sub-component wrapper ──────────────────────────────────────────────────
function TabActions({ label, onClick }: { label: string; onClick: () => void }) {
function TabActions({ label, onClick, exportUrl, exportFile }: {
label: string;
onClick: () => void;
exportUrl: string;
exportFile: string;
}) {
const token = useAuthStore((s) => s.token);
const [exporting, setExporting] = useState(false);
const handleExport = async () => {
setExporting(true);
try {
await exportJson(exportUrl, exportFile, token);
} catch {
// silent
} finally {
setExporting(false);
}
};
return (
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 'var(--card-pad)' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: 'var(--card-pad)' }}>
<button onClick={handleExport} disabled={exporting} className="btn ghost sm">
<ArrowDownTrayIcon style={{ width: 15, height: 15 }} />
{exporting ? 'در حال دانلود...' : 'خروجی JSON'}
</button>
<button onClick={onClick} className="btn primary sm">
<PlusIcon style={{ width: 15, height: 15 }} /> {label}
</button>
@@ -191,7 +228,7 @@ function ProvincesTab() {
return (
<>
<TabActions label="افزودن استان" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} />
<TabActions label="افزودن استان" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/provinces?limit=9999" exportFile="state.json" />
<DataTable<Province> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در استان‌ها..." emptyMessage="هیچ استانی یافت نشد"
actions={(p) => (
<>
@@ -351,7 +388,7 @@ function CitiesTab() {
return (
<>
<TabActions label="افزودن شهر" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} />
<TabActions label="افزودن شهر" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/cities?limit=9999" exportFile="city.json" />
<DataTable<City> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در شهرها..." emptyMessage="هیچ شهری یافت نشد"
actions={(c) => (
<>
@@ -511,7 +548,7 @@ function SpecialtiesTab() {
return (
<>
<TabActions label="افزودن تخصص" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} />
<TabActions label="افزودن تخصص" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/specialties?limit=9999" exportFile="specialties.json" />
<DataTable<SpecialtyFull> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تخصص‌ها..." emptyMessage="هیچ تخصصی یافت نشد"
actions={(s) => (
<>
@@ -632,7 +669,7 @@ function DoctorServicesTab() {
return (
<>
<TabActions label="افزودن خدمت" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} />
<TabActions label="افزودن خدمت" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/doctor-services?limit=9999" exportFile="doctor-services.json" />
<DataTable<DoctorService> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در خدمات..." emptyMessage="هیچ خدمتی یافت نشد"
actions={(s) => (
<>
@@ -758,7 +795,7 @@ function InsurancesTab() {
return (
<>
<TabActions label="افزودن بیمه" onClick={() => { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} />
<TabActions label="افزودن بیمه" onClick={() => { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} exportUrl="/api/v1/admin/insurances?limit=9999" exportFile="insurances.json" />
<DataTable<Insurance> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در بیمه‌ها..." emptyMessage="هیچ بیمه‌ای یافت نشد"
actions={(i) => (
<>
@@ -872,7 +909,7 @@ function TagsTab() {
return (
<>
<TabActions label="افزودن تگ" onClick={() => { reset({ status: '1' }); setAddOpen(true); }} />
<TabActions label="افزودن تگ" onClick={() => { reset({ status: '1' }); setAddOpen(true); }} exportUrl="/api/v1/admin/tags?limit=9999" exportFile="tags.json" />
<DataTable<Tag> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تگ‌ها..." emptyMessage="هیچ تگی یافت نشد"
actions={(t) => (
<>
+3 -3
View File
@@ -178,9 +178,9 @@ List doctors with pagination and filters.
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search in title |
| `specialty_id` | integer | ❌ | Filter by specialty |
| `city_id` | integer | ❌ | Filter by city |
| `state_id` | integer | ❌ | Filter by province |
| `specialty` | integer | ❌ | Filter by specialty ID |
| `city` | integer | ❌ | Filter by city ID — شامل دکترهایی که مستقیم در آن شهر هستند (`doctor_cities`) یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) |
| `state` | integer | ❌ | Filter by province ID |
### Response `200`
```json
+7 -2
View File
@@ -5,6 +5,7 @@ namespace App\Doctor\Repository;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Tools\Pagination\Paginator;
@@ -48,13 +49,17 @@ class DoctorRepository extends ServiceEntityRepository
->leftJoin('d.specialties', 's')
->leftJoin('d.provinces', 'pr')
->leftJoin('d.cities', 'ci')
->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors')
->leftJoin(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL')
->distinct();
if (!empty($filters['state'])) {
$qb->andWhere('pr.id = :state')->setParameter('state', (int) $filters['state']);
$qb->andWhere('pr.id = :state OR IDENTITY(ca.province) = :state')
->setParameter('state', (int) $filters['state']);
}
if (!empty($filters['city'])) {
$qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']);
$qb->andWhere('ci.id = :city OR IDENTITY(ca.city) = :city')
->setParameter('city', (int) $filters['city']);
}
if (!empty($filters['specialty'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);