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>
This commit is contained in:
hamed
2026-08-07 16:43:34 +03:30
co-authored by Claude Opus 5
parent 02fdfd13cb
commit 288919339e
5 changed files with 134 additions and 21 deletions
@@ -41,7 +41,7 @@ export interface BookingResource {
*/
export default function NewAppointmentModal({
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null,
treatmentSessionUuid = null,
treatmentSessionUuid = null, patient = null,
}: {
slot: BookingSlot;
onClose: () => void;
@@ -57,11 +57,20 @@ export default function NewAppointmentModal({
* اتصال از روی سرویس حدس زده می‌شود و سرویسِ اشتباه یک پروندهٔ موازی می‌سازد.
*/
treatmentSessionUuid?: string | null;
/**
* بیمارِ از پیش معلوم — وقتی مودال از پروندهٔ خودِ بیمار باز می‌شود.
*
* مرحلهٔ جستجو رد می‌شود: کسی که پروندهٔ بیمار را باز کرده نباید همان بیمار را
* دوباره با کد ملی پیدا کند.
*/
patient?: { name: string | null; mobile: string; national_code: string | null } | null;
}) {
const [mobile, setMobile] = useState('');
const [lookup, setLookup] = useState<PatientLookup | null>(null);
const [patientName, setPatientName] = useState('');
const [nationalCode, setNationalCode] = useState('');
const [mobile, setMobile] = useState(patient?.mobile ?? '');
const [lookup, setLookup] = useState<PatientLookup | null>(
patient === null ? null : { found: true, name: patient.name, mobile: patient.mobile, national_code: patient.national_code },
);
const [patientName, setPatientName] = useState(patient?.name ?? '');
const [nationalCode, setNationalCode] = useState(patient?.national_code ?? '');
// معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل.
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
const [pick, setPick] = useState<ServicePick>({ serviceUuids: [], durations: {}, slot: null });
@@ -329,7 +338,23 @@ export default function NewAppointmentModal({
)}
<Step n={pickerMode ? 2 : null} title="بیمار">
<div className="field-block" style={{ marginBottom: 14 }}>
{/* بیمارِ از پیش معلوم: فقط تأیید می‌شود، جستجو لازم نیست. */}
{patient !== null && (
<div style={{
marginBottom: 14, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
}}>
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{patient.name || 'بدون نام'}</div>
<div style={{ color: 'var(--text-2)', fontSize: 12, direction: 'ltr', textAlign: 'start' }}>
{patient.mobile}{patient.national_code ? ` · ${patient.national_code}` : ''}
</div>
</div>
</div>
)}
<div className="field-block" style={{ marginBottom: 14, display: patient === null ? undefined : 'none' }}>
<label htmlFor="appt-patient-search">جستجوی بیمار <span className="req">*</span></label>
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
<div className="seg" style={{ display: 'flex', marginBottom: 8 }}>
@@ -391,7 +416,8 @@ export default function NewAppointmentModal({
</div>
</div>
{foundWithNationalCode && (
{/* وقتی بیمار از قبل معلوم است، کارت بالا همین را می‌گوید؛ دو بار گفتنش نویز است. */}
{patient === null && foundWithNationalCode && (
<div style={{
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', fontSize: 13,
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDownIcon, ClipboardDocumentListIcon, CpuChipIcon } from '@heroicons/react/24/outline';
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';
@@ -23,7 +24,7 @@ interface PlanSession {
}
interface PlanResponse {
case: TreatmentCaseSummary;
case: TreatmentCaseSummary & { patient_national_code: string | null };
/** دستگاهِ دوره — همان که جلسهٔ قبل رویش انجام شد. */
resource: { uuid: string; name: string } | null;
sessions: PlanSession[];
@@ -58,6 +59,10 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
staleTime: 30_000,
});
// جستجو در همین صفحه فیلتر می‌شود نه روی سرور: `/plan` کل دوره را یک‌جا می‌دهد و
// برای چند ده جلسه رفت‌وبرگشت اضافه هیچ چیزی جز تأخیر اضافه نمی‌کند.
const [term, setTerm] = useState('');
const cases = data?.data ?? [];
if (isLoading) {
@@ -92,12 +97,54 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
return (
<div style={{ display: 'grid', gap: 14, padding: '16px 0' }}>
{cases.map((c) => <CasePlan key={c.uuid} summary={c} />)}
<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>
);
}
function CasePlan({ summary }: { summary: TreatmentCaseSummary }) {
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');
@@ -108,7 +155,12 @@ function CasePlan({ summary }: { summary: TreatmentCaseSummary }) {
staleTime: 30_000,
});
const sessions = data?.data?.sessions ?? [];
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">
@@ -150,11 +202,24 @@ function CasePlan({ summary }: { summary: TreatmentCaseSummary }) {
<button type="button" className="btn ghost sm" onClick={() => refetch()}>تلاش دوباره</button>
</div>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{sessions.map((s) => (
<SessionCard key={s.uuid} session={s} summary={summary} resource={data?.data?.resource ?? null} />
))}
</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>
)}
@@ -162,10 +227,11 @@ function CasePlan({ summary }: { summary: TreatmentCaseSummary }) {
);
}
function SessionCard({ session: s, summary, resource }: {
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();
@@ -254,6 +320,11 @@ function SessionCard({ session: s, summary, resource }: {
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] });
+1 -1
View File
@@ -49,7 +49,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
{ key: 'services', label: 'سرویس‌ها', icon: (c) => <TabServices color={c} /> },
{ key: 'info', label: 'اطلاعات پرونده', icon: (c) => <TabInfo color={c} /> },
{ key: 'appointments', label: 'نوبت‌ها', icon: (c) => <TabCalendar color={c} /> },
{ key: 'treatment', label: 'نوبت‌های بعدی', icon: (c) => <ClipboardDocumentListIcon style={{ width: 18, color: c }} /> },
{ key: 'treatment', label: 'دوره‌های درمان', icon: (c) => <ClipboardDocumentListIcon style={{ width: 18, color: c }} /> },
{ key: 'payments', label: 'پرداخت‌ها', icon: (c) => <TabCard color={c} /> },
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
{ key: 'notes', label: 'یادداشت‌ها', icon: (c) => <DocumentTextIcon style={{ width: 18, color: c }} /> },
+4
View File
@@ -296,6 +296,10 @@ single-session again. Idempotent: deleting a service that has no protocol still
| `is_estimate` | `false` یعنی به واقعیتی گره خورده، `true` یعنی محاسبهٔ لحظهٔ نمایش |
| `areas[]` | نواحی با `parameters` (خوانده‌های دستگاه)، `resource`، `note`، زمان‌ها |
`case.patient_national_code` هم می‌آید: از پروفایل و در نبودش از کاربر (همان COALESCE
که `PatientController` می‌کند). فرم ثبت نوبت با آن بیمار را از پیش پر می‌کند تا منشی
کسی را که همین‌جا معلوم است دوباره جستجو نکند.
پاسخ یک `resource` هم دارد: دستگاهی که جلسهٔ قبلِ همین دوره رویش انجام شده
(`NextSessionSlotFinder::preferredResource`). فرم ثبت نوبت بدونش کار نمی‌کند — سرویسِ
دوره روی تقویم منبع رزرو می‌شود نه روی برنامهٔ پزشک. `null` یعنی هنوز هیچ جلسه‌ای روی
@@ -39,6 +39,7 @@ class TreatmentCaseController extends BaseController
private readonly TreatmentCaseOpener $opener,
private readonly TreatmentCaseEditor $editor,
private readonly TreatmentPlanProjector $planner,
private readonly \App\UserProfile\Repository\UserProfileRepository $profiles,
) {}
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
@@ -166,8 +167,19 @@ class TreatmentCaseController extends BaseController
*/
$resource = $this->slotFinder->preferredResource($case);
/**
* کد ملی برای پیش‌پرکردنِ فرم ثبت نوبت لازم است و روی پروفایل می‌نشیند، نه
* روی کاربر — همان COALESCE که `PatientController` هم می‌کند. بدونش منشی
* بیماری را دوباره جستجو می‌کند که همین‌جا معلوم است کیست.
*/
$patientUser = $case->getPatientRecord()->getUser();
$nationalCode = $this->profiles->findOneBy(['user' => $patientUser])?->getNationalCode()
?? $patientUser->getNationalCode();
return $this->success([
'case' => $case->toArray(),
'case' => $case->toArray() + [
'patient_national_code' => $nationalCode,
],
'resource' => $resource === null ? null : [
'uuid' => $resource->getUuid(),
'name' => $resource->getName(),