From 5b3e80f92af472c6cd27290e6d2e585676228b07 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 14:16:00 +0330 Subject: [PATCH] fix(dashboard): make the staff dashboard show the operator's actual work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every number on it was zero. "نوبت‌های امروز من" counted appointments where appointments.staff_id matches — a column no booking path fills by default, and which is NULL on every row in the database. "سرویس‌های من" read only direct service assignment, so an operator whose whole job comes from a treatment protocol was told they had no services. The landing page of the only role that has one data page said, in effect, that they had nothing to do — while they had two sessions booked that day. Today's work now comes from TreatmentSessionRepository::findTodayForStaff, the same queue rule the sessions page uses, so there is one definition of "my work today" rather than two that disagree. Services are the union of direct assignment and protocol authorisation. The two stat cards are links to the pages they name; a number with no destination made the user hunt the sidebar for a page the card had just mentioned. Each row of the work list opens that session. The avatar moves from its own full-width card into the header — two lines of text were costing a card and pushing the day's work below the fold on mobile. The assigned-appointments table renders only when it has rows. Co-Authored-By: Claude Opus 5 --- assets/admin/pages/DashboardPage.test.tsx | 57 +++++++++ assets/admin/pages/DashboardPage.tsx | 116 ++++++++++++++---- docs/api/dashboard.md | 24 +++- .../Controller/DashboardController.php | 43 ++++++- .../TreatmentProtocolRepository.php | 33 +++++ 5 files changed, 249 insertions(+), 24 deletions(-) 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(); + } }