172 lines
6.8 KiB
TypeScript
172 lines
6.8 KiB
TypeScript
/**
|
|
* «لیست نوبتهای جدید» داشبورد پزشک، با فیلتر و صفحهبندی سمت سرور.
|
|
*
|
|
* چرا 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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|