diff --git a/assets/admin/pages/DashboardPage.test.tsx b/assets/admin/pages/DashboardPage.test.tsx index 47414d8f..50ae9117 100644 --- a/assets/admin/pages/DashboardPage.test.tsx +++ b/assets/admin/pages/DashboardPage.test.tsx @@ -188,3 +188,60 @@ describe('DashboardPage (ported clinic dashboard)', () => { ).toBeInTheDocument(); }); }); + +describe('داشبورد پرسنل', () => { + const staffPayload = { + success: true, + data: { + scope: 'clinic', + staff: { uuid: 'st-1', full_name: 'پرسنل۱', job_title: 'پرسنل لیزر' }, + owner: { name: 'مدیسا' }, + stats: { today_sessions: 2, today_appointments: 0, services: 1 }, + services: [{ uuid: 'svc-1', name: 'لیزر توتال', section_name: 'لیزر', price_rials: 10_000_000, duration_minutes: 40 }], + today_sessions: [ + { uuid: 'ses-1', session_number: 1, total_sessions: 3, status: 'in_progress', service_name: 'لیزر توتال', patient_name: 'محمد رسولی', slot_start: 1_786_084_200 }, + { uuid: 'ses-2', session_number: 1, total_sessions: 3, status: 'done', service_name: 'لیزر توتال', patient_name: 'محمد رستمی', slot_start: 1_786_080_600 }, + ], + today_appointments: [], + }, + }; + + beforeEach(() => { + get.mockReset(); + useAuthStore.setState({ primaryRole: 'staff' } as never); + get.mockResolvedValue(staffPayload); + }); + + /** کارِ اپراتور جلسهٔ درمان است؛ `appointments.staff_id` را هیچ مسیری پر نمی‌کند. */ + it('کار امروز را از جلسات درمان می‌سازد، نه نوبت‌های اختصاص‌یافته', async () => { + renderWithProviders(); + + expect(await screen.findByText('محمد رسولی')).toBeInTheDocument(); + expect(screen.getByText('محمد رستمی')).toBeInTheDocument(); + }); + + it('کارت‌های آمار به صفحهٔ کار خودشان لینک‌اند', async () => { + renderWithProviders(); + + const sessions = (await screen.findByText('جلسات امروز من')).closest('a'); + expect(sessions).toHaveAttribute('href', '/admin/my-sessions'); + + const services = screen.getByText('سرویس‌های من').closest('a'); + expect(services).toHaveAttribute('href', '/admin/my-services'); + }); + + it('هر ردیف کار به همان جلسه می‌رود', async () => { + renderWithProviders(); + + const row = (await screen.findByText('محمد رسولی')).closest('a'); + expect(row).toHaveAttribute('href', '/admin/my-sessions/ses-1'); + }); + + /** جدولِ همیشه-خالی زیر کارِ واقعی فقط صفحه را بلند می‌کرد. */ + it('نوبت‌های اختصاص‌یافته وقتی خالی است اصلاً نمی‌آید', async () => { + renderWithProviders(); + await screen.findByText('محمد رسولی'); + + expect(screen.queryByText('نوبت‌های اختصاص‌یافته به من')).not.toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 0f07eca7..75bbe538 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -21,6 +21,7 @@ import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTab import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsPanel'; import { usePermissions } from '../hooks/usePermissions'; import { STATUS_META } from '../components/ui/AppointmentStatusDropdown'; +import StatusBadge from '../components/ui/StatusBadge'; // ── Chart period (Jalali) ───────────────────────────────────────────────── @@ -959,8 +960,18 @@ interface StaffDashboardData { scope: 'doctor' | 'clinic'; staff: { uuid: string; full_name: string; job_title: string | null }; owner: { name: string }; - stats: { today_appointments: number; services: number }; + stats: { today_sessions: number; today_appointments: number; services: number }; services: { uuid: string; name: string; section_name: string; price_rials: number; duration_minutes: number | null }[]; + /** کارِ امروزِ اپراتور. نوبتِ اختصاص‌یافته زیرمجموعهٔ همین صف است. */ + today_sessions: { + uuid: string; + session_number: number; + total_sessions: number; + status: string; + service_name: string; + patient_name: string | null; + slot_start: number | null; + }[]; today_appointments: ApptRow[]; } @@ -991,17 +1002,41 @@ function StaffDashboard() { } const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب'; + const sessions = d.today_sessions ?? []; + const settled = sessions.filter(s => ['done', 'cancelled', 'no_show'].includes(s.status)).length; + + // هر کارت به صفحهٔ کارِ خودش می‌رود. عددِ بی‌مقصد یعنی کاربر باید در سایدبار + // دنبال صفحه‌ای بگردد که کارت همین حالا اسمش را برده است. const kpiCards = [ - { label: 'نوبت‌های امروز من', value: formatNumber(d.stats?.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, - { label: 'سرویس‌های من', value: formatNumber(d.stats?.services ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' }, + { + to: '/admin/my-sessions', + label: 'جلسات امروز من', + value: formatNumber(d.stats?.today_sessions ?? 0), + hint: sessions.length > 0 ? `${formatNumber(settled)} انجام شده` : 'کاری برای امروز نیست', + icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)', + }, + { + to: '/admin/my-services', + label: 'سرویس‌های من', + value: formatNumber(d.stats?.services ?? 0), + hint: 'سرویس‌هایی که مجاز به انجامشان هستید', + icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)', + }, ]; return (
+ {/* نام و سمت به هدر آمدند: یک کارتِ تمام‌عرض برای دو خط متن، روی موبایل + کارِ روز را زیر خط تا می‌برد. */}
-
-

داشبورد پرسنل

-
{today} · {scopeLabel} {d.owner?.name ?? ''}
+
+ +
+

{d.staff?.full_name ?? 'داشبورد پرسنل'}

+
+ {d.staff?.job_title ? `${d.staff.job_title} · ` : ''}{scopeLabel} {d.owner?.name ?? ''} · {today} +
+
-
- -
-
{d.staff?.full_name ?? '—'}
- {d.staff?.job_title &&
{d.staff.job_title}
} -
-
-
{kpiCards.map(c => ( -
+
{c.label}
{c.value}
-
+
{c.hint}
+ ))}
+
+
+

کار امروز من

+ همهٔ جلسات +
+ {sessions.length === 0 ? ( +

+ امروز جلسه‌ای برای شما ثبت نشده است. +

+ ) : ( +
+ {sessions.map(s => ( + + + {s.slot_start === null + ? '—' + : new Date(s.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })} + +
+
{s.patient_name || 'بیمار نامشخص'}
+
+ {s.service_name} · جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)} +
+
+ + + ))} +
+ )} +
+

سرویس‌های تخصیص‌یافته

@@ -1053,12 +1123,16 @@ function StaffDashboard() { )}
-
-
-

نوبت‌های امروز من

+ {/* نوبتِ مستقیمِ اختصاص‌یافته فقط وقتی نشان داده می‌شود که وجود داشته باشد؛ + یک جدول همیشه-خالی زیر کارِ واقعی، فقط صفحه را بلند می‌کرد. */} + {(d.today_appointments?.length ?? 0) > 0 && ( +
+
+

نوبت‌های اختصاص‌یافته به من

+
+
- -
+ )}
); } diff --git a/docs/api/dashboard.md b/docs/api/dashboard.md index efdab7e3..f1522cdf 100644 --- a/docs/api/dashboard.md +++ b/docs/api/dashboard.md @@ -244,7 +244,16 @@ Returns stats for the authenticated secretary and (conditionally) today's appoin ## GET /api/v1/dashboard/staff -داشبورد پرسنل: سرویس‌هایی که به این پرسنل تخصیص یافته و نوبت‌های امروزِ خودش. +داشبورد پرسنل: کارِ امروزِ اپراتور و سرویس‌هایی که مجاز به انجامشان است. + +> **کارِ امروز از جلسات درمان می‌آید، نه از `appointments.staff_id`.** آن ستون را هیچ +> مسیرِ نوبت‌دهی به‌صورت پیش‌فرض پر نمی‌کند، پس `today_appointments` برای اپراتوری که +> تمام روز جلسه دارد هم خالی است. `today_sessions` همان صفِ +> `TreatmentSessionRepository::findTodayForStaff` است — یک قاعده، نه دو تا. قواعدش در +> [treatment.md](treatment.md#جلسات-امروز-من-یک-صف-است). +> +> `services` هم اتحادِ دو منبع است: تخصیصِ مستقیمِ سرویس، و پروتکلِ «طول درمان» که این +> پرسنل را مجاز دانسته. بدون دومی، اپراتورِ لیزر «هیچ سرویسی ندارید» می‌دید. **Auth:** `ROLE_STAFF` — و علاوه بر نقش، باید ردیف **فعالِ** `clinic_staff` در محیط فعال وجود داشته باشد. توکن تا انقضا معتبر می‌ماند، پس غیرفعال‌کردن پرسنل همان لحظه با همین بررسی @@ -270,7 +279,7 @@ Returns stats for the authenticated secretary and (conditionally) today's appoin "version": 1, "resources": { "services": { "view": true }, "appointments": { "view": true } } }, - "stats": { "today_appointments": 0, "services": 1 }, + "stats": { "today_sessions": 2, "today_appointments": 0, "services": 1 }, "services": [ { "uuid": "f9ffb607-f137-4ffb-8327-76427fd6fe55", @@ -280,6 +289,17 @@ Returns stats for the authenticated secretary and (conditionally) today's appoin "duration_minutes": null } ], + "today_sessions": [ + { + "uuid": "86300220-c8e3-4928-b5e2-ce54a75cd59b", + "session_number": 1, + "total_sessions": 3, + "status": "in_progress", + "service_name": "لیزر توتال", + "patient_name": "محمد رسولی", + "slot_start": 1786084200 + } + ], "today_appointments": [] } } diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php index f7ad2e1f..28ff0e9c 100644 --- a/src/Dashboard/Controller/DashboardController.php +++ b/src/Dashboard/Controller/DashboardController.php @@ -17,6 +17,9 @@ use App\Shared\Controller\BaseController; use App\Sms\Service\SmsWalletService; use App\Staff\Repository\ClinicStaffRepository; use App\Staff\Security\StaffPermissions; +use App\Treatment\Entity\TreatmentSession; +use App\Treatment\Repository\TreatmentProtocolRepository; +use App\Treatment\Repository\TreatmentSessionRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -37,6 +40,8 @@ class DashboardController extends BaseController private readonly PatientRecordRepository $patientRecordRepo, private readonly PatientSessionRepository $patientSessionRepo, private readonly EntityContextResolver $contextResolver, + private readonly TreatmentSessionRepository $treatmentSessionRepo, + private readonly TreatmentProtocolRepository $protocolRepo, private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo, private readonly ClinicStaffRepository $staffRepo, @@ -726,6 +731,40 @@ class DashboardController extends BaseController ')->setParameters(['staff' => $staff, 's' => $todayStart, 'e' => $todayEnd]) ->getArrayResult(); + /** + * کارِ امروزِ اپراتور جلسهٔ درمان است، نه نوبتِ اختصاص‌یافته. + * + * `appointments.staff_id` را هیچ مسیرِ نوبت‌دهی به‌صورت پیش‌فرض پر نمی‌کند، پس + * کوئری بالا برای اپراتوری که تمام روز جلسه دارد هم صفر برمی‌گرداند. صف واقعی + * همان قاعدهٔ `findTodayForStaff` است — یک منبعِ حقیقت، نه دو تا. + */ + $todaySessions = array_map( + static fn (TreatmentSession $s): array => [ + 'uuid' => $s->getUuid(), + 'session_number'=> $s->getSessionNumber(), + 'total_sessions'=> $s->getTreatmentCase()->getTotalSessions(), + 'status' => $s->getStatus(), + 'service_name' => $s->getTreatmentCase()->getServiceItem()->getName(), + 'patient_name' => $s->getAppointment()?->getPatientName(), + 'slot_start' => $s->getAppointment()?->getSlotStart(), + ], + $this->treatmentSessionRepo->findTodayForStaff($staff, $todayStart, $todayEnd), + ); + + /** + * سرویس‌های اپراتور از دو جا می‌آیند: تخصیص مستقیم، و پروتکلِ «طول درمان» که + * او را مجاز دانسته. بدون دومی، اپراتورِ لیزر که تمام کارش از پروتکل می‌آید + * «هیچ سرویسی ندارید» می‌دید. + */ + $byAssignment = $this->serviceItemRepo->findByStaff($staff); + $byProtocol = $this->protocolRepo->findServicesForStaff($staff); + + $merged = []; + foreach ([...$byAssignment, ...$byProtocol] as $item) { + $merged[$item->getUuid()] = $item; + } + ksort($merged); + $services = array_map( static fn(ServiceItem $item) => [ 'uuid' => $item->getUuid(), @@ -734,7 +773,7 @@ class DashboardController extends BaseController 'price_rials' => $item->getPriceRials(), 'duration_minutes' => $item->getDurationMinutes(), ], - $this->serviceItemRepo->findByStaff($staff), + array_values($merged), ); return $this->success([ @@ -751,10 +790,12 @@ class DashboardController extends BaseController ], 'permissions' => StaffPermissions::DEFAULT, 'stats' => [ + 'today_sessions' => count($todaySessions), 'today_appointments' => count($todayAppts), 'services' => count($services), ], 'services' => $services, + 'today_sessions' => $todaySessions, 'today_appointments' => $todayAppts, ]); } diff --git a/src/Treatment/Repository/TreatmentProtocolRepository.php b/src/Treatment/Repository/TreatmentProtocolRepository.php index 866e7cb7..32df6f5e 100644 --- a/src/Treatment/Repository/TreatmentProtocolRepository.php +++ b/src/Treatment/Repository/TreatmentProtocolRepository.php @@ -3,6 +3,7 @@ namespace App\Treatment\Repository; use App\ClinicService\Entity\ServiceItem; +use App\Staff\Entity\ClinicStaff; use App\Treatment\Entity\TreatmentProtocol; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -27,4 +28,36 @@ class TreatmentProtocolRepository extends ServiceEntityRepository { return $this->findOneBy(['serviceItem' => $service, 'active' => true]); } + + /** + * سرویس‌هایی که پروتکلِ فعالشان این پرسنل را مجاز دانسته. + * + * تخصیصِ مستقیمِ سرویس (`ServiceItemRepository::findByStaff`) این‌ها را نمی‌بیند، + * ولی اپراتوری که تمام کارش از پروتکل می‌آید واقعاً همین سرویس‌ها را انجام می‌دهد. + * + * @return list + */ + public function findServicesForStaff(ClinicStaff $staff): array + { + // ریشه ServiceItem است نه پروتکل: Doctrine اجازه نمی‌دهد entityِ join‌شده را + // بدون alias ریشه select کنی. + return $this->getEntityManager()->createQueryBuilder() + ->select('si') + ->from(ServiceItem::class, 'si') + ->join(TreatmentProtocol::class, 'p', 'WITH', 'p.serviceItem = si') + ->join('p.allowedStaff', 'ps') + ->join('si.section', 'sec') + ->where('ps.staff = :staff') + ->andWhere('p.active = true') + ->andWhere('si.active = true') + // محیط، وگرنه پرسنلِ یک کلینیک سرویس کلینیک دیگر را می‌بیند. + ->andWhere('sec.entityType = :type') + ->andWhere('sec.entityId = :id') + ->setParameter('staff', $staff) + ->setParameter('type', $staff->getEntityType()) + ->setParameter('id', $staff->getEntityId()) + ->orderBy('si.name', 'ASC') + ->getQuery() + ->getResult(); + } }