diff --git a/assets/admin/pages/AppointmentsPage.test.tsx b/assets/admin/pages/AppointmentsPage.test.tsx
index ac7c6279..826d0ede 100644
--- a/assets/admin/pages/AppointmentsPage.test.tsx
+++ b/assets/admin/pages/AppointmentsPage.test.tsx
@@ -61,9 +61,14 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic1' } as any);
get.mockImplementation((url: string) => {
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 5, completed: 1, waiting: 3, cancelled: 1 } });
- if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: { data: [
- { uuid: 'd1', name: 'دکتر محمدی' }, { uuid: 'd2', name: 'دکتر رضایی' },
- ] } });
+ // کلینیک هم از اندپوینت احرازشده میخواند؛ `has_schedule` مبنای ساختن تب است.
+ if (url.includes('/my/clinic-doctors') || url.includes('/clinic/doctor-list/')) {
+ return Promise.resolve({ success: true, data: { data: [
+ { uuid: 'd1', name: 'دکتر محمدی', has_schedule: true },
+ { uuid: 'd2', name: 'دکتر رضایی', has_schedule: true },
+ { uuid: 'd3', name: 'دکتر بیبرنامه', has_schedule: false },
+ ] } });
+ }
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
// منابع زیر نظر پزشکاند؛ تب هر منبع فقط زیر ناظرِ خودش دیده میشود.
@@ -108,6 +113,14 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
expect(calls.some((u: string) => u.includes('doctor_uuid='))).toBe(false);
});
+ /** پزشکی که در تنظیمات نوبتدهی روز کاری تعریف نکرده تب نمیگیرد. */
+ it('پزشک بدون برنامهٔ کاری تب نمیگیرد', async () => {
+ renderWithProviders();
+ expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument();
+ expect(screen.getByText('دکتر رضایی')).toBeInTheDocument();
+ expect(screen.queryByText('دکتر بیبرنامه')).toBeNull();
+ });
+
it('auto-selects the first doctor so the timeline loads its slots', async () => {
renderWithProviders();
await screen.findByText('دکتر محمدی');
diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx
index 09b5891e..fccb3669 100644
--- a/assets/admin/pages/AppointmentsPage.tsx
+++ b/assets/admin/pages/AppointmentsPage.tsx
@@ -595,12 +595,13 @@ export default function AppointmentsPage() {
const pagedAppointments = filteredAppointments.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE);
// ── Clinic doctors (authoritative list for tabs)
- const clinicDoctorsQuery = useQuery>({
- queryKey: ['clinic-doctors', dbUuid, isSecretary],
- // منشی از اندپوینتِ احرازشده میگیرد تا فقط پزشکانِ تخصیصیافتهاش بیایند —
- // چه کلینیک چندپزشکه و چه پزشک مستقل؛ کلینیک/ادمین از لیستِ عمومیِ کلینیک.
+ const clinicDoctorsQuery = useQuery>({
+ queryKey: ['clinic-doctors', dbUuid, isSecretary, isClinic],
+ // منشی و کلینیک از اندپوینتِ احرازشده میگیرند: هم فقط پزشکانِ مجاز را میدهد و هم
+ // `has_schedule` را، که مبنای ساختن تب است. ادمین از لیستِ کلینیکِ انتخابشده
+ // میخواند (آن اندپوینت نقش ادمین را پوشش نمیدهد) و آنجا فلگ نمیآید.
queryFn: () => api.get(
- isSecretary
+ isSecretary || isClinic
? '/api/v1/my/clinic-doctors'
: `/api/v1/clinic/doctor-list/${dbUuid}`,
),
@@ -610,7 +611,11 @@ export default function AppointmentsPage() {
const doctors = React.useMemo(() => {
const map = new Map();
- clinicDoctorsList.forEach(d => map.set(d.uuid, d.name));
+ // پزشکِ بدون برنامهٔ کاری تب نمیگیرد — تبش جز یک تایملاین همیشهخالی چیزی ندارد.
+ // نبودِ فیلد (مسیر ادمین) یعنی «نمیدانیم»، پس پنهان نمیشود.
+ clinicDoctorsList
+ .filter(d => d.has_schedule !== false)
+ .forEach(d => map.set(d.uuid, d.name));
appointments.forEach(a => {
if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) {
map.set(a.doctor_uuid, a.doctor_name);
diff --git a/docs/api/appointment.md b/docs/api/appointment.md
index 49895ba4..9814e3f2 100644
--- a/docs/api/appointment.md
+++ b/docs/api/appointment.md
@@ -1085,6 +1085,23 @@ New query param `reserve=1` → returns only reserve-list entries; without it on
| `service_total_minutes` | int\|null | مدت ثبتشده؛ در حالت اسلاتی `null` |
| `service_buffer_minutes` | int\|null | بافر مؤثر لحظهٔ ثبت |
+### `has_schedule` روی فهرست پزشکان (2026-08)
+
+`GET /api/v1/my/clinic-doctors` برای هر پزشک `has_schedule` میدهد: آیا در محیط جاری
+`WeeklySchedule` دارد یا نه. صفحهٔ نوبتها فقط برای پزشکِ دارای برنامه تب میسازد —
+تبِ پزشکی که روز کاری ندارد جز یک تایملاین همیشهخالی چیزی نشان نمیدهد.
+
+با یک کوئری برای کل فهرست گرفته میشود (`findByDoctors`)، نه یکی بهازای هر پزشک.
+
+```json
+{ "success": true, "data": { "data": [
+ { "uuid": "631e81d8-…", "name": "امیر کاظمی", "has_schedule": true }
+] } }
+```
+
+`GET /api/v1/clinic/doctor-list/{clinicUuid}` این فیلد را **ندارد** (عمومی است و نقش
+ادمین را سرو میکند)؛ کلاینت نبودِ فیلد را «نمیدانیم» میگیرد و پزشک را پنهان نمیکند.
+
### فیلتر و فیلدِ منبع (2026-08)
صفحهٔ نوبتها برای هر منبع تبِ مستقل دارد، پس فهرست باید بتواند «نوبتهای همین دستگاه/اتاق»
diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php
index 39d78051..1a22a87b 100644
--- a/src/Appointment/Controller/MyAppointmentsController.php
+++ b/src/Appointment/Controller/MyAppointmentsController.php
@@ -46,6 +46,7 @@ class MyAppointmentsController extends BaseController
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly VisitPriceRequirementResolver $visitPriceResolver,
private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership,
+ private readonly \App\Appointment\Repository\WeeklyScheduleRepository $scheduleRepo,
) {}
/**
@@ -77,12 +78,28 @@ class MyAppointmentsController extends BaseController
$doctors = $doctor !== null ? [$doctor] : [];
}
+ /**
+ * `has_schedule`: آیا این پزشک در محیط جاری برنامهٔ کاری تعریف کرده؟
+ *
+ * صفحهٔ نوبتها فقط برای پزشکِ دارای برنامه تب میسازد — تبِ پزشکی که روز کاری
+ * ندارد جز یک تایملاین همیشهخالی چیزی نشان نمیدهد. یک کوئری برای کل فهرست
+ * گرفته میشود، نه یکی بهازای هر پزشک.
+ */
+ $scheduledDoctorIds = [];
+ foreach ($this->scheduleRepo->findByDoctors($doctors) as $schedule) {
+ $scheduledDoctorIds[(int) $schedule->getDoctor()->getId()] = true;
+ }
+
$data = array_map(
- static fn(Doctor $d) => ['uuid' => $d->getUuid(), 'name' => $d->getName()],
+ static fn(Doctor $d) => [
+ 'uuid' => $d->getUuid(),
+ 'name' => $d->getName(),
+ 'has_schedule' => isset($scheduledDoctorIds[(int) $d->getId()]),
+ ],
$doctors,
);
- return $this->success(['data' => $data]);
+ return $this->success(['data' => array_values($data)]);
}
#[Route('/api/v1/my/appointment', methods: ['POST'])]
diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php
index 37931eb9..b0158b22 100644
--- a/tests/ApiTestCase.php
+++ b/tests/ApiTestCase.php
@@ -24,6 +24,8 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
* users and issue JWTs so tests can hit authenticated /api and /oauth endpoints.
* Runs against the dedicated db_test database (see .env.test / doctrine when@test).
*/
+use App\Doctor\Entity\DoctorAddress;
+
abstract class ApiTestCase extends WebTestCase
{
/**
@@ -255,4 +257,32 @@ abstract class ApiTestCase extends WebTestCase
return array_sum(array_map('count', $holder->getData()));
}
+ /**
+ * پزشکِ ناظرِ محیطِ همین آدرس — از وقتی ناظر روی منبع الزامی شد، هر ساختِ منبع
+ * یکی لازم دارد. برای کلینیک یک پزشک عضو ساخته میشود و برای مطب، خودِ پزشک.
+ */
+ protected function supervisorFor(DoctorAddress $address): Doctor
+ {
+ if ($address->tenantEntityType() === 'doctor') {
+ return $this->em->getRepository(Doctor::class)->find($address->tenantEntityId());
+ }
+
+ $clinic = $this->em->getRepository(Clinic::class)->find($address->tenantEntityId());
+
+ foreach ($clinic->getDoctors() as $existing) {
+ return $existing;
+ }
+
+ $user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
+ $doctor = new Doctor($user, 'پزشک ناظر');
+ $doctor->setMobileNumber($user->getMobileNumber());
+ $this->em->persist($doctor);
+ $this->em->flush();
+
+ $clinic->getDoctors()->add($doctor);
+ $this->em->flush();
+
+ return $doctor;
+ }
+
}
diff --git a/tests/Appointment/AppointmentPlanTest.php b/tests/Appointment/AppointmentPlanTest.php
index 366b2ad5..ac54763d 100644
--- a/tests/Appointment/AppointmentPlanTest.php
+++ b/tests/Appointment/AppointmentPlanTest.php
@@ -65,6 +65,7 @@ class AppointmentPlanTest extends ApiTestCase
{
$body = $this->authJson('POST', '/api/v1/resource', $user, $extra + [
'address_uuid' => $address->getUuid(),
+ 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => $name,
]);
diff --git a/tests/Appointment/AvailabilityEngineTest.php b/tests/Appointment/AvailabilityEngineTest.php
index 1b41ac4e..1dd10bad 100644
--- a/tests/Appointment/AvailabilityEngineTest.php
+++ b/tests/Appointment/AvailabilityEngineTest.php
@@ -74,6 +74,7 @@ class AvailabilityEngineTest extends ApiTestCase
{
$created = $this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
+ 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => $name,
]);
@@ -244,6 +245,7 @@ class AvailabilityEngineTest extends ApiTestCase
$created = $this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
+ 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
'type_uuid' => $room->getUuid(),
'name' => 'اتاق سهتخته',
'capacity' => 3,
@@ -297,6 +299,7 @@ class AvailabilityEngineTest extends ApiTestCase
// منبع هست ولی هیچ شیفتی ندارد.
$this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
+ 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
'type_uuid' => $room->getUuid(),
'name' => 'اتاق بیشیفت',
]);
@@ -350,6 +353,7 @@ class AvailabilityEngineTest extends ApiTestCase
$created = $this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
+ 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
'type_uuid' => $device->getUuid(),
'name' => 'لیزر تنها',
'setup_minutes' => 10,
diff --git a/tests/Appointment/HoldAndBookTest.php b/tests/Appointment/HoldAndBookTest.php
index 28d86c98..c4b1ac6f 100644
--- a/tests/Appointment/HoldAndBookTest.php
+++ b/tests/Appointment/HoldAndBookTest.php
@@ -106,6 +106,7 @@ class HoldAndBookTest extends ApiTestCase
{
$created = $this->authJson('POST', '/api/v1/resource', $user, $extra + [
'address_uuid' => $address->getUuid(),
+ 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => $name,
]);
diff --git a/tests/Pricing/PricingTest.php b/tests/Pricing/PricingTest.php
index ff50968d..227edf6e 100644
--- a/tests/Pricing/PricingTest.php
+++ b/tests/Pricing/PricingTest.php
@@ -259,9 +259,10 @@ class PricingTest extends ApiTestCase
$this->em->flush();
$resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [
- 'address_uuid' => $c['address']->getUuid(),
- 'type_uuid' => $room->getUuid(),
- 'name' => 'اتاق ۱',
+ 'address_uuid' => $c['address']->getUuid(),
+ 'type_uuid' => $room->getUuid(),
+ 'name' => 'اتاق ۱',
+ 'supervisor_doctor_uuid' => $this->supervisorFor($c['address'])->getUuid(),
]);
self::assertSame(201, $this->responseCode());
@@ -344,9 +345,10 @@ class PricingTest extends ApiTestCase
$this->em->flush();
$resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [
- 'address_uuid' => $c['address']->getUuid(),
- 'type_uuid' => $room->getUuid(),
- 'name' => 'اتاق ویزیت',
+ 'address_uuid' => $c['address']->getUuid(),
+ 'type_uuid' => $room->getUuid(),
+ 'name' => 'اتاق ویزیت',
+ 'supervisor_doctor_uuid' => $this->supervisorFor($c['address'])->getUuid(),
]);
self::assertSame(201, $this->responseCode());
diff --git a/tests/Resource/ResourceTestCase.php b/tests/Resource/ResourceTestCase.php
index 5cb1fdb8..3e67f6da 100644
--- a/tests/Resource/ResourceTestCase.php
+++ b/tests/Resource/ResourceTestCase.php
@@ -61,34 +61,6 @@ abstract class ResourceTestCase extends ApiTestCase
return [$user, $doctor, $address];
}
- /**
- * پزشکِ ناظرِ محیطِ همین آدرس — از وقتی ناظر روی منبع الزامی شد، هر ساختِ منبع
- * یکی لازم دارد. برای کلینیک یک پزشک عضو ساخته میشود و برای مطب، خودِ پزشک.
- */
- protected function supervisorFor(DoctorAddress $address): Doctor
- {
- if ($address->tenantEntityType() === 'doctor') {
- return $this->em->getRepository(Doctor::class)->find($address->tenantEntityId());
- }
-
- $clinic = $this->em->getRepository(Clinic::class)->find($address->tenantEntityId());
-
- foreach ($clinic->getDoctors() as $existing) {
- return $existing;
- }
-
- $user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
- $doctor = new Doctor($user, 'پزشک ناظر');
- $doctor->setMobileNumber($user->getMobileNumber());
- $this->em->persist($doctor);
- $this->em->flush();
-
- $clinic->getDoctors()->add($doctor);
- $this->em->flush();
-
- return $doctor;
- }
-
protected function resourceType(DoctorAddress $address, string $code = 'device', string $name = 'دستگاه'): ResourceType
{
$type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);