diff --git a/assets/admin/components/dashboard/DoctorAppointmentsPanel.tsx b/assets/admin/components/dashboard/DoctorAppointmentsPanel.tsx new file mode 100644 index 00000000..9d01e7e4 --- /dev/null +++ b/assets/admin/components/dashboard/DoctorAppointmentsPanel.tsx @@ -0,0 +1,171 @@ +/** + * «لیست نوبت‌های جدید» داشبورد پزشک، با فیلتر و صفحه‌بندی سمت سرور. + * + * چرا endpoint داشبورد استفاده نمی‌شود: `/api/v1/dashboard/doctor` فقط ۱۰ نوبتِ + * امروز را برمی‌گرداند و `version` ندارد، پس نه فیلتر معنا می‌دهد نه تغییر وضعیت. + * به‌جای ساخت endpoint جدید، `/api/v1/appointments/doctor/{uuid}` توسعه داده شد. + */ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../lib/api'; +import SearchableSelect from '../ui/SearchableSelect'; +import PersianDateInput from '../ui/PersianDateInput'; +import Pagination from '../ui/Pagination'; +import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable'; + +const PER_PAGE = 20; + +/** وضعیت‌هایی که «هنوز ویزیت نشده» محسوب می‌شوند — فیلتر پیش‌فرض. */ +export const NOT_VISITED_STATUSES = ['pending', 'confirmed']; + +const STATUS_OPTIONS: { value: string; label: string }[] = [ + { value: '', label: 'ویزیت‌نشده‌ها (پیش‌فرض)' }, + { value: 'pending', label: 'ثبت شده' }, + { value: 'confirmed', label: 'قطعی شده' }, + { value: 'following_up', label: 'در حال پیگیری' }, + { value: 'salon', label: 'سالن' }, + { value: 'completed', label: 'ویزیت شده' }, + { value: 'cancelled_by_doctor', label: 'لغو شده' }, + { value: 'cancelled_by_user', label: 'لغو توسط بیمار' }, + { value: 'no_show', label: 'غیبت' }, + { value: 'expired', label: 'منقضی شده' }, +]; + +interface ServiceOption { uuid: string; name?: string } + +/** YYYY-MM-DD میلادی → تایم‌استمپ ثانیه‌ای در ابتدا/انتهای همان روز محلی. */ +function dayBound(iso: string, edge: 'start' | 'end'): number | null { + if (!iso) return null; + const [y, m, d] = iso.split('-').map(Number); + if (!y || !m || !d) return null; + const dt = edge === 'start' ? new Date(y, m - 1, d, 0, 0, 0) : new Date(y, m - 1, d, 23, 59, 59); + return Math.floor(dt.getTime() / 1000); +} + +interface ApiAppointment { + uuid: string; + status: string; + version: number; + slot_start: number; + slot_end?: number | null; + patient_name?: string | null; + patient_mobile?: string | null; + doctor?: { name?: string | null } | null; + user?: { mobile?: string | null } | null; + service_item?: { name?: string | null } | null; +} + +export default function DoctorAppointmentsPanel({ doctorUuid, clinicUuid }: { + doctorUuid?: string | null; + clinicUuid?: string | null; +}) { + const [status, setStatus] = useState(''); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [q, setQ] = useState(''); + const [service, setService] = useState(''); + const [page, setPage] = useState(1); + + const servicesQ = useQuery<{ data?: ServiceOption[] }>({ + queryKey: ['service-sections'], + queryFn: () => api.get('/api/v1/service-sections'), + }); + + const params = useMemo(() => { + const p = new URLSearchParams(); + // فیلتر پیش‌فرض: هر نوبتی که هنوز ویزیت یا لغو نشده. + // براکت لازم است تا Symfony پارامتر را آرایه ببیند، نه رشته. + (status ? [status] : NOT_VISITED_STATUSES).forEach(s => p.append('statuses[]', s)); + const f = dayBound(from, 'start'); + const t = dayBound(to, 'end'); + if (f !== null) p.set('from', String(f)); + if (t !== null) p.set('to', String(t)); + if (q.trim()) p.set('q', q.trim()); + if (service) p.set('service_uuid', service); + if (clinicUuid) p.set('clinic_uuid', clinicUuid); + p.set('page', String(page)); + p.set('limit', String(PER_PAGE)); + return p.toString(); + }, [status, from, to, q, service, clinicUuid, page]); + + const queryKey = ['doctor-appointments', doctorUuid, params]; + + const listQ = useQuery<{ data?: ApiAppointment[]; meta?: { totalRecords?: number } }>({ + queryKey, + queryFn: () => api.get(`/api/v1/appointments/doctor/${doctorUuid}?${params}`), + enabled: !!doctorUuid, + staleTime: 30_000, + }); + + const rows: ApptRow[] = useMemo( + () => (listQ.data?.data ?? []).map(a => ({ + uuid: a.uuid, + patient_name: a.patient_name ?? null, + patient_mobile: a.patient_mobile ?? a.user?.mobile ?? null, + doctor_name: a.doctor?.name ?? null, + service_name: a.service_item?.name ?? null, + slot_start: a.slot_start, + slot_end: a.slot_end ?? null, + status: a.status, + version: a.version, + })), + [listQ.data], + ); + + const total = listQ.data?.meta?.totalRecords ?? 0; + + /** هر تغییر فیلتر صفحه را به اول برمی‌گرداند تا کاربر روی صفحهٔ خالی نماند. */ + const onFilter = (setter: (v: T) => void) => (v: T) => { setter(v); setPage(1); }; + + return ( + <> +
+
+ onFilter(setStatus)(v == null ? '' : String(v))} + options={STATUS_OPTIONS} + placeholder="وضعیت" + /> +
+
+ +
+
+ +
+
+ onFilter(setService)(v == null ? '' : String(v))} + options={[ + { value: '', label: 'همهٔ سرویس‌ها' }, + ...(servicesQ.data?.data ?? []).map(s => ({ value: s.uuid, label: s.name ?? '—' })), + ]} + placeholder="سرویس" + /> +
+ onFilter(setQ)(e.target.value)} + placeholder="نام یا شماره تماس بیمار" + /> +
+ + + + {total > PER_PAGE && ( +
+ +
+ )} + + ); +} diff --git a/assets/admin/components/dashboard/NewAppointmentsTable.tsx b/assets/admin/components/dashboard/NewAppointmentsTable.tsx index 2907112c..547293fb 100644 --- a/assets/admin/components/dashboard/NewAppointmentsTable.tsx +++ b/assets/admin/components/dashboard/NewAppointmentsTable.tsx @@ -89,6 +89,11 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro {formatTime(r.slot_end)} {r.service_name || '—'} {r.doctor_name ? `دکتر ${r.doctor_name}` : '—'} + + {queryKey && r.version != null + ? + : } + + + {meta.label} + + ); +} + /** همه‌ی سلول‌ها text-start هستند تا دقیقاً زیر هدر هم‌نامشان بنشینند. */ function Cell({ children, className = '' }: { children: React.ReactNode; className?: string }) { return ( diff --git a/assets/admin/components/dashboard/TauriDashboardView.tsx b/assets/admin/components/dashboard/TauriDashboardView.tsx index 09326d60..ae042fc5 100644 --- a/assets/admin/components/dashboard/TauriDashboardView.tsx +++ b/assets/admin/components/dashboard/TauriDashboardView.tsx @@ -77,6 +77,8 @@ export interface TauriDashboardViewProps { /** «میزان درآمد» series (revenue_by_day) */ incomeLine: ChartPoint[]; appointments: ApptRow[]; + /** جایگزین جدول ساده — برای نمایش نسخهٔ فیلتردار/قابل‌ویرایش. */ + appointmentsSlot?: React.ReactNode; loading: boolean; formatNumber: (n: number) => string; formatRial: (rial: number) => string; @@ -97,6 +99,7 @@ export function TauriDashboardView({ patientBars, incomeLine, appointments, + appointmentsSlot, loading, formatNumber, formatRial, @@ -152,7 +155,8 @@ export function TauriDashboardView({
- + {/* والد می‌تواند نسخهٔ فیلتردار را جای جدول ساده بنشاند. */} + {appointmentsSlot ?? }
diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 4fa95aec..655389a7 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -18,6 +18,7 @@ const jalaali = require('jalaali-js') as { toJalaali: (date: Date) => { jy: number; jm: number; jd: number }; }; import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTable'; +import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsPanel'; import { usePermissions } from '../hooks/usePermissions'; // ── Chart period (Jalali) ───────────────────────────────────────────────── @@ -827,6 +828,7 @@ function DoctorDashboard() { patientBars={(d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }))} incomeLine={(d?.charts?.revenue_by_month ?? []).map(x => ({ label: x.label, value: x.amount_rials }))} appointments={d?.today_appointments ?? []} + appointmentsSlot={} loading={q.isFetching} formatNumber={formatNumber} formatRial={formatRial} @@ -1152,7 +1154,7 @@ function InvitedDoctorDashboard() {

لیست نوبت‌های جدید

نوبت‌ها - + )} diff --git a/docs/api/appointment.md b/docs/api/appointment.md index c39f39e6..473919e5 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -375,7 +375,21 @@ never leak into a clinic. Anyone else gets `403`. ### Query Parameters | Param | Type | Required | Description | |-------|------|----------|-------------| -| `status` | string | ❌ | Filter: `pending`, `confirmed`, `cancelled`, `completed`, `no_show` | +| `status` | string | ❌ | Single-status filter (legacy) | +| `statuses` | string[] | ❌ | Repeatable: `statuses=pending&statuses=confirmed` | +| `from` | int | ❌ | Unix ts — `slot_start >= from` | +| `to` | int | ❌ | Unix ts — `slot_start <= to` | +| `q` | string | ❌ | Substring match on patient name / mobile (appointment *and* user fields) | +| `service_uuid` | string (UUID) | ❌ | Filter by service item | +| `page` | int | ❌ | Default `1` | +| `limit` | int | ❌ | Default `20`, max `100` | + +**Two response shapes.** With **none** of `statuses`/`from`/`to`/`q`/`service_uuid`/`page`/`limit` +present, the legacy nested-array response below is returned unchanged. With **any** of them +present the response is the standard paginated envelope +(`{ success, data: [...], meta: { totalRecords, totalPages, currentPage, limit } }`). +The doctor dashboard filter bar uses the paginated form, defaulting `statuses` to +`pending`, `confirmed`, `following_up`, `salon` (i.e. "not yet visited"). ### Response `200` ```json diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index f6f815d1..0ce8676d 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -654,7 +654,10 @@ class AppointmentController extends BaseController return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); } - $statuses = $request->query->all('statuses'); + // هم `statuses[]=a&statuses[]=b` و هم `statuses=a,b` پذیرفته می‌شود؛ سینتکس + // دوم بدون براکت در Symfony به رشته تبدیل می‌شود و all() استثنا می‌دهد. + $rawStatuses = $request->query->has('statuses') ? $request->query->all()['statuses'] : []; + $statuses = is_array($rawStatuses) ? $rawStatuses : explode(',', (string) $rawStatuses); if ($statuses === [] && $request->query->get('status')) { $statuses = [$request->query->get('status')]; } diff --git a/tests/Appointment/DoctorAppointmentFilterTest.php b/tests/Appointment/DoctorAppointmentFilterTest.php new file mode 100644 index 00000000..dd598482 --- /dev/null +++ b/tests/Appointment/DoctorAppointmentFilterTest.php @@ -0,0 +1,107 @@ +em->getRepository(Appointment::class); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function booking(Doctor $doctor, int $start, ?string $status = null, ?string $name = null): Appointment + { + $a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800); + if ($name !== null) { + $a->setPatientName($name); + } + if ($status === Appointment::STATUS_COMPLETED) { + $a->transitionTo(Appointment::STATUS_CONFIRMED); + $a->transitionTo(Appointment::STATUS_COMPLETED); + } elseif ($status !== null) { + $a->transitionTo($status); + } + $this->em->persist($a); + $this->em->flush(); + + return $a; + } + + public function testStatusFilterExcludesVisited(): void + { + $doctor = $this->makeDoctor(); + $base = time() + 86_400; + $this->booking($doctor, $base); // pending + $this->booking($doctor, $base + 3_600, Appointment::STATUS_CONFIRMED); + $this->booking($doctor, $base + 7_200, Appointment::STATUS_COMPLETED); + + $res = $this->repo()->searchByDoctor( + $doctor, + [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], + ); + + self::assertSame(2, $res['total']); + foreach ($res['items'] as $a) { + self::assertNotSame(Appointment::STATUS_COMPLETED, $a->getStatus()); + } + } + + public function testDateRangeAndNameSearch(): void + { + $doctor = $this->makeDoctor(); + $base = time() + 86_400; + $this->booking($doctor, $base, null, 'علی رضایی'); + $this->booking($doctor, $base + 200_000, null, 'مریم کاظمی'); + + $inRange = $this->repo()->searchByDoctor($doctor, [], null, $base - 60, $base + 60); + self::assertSame(1, $inRange['total']); + self::assertSame('علی رضایی', $inRange['items'][0]->getPatientName()); + + $byName = $this->repo()->searchByDoctor($doctor, [], null, null, null, 'کاظمی'); + self::assertSame(1, $byName['total']); + self::assertSame('مریم کاظمی', $byName['items'][0]->getPatientName()); + } + + public function testPaginationSlicesAndReportsFullTotal(): void + { + $doctor = $this->makeDoctor(); + $base = time() + 86_400; + for ($i = 0; $i < 5; $i++) { + $this->booking($doctor, $base + $i * 3_600); + } + + $page2 = $this->repo()->searchByDoctor($doctor, [], null, null, null, null, null, 2, 2); + + self::assertSame(5, $page2['total'], 'total باید کل نتایج باشد نه اندازهٔ صفحه'); + self::assertCount(2, $page2['items']); + } + + public function testEmptyResultForUnmatchedFilter(): void + { + $doctor = $this->makeDoctor(); + $this->booking($doctor, time() + 86_400); + + $res = $this->repo()->searchByDoctor($doctor, [Appointment::STATUS_NO_SHOW]); + + self::assertSame(0, $res['total']); + self::assertSame([], $res['items']); + } +}