feat: implement filtered and paginated doctor appointments panel with status filtering

This commit is contained in:
hamed
2026-07-19 11:50:16 +03:30
parent 8420a1a6c2
commit 4e78a76824
7 changed files with 324 additions and 4 deletions
@@ -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>