Files
clinicpro/assets/admin/components/patient/PatientTreatmentTab.tsx
T
hamedandClaude Opus 5 288919339e feat(admin): name the tab for what it shows, prefill the patient, paginate and search
The tab was called 'نوبت‌های بعدی' but shows the whole course — finished sessions
with their recorded readings as much as upcoming ones. It is 'دوره‌های درمان' now.

Booking from a session still made the user search for a patient the page already
had open. The plan response carries the patient's national code (from the
profile, falling back to the user — the same COALESCE PatientController uses,
because users.national_code is routinely empty), and the modal takes a patient
prop that seeds the lookup and hides the search step. The old 'بیمار یافت شد'
card is suppressed in that mode; saying it twice is noise.

Sessions are now searchable and paged. A protocol allows up to 60 steps and a
patient can hold several courses, so an unbounded list was only ever going to
work for the small cases. Search filters on what the card actually shows —
service, staff, status, session number, area names — and runs in the page,
since /plan already returns the whole course and a round trip would add latency
and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:43:34 +03:30

338 lines
14 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDownIcon, ClipboardDocumentListIcon, CpuChipIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import StatusBadge from '../ui/StatusBadge';
import Pagination from '../ui/Pagination';
import NewAppointmentModal from '../appointments/NewAppointmentModal';
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
import { useClinicContext } from '../../hooks/useClinicContext';
import { formatDateTime, formatNumber } from '../../lib/utils';
import type { TreatmentCaseSummary, SessionAreaRecord } from '../../types';
interface PlanSession {
uuid: string;
session_number: number;
total_sessions: number;
status: string;
planned_at: number | null;
is_estimate: boolean;
appointment: { uuid: string; slot_start: number } | null;
performed_by: { uuid: string; name: string } | null;
areas?: SessionAreaRecord[];
}
interface PlanResponse {
case: TreatmentCaseSummary & { patient_national_code: string | null };
/** دستگاهِ دوره — همان که جلسهٔ قبل رویش انجام شد. */
resource: { uuid: string; name: string } | null;
sessions: PlanSession[];
}
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
active: 'در جریان',
completed: 'تمام شده',
abandoned: 'رها شده',
};
/** `planned_at` (ثانیه) → `YYYY-MM-DD` میلادی، همان چیزی که مودال می‌خواهد. */
function isoDay(ts: number): string {
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/**
* درمان‌های چندجلسه‌ایِ همین بیمار، با تقویم کل دوره.
*
* تاریخ‌ها از `/plan` می‌آیند و بخشی‌شان تخمینی‌اند — همان‌جا برچسب می‌خورند تا با
* نوبتِ ثبت‌شده اشتباه گرفته نشوند. کارت نوبت نیست؛ دکمه‌اش نوبت می‌سازد.
*/
export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string }) {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['patient-treatment-cases', recordUuid],
queryFn: () => api.get<ApiResponse<TreatmentCaseSummary[]>>(
`/api/v1/treatment-cases?record=${encodeURIComponent(recordUuid)}`,
),
enabled: recordUuid !== '',
staleTime: 30_000,
});
// جستجو در همین صفحه فیلتر می‌شود نه روی سرور: `/plan` کل دوره را یک‌جا می‌دهد و
// برای چند ده جلسه رفت‌وبرگشت اضافه هیچ چیزی جز تأخیر اضافه نمی‌کند.
const [term, setTerm] = useState('');
const cases = data?.data ?? [];
if (isLoading) {
return (
<div style={{ display: 'grid', gap: 12, padding: '16px 0' }}>
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 96 }} />)}
</div>
);
}
if (isError) {
return (
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'center', padding: 32 }}>
<span style={{ fontSize: 13.5, color: 'var(--danger)' }}>خواندن درمان‌های این بیمار ناموفق بود.</span>
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
</div>
);
}
if (cases.length === 0) {
return (
<div className="card card-pad" style={{ display: 'grid', gap: 8, justifyItems: 'center', padding: 40 }}>
<ClipboardDocumentListIcon style={{ width: 40, height: 40, color: 'var(--text-3)' }} />
<span style={{ fontSize: 13.5, color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.9 }}>
این بیمار درمان چندجلسه‌ای ندارد.
<br />
پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.
</span>
</div>
);
}
return (
<div style={{ display: 'grid', gap: 14, padding: '16px 0' }}>
<div className="field" style={{ maxWidth: 420 }}>
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} />
<input
value={term}
onChange={(e) => setTerm(e.target.value)}
placeholder="سرویس، پرسنل، وضعیت یا شمارهٔ جلسه"
aria-label="جستجوی جلسات"
/>
{term !== '' && (
<button type="button" className="mini-btn" aria-label="پاک کردن جستجو" onClick={() => setTerm('')}>
<XMarkIcon style={{ width: 15, height: 15 }} />
</button>
)}
</div>
{cases.map((c) => <CasePlan key={c.uuid} summary={c} term={term.trim()} />)}
</div>
);
}
const PAGE_SIZE = 8;
const SESSION_STATUS_TEXT: Record<string, string> = {
planned: 'برنامه‌ریزی شده',
booked: 'زمان‌بندی شده',
in_progress: 'در حال انجام',
done: 'انجام شد',
cancelled: 'لغو شده',
no_show: 'غیبت',
};
/** جستجو روی همان چیزهایی که در کارت دیده می‌شوند، نه فیلدهای پنهان. */
function matches(s: PlanSession, serviceName: string, term: string): boolean {
if (term === '') return true;
const hay = [
serviceName,
s.performed_by?.name ?? '',
SESSION_STATUS_TEXT[s.status] ?? s.status,
String(s.session_number),
formatNumber(s.session_number),
...(s.areas ?? []).map((a) => a.area.name),
].join(' ');
return hay.includes(term);
}
function CasePlan({ summary, term }: { summary: TreatmentCaseSummary; term: string }) {
// دورهٔ باز پیش‌فرض باز است و دورهٔ بسته جمع — کارِ پیشِ رو مهم‌تر از سابقه است.
const [open, setOpen] = useState(summary.status === 'active');
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['treatment-case-plan', summary.uuid],
queryFn: () => api.get<ApiResponse<PlanResponse>>(`/api/v1/treatment-case/${summary.uuid}/plan`),
enabled: open,
staleTime: 30_000,
});
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [term]);
const all = data?.data?.sessions ?? [];
const sessions = all.filter((s) => matches(s, summary.service.name, term));
const paged = sessions.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<div className="card">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
style={{
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', width: '100%',
padding: 'var(--card-pad)', background: 'none', border: 'none', cursor: 'pointer',
font: 'inherit', color: 'var(--text)', textAlign: 'start',
}}
>
<strong style={{ fontSize: 14 }}>{summary.service.name}</strong>
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
</span>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
{formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه
</span>
{summary.supervisor && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پزشک ناظر: {summary.supervisor.name}</span>
)}
<ChevronDownIcon
style={{
width: 16, height: 16, marginInlineStart: 'auto', flexShrink: 0,
transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none',
}}
/>
</button>
{open && (
<div style={{ padding: '0 var(--card-pad) var(--card-pad)' }}>
{isLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال خواندن تقویم دوره…</div>
) : isError ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>خواندن تقویم این دوره ناموفق بود.</span>
<button type="button" className="btn ghost sm" onClick={() => refetch()}>تلاش دوباره</button>
</div>
) : (
sessions.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
جلسه‌ای با «{term}» در این دوره پیدا نشد.
</div>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{paged.map((s) => (
<SessionCard
key={s.uuid}
session={s}
summary={summary}
resource={data?.data?.resource ?? null}
nationalCode={data?.data?.case.patient_national_code ?? null}
/>
))}
<Pagination page={page} total={sessions.length} limit={PAGE_SIZE} onPageChange={setPage} />
</div>
)
)}
</div>
)}
</div>
);
}
function SessionCard({ session: s, summary, resource, nationalCode }: {
session: PlanSession;
summary: TreatmentCaseSummary;
resource: { uuid: string; name: string } | null;
nationalCode: string | null;
}) {
const [booking, setBooking] = useState(false);
const qc = useQueryClient();
const clinicUuid = useClinicContext();
const supervisor = summary.supervisor;
// سرویس‌ها از تقویم منبع می‌آیند نه از برنامهٔ پزشک — دورهٔ لیزر روی دستگاه رزرو
// می‌شود و `useDoctorBookingServices` برای این پزشک چیزی برنمی‌گرداند.
const { services } = useResourceBookingServices(booking ? resource?.uuid : null);
const settled = ['done', 'cancelled', 'no_show'].includes(s.status);
const bookable = !settled && s.appointment === null && summary.status === 'active';
const areas = s.areas ?? [];
return (
<div style={{
display: 'grid', gap: 8, padding: '12px 14px',
borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 13.5 }}>
جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)}
</strong>
<StatusBadge type="treatment-session" value={s.status} />
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>
{s.planned_at === null ? '—' : formatDateTime(s.planned_at)}
</span>
{/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */}
{s.is_estimate && (
<span className="badge gray" style={{ fontSize: 11 }}>تخمینی</span>
)}
{s.performed_by && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پرسنل: {s.performed_by.name}</span>
)}
</div>
{/* آنچه واقعاً انجام شد: ناحیه، دستگاه و خوانده‌هایش. */}
{areas.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
{areas.map((a) => (
<div key={a.uuid} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 12.5 }}>
<span style={{ fontWeight: 600 }}>{a.area.name}</span>
<StatusBadge type="treatment-area" value={a.status} />
{a.resource && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-3)' }}>
<CpuChipIcon style={{ width: 13, height: 13 }} />
{a.resource.name}
</span>
)}
{a.parameters && Object.entries(a.parameters).map(([k, v]) => (
<span key={k} style={{ color: 'var(--text-3)' }}>{k}: <b style={{ color: 'var(--text-2)' }}>{String(v)}</b></span>
))}
{a.note && <span style={{ color: 'var(--text-3)' }}>یادداشت: {a.note}</span>}
</div>
))}
</div>
)}
{bookable && (
/* بدون دستگاه، فرم سرویسی برای انتخاب ندارد؛ دکمهٔ خاموشِ بی‌توضیح بدتر از
نبودنش است، پس دلیلش نوشته می‌شود. */
resource === null ? (
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
هنوز هیچ جلسه‌ای از این دوره روی دستگاهی انجام نشده نوبت را از صفحهٔ نوبت‌ها ثبت کنید.
</span>
) : (
<button
type="button"
className="btn primary sm"
style={{ justifySelf: 'start' }}
onClick={() => setBooking(true)}
>
ثبت نوبت این جلسه
</button>
)
)}
{booking && resource && (
<NewAppointmentModal
slot={{
start: 0, end: 0, start_time: '', end_time: '',
doctor_uuid: supervisor?.uuid ?? '', doctor_name: supervisor?.name ?? '',
}}
resource={resource}
services={services}
date={s.planned_at === null ? undefined : isoDay(s.planned_at)}
clinicUuid={clinicUuid}
treatmentSessionUuid={s.uuid}
patient={{
name: summary.patient.name,
mobile: summary.patient.mobile,
national_code: nationalCode,
}}
onClose={() => setBooking(false)}
onSuccess={() => {
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
}}
/>
)}
</div>
);
}