feat: implement filtered and paginated doctor appointments panel with status filtering
This commit is contained in:
@@ -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 = <T,>(setter: (v: T) => void) => (v: T) => { setter(v); setPage(1); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-[10px] items-end mb-[14px]">
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<SearchableSelect
|
||||
value={status}
|
||||
onChange={v => onFilter(setStatus)(v == null ? '' : String(v))}
|
||||
options={STATUS_OPTIONS}
|
||||
placeholder="وضعیت"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<PersianDateInput value={from} onChange={onFilter(setFrom)} placeholder="از تاریخ" />
|
||||
</div>
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<PersianDateInput value={to} onChange={onFilter(setTo)} placeholder="تا تاریخ" />
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<SearchableSelect
|
||||
value={service}
|
||||
onChange={v => onFilter(setService)(v == null ? '' : String(v))}
|
||||
options={[
|
||||
{ value: '', label: 'همهٔ سرویسها' },
|
||||
...(servicesQ.data?.data ?? []).map(s => ({ value: s.uuid, label: s.name ?? '—' })),
|
||||
]}
|
||||
placeholder="سرویس"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
style={{ minWidth: 190 }}
|
||||
value={q}
|
||||
onChange={e => onFilter(setQ)(e.target.value)}
|
||||
placeholder="نام یا شماره تماس بیمار"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewAppointmentsTable
|
||||
rows={rows}
|
||||
loading={listQ.isLoading}
|
||||
queryKey={queryKey}
|
||||
emptyText="نوبتی با این فیلترها یافت نشد"
|
||||
/>
|
||||
|
||||
{total > PER_PAGE && (
|
||||
<div className="mt-[14px]">
|
||||
<Pagination page={page} total={total} limit={PER_PAGE} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +89,11 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro
|
||||
<Cell><bdi dir="ltr">{formatTime(r.slot_end)}</bdi></Cell>
|
||||
<Cell>{r.service_name || '—'}</Cell>
|
||||
<Cell>{r.doctor_name ? `دکتر ${r.doctor_name}` : '—'}</Cell>
|
||||
<Cell>
|
||||
{queryKey && r.version != null
|
||||
? <AppointmentStatusDropdown uuid={r.uuid} currentStatus={r.status} version={r.version} queryKey={queryKey} />
|
||||
: <StatusPill status={r.status} />}
|
||||
</Cell>
|
||||
<td className="py-[10px] px-[18px] text-center">
|
||||
<Link
|
||||
to={isoDay(r.slot_start) ? `/admin/appointments?date=${isoDay(r.slot_start)}` : '/admin/appointments'}
|
||||
@@ -105,6 +110,20 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro
|
||||
);
|
||||
}
|
||||
|
||||
/** نمایش فقطخواندنی وضعیت — وقتی version یا queryKey در دسترس نیست. */
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const meta = STATUS_META[status] ?? { label: status, color: '#9ca3af' };
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-[5px] px-[10px] py-[3px] rounded-full text-[12px] font-bold whitespace-nowrap"
|
||||
style={{ background: `${meta.color}15`, border: `1.5px solid ${meta.color}30`, color: meta.color }}
|
||||
>
|
||||
<span className="w-[7px] h-[7px] rounded-full shrink-0" style={{ background: meta.color }} />
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** همهی سلولها text-start هستند تا دقیقاً زیر هدر همنامشان بنشینند. */
|
||||
function Cell({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
|
||||
@@ -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({
|
||||
</Link>
|
||||
</div>
|
||||
<div className="px-0 md:px-[24px] pb-[16px]">
|
||||
<NewAppointmentsTable rows={appointments} loading={loading} />
|
||||
{/* والد میتواند نسخهٔ فیلتردار را جای جدول ساده بنشاند. */}
|
||||
{appointmentsSlot ?? <NewAppointmentsTable rows={appointments} loading={loading} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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={<DoctorAppointmentsPanel doctorUuid={d?.doctor?.uuid} />}
|
||||
loading={q.isFetching}
|
||||
formatNumber={formatNumber}
|
||||
formatRial={formatRial}
|
||||
@@ -1152,7 +1154,7 @@ function InvitedDoctorDashboard() {
|
||||
<h3 style={{ fontSize: 16 }}>لیست نوبتهای جدید</h3>
|
||||
<Link to="/admin/appointments" className="muted" style={{ fontSize: 13 }}>نوبتها</Link>
|
||||
</div>
|
||||
<NewAppointmentsTable rows={d?.today_appointments ?? []} loading={q.isFetching} />
|
||||
<DoctorAppointmentsPanel doctorUuid={d?.doctor?.uuid} clinicUuid={dbUuid} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -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')];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Filtered + paginated doctor appointment list backing the dashboard filter bar
|
||||
* (AppointmentRepository::searchByDoctor).
|
||||
*/
|
||||
class DoctorAppointmentFilterTest extends ApiTestCase
|
||||
{
|
||||
private function repo(): AppointmentRepository
|
||||
{
|
||||
return $this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user