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:
@@ -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] });
|
||||
|
||||
@@ -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 }} /> },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user