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({
|
export default function NewAppointmentModal({
|
||||||
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null,
|
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null,
|
||||||
treatmentSessionUuid = null,
|
treatmentSessionUuid = null, patient = null,
|
||||||
}: {
|
}: {
|
||||||
slot: BookingSlot;
|
slot: BookingSlot;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -57,11 +57,20 @@ export default function NewAppointmentModal({
|
|||||||
* اتصال از روی سرویس حدس زده میشود و سرویسِ اشتباه یک پروندهٔ موازی میسازد.
|
* اتصال از روی سرویس حدس زده میشود و سرویسِ اشتباه یک پروندهٔ موازی میسازد.
|
||||||
*/
|
*/
|
||||||
treatmentSessionUuid?: string | null;
|
treatmentSessionUuid?: string | null;
|
||||||
|
/**
|
||||||
|
* بیمارِ از پیش معلوم — وقتی مودال از پروندهٔ خودِ بیمار باز میشود.
|
||||||
|
*
|
||||||
|
* مرحلهٔ جستجو رد میشود: کسی که پروندهٔ بیمار را باز کرده نباید همان بیمار را
|
||||||
|
* دوباره با کد ملی پیدا کند.
|
||||||
|
*/
|
||||||
|
patient?: { name: string | null; mobile: string; national_code: string | null } | null;
|
||||||
}) {
|
}) {
|
||||||
const [mobile, setMobile] = useState('');
|
const [mobile, setMobile] = useState(patient?.mobile ?? '');
|
||||||
const [lookup, setLookup] = useState<PatientLookup | null>(null);
|
const [lookup, setLookup] = useState<PatientLookup | null>(
|
||||||
const [patientName, setPatientName] = useState('');
|
patient === null ? null : { found: true, name: patient.name, mobile: patient.mobile, national_code: patient.national_code },
|
||||||
const [nationalCode, setNationalCode] = useState('');
|
);
|
||||||
|
const [patientName, setPatientName] = useState(patient?.name ?? '');
|
||||||
|
const [nationalCode, setNationalCode] = useState(patient?.national_code ?? '');
|
||||||
// معیار جستجوی بیمار: کد ملی (پیشفرض) یا موبایل.
|
// معیار جستجوی بیمار: کد ملی (پیشفرض) یا موبایل.
|
||||||
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
|
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
|
||||||
const [pick, setPick] = useState<ServicePick>({ serviceUuids: [], durations: {}, slot: null });
|
const [pick, setPick] = useState<ServicePick>({ serviceUuids: [], durations: {}, slot: null });
|
||||||
@@ -329,7 +338,23 @@ export default function NewAppointmentModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Step n={pickerMode ? 2 : null} title="بیمار">
|
<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>
|
<label htmlFor="appt-patient-search">جستجوی بیمار <span className="req">*</span></label>
|
||||||
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
||||||
<div className="seg" style={{ display: 'flex', marginBottom: 8 }}>
|
<div className="seg" style={{ display: 'flex', marginBottom: 8 }}>
|
||||||
@@ -391,7 +416,8 @@ export default function NewAppointmentModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{foundWithNationalCode && (
|
{/* وقتی بیمار از قبل معلوم است، کارت بالا همین را میگوید؛ دو بار گفتنش نویز است. */}
|
||||||
|
{patient === null && foundWithNationalCode && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
||||||
background: 'var(--success-bg)', fontSize: 13,
|
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 { 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 { api } from '../../lib/api';
|
||||||
import type { ApiResponse } from '../../lib/api';
|
import type { ApiResponse } from '../../lib/api';
|
||||||
import StatusBadge from '../ui/StatusBadge';
|
import StatusBadge from '../ui/StatusBadge';
|
||||||
|
import Pagination from '../ui/Pagination';
|
||||||
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
||||||
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
||||||
import { useClinicContext } from '../../hooks/useClinicContext';
|
import { useClinicContext } from '../../hooks/useClinicContext';
|
||||||
@@ -23,7 +24,7 @@ interface PlanSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PlanResponse {
|
interface PlanResponse {
|
||||||
case: TreatmentCaseSummary;
|
case: TreatmentCaseSummary & { patient_national_code: string | null };
|
||||||
/** دستگاهِ دوره — همان که جلسهٔ قبل رویش انجام شد. */
|
/** دستگاهِ دوره — همان که جلسهٔ قبل رویش انجام شد. */
|
||||||
resource: { uuid: string; name: string } | null;
|
resource: { uuid: string; name: string } | null;
|
||||||
sessions: PlanSession[];
|
sessions: PlanSession[];
|
||||||
@@ -58,6 +59,10 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
|
|||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// جستجو در همین صفحه فیلتر میشود نه روی سرور: `/plan` کل دوره را یکجا میدهد و
|
||||||
|
// برای چند ده جلسه رفتوبرگشت اضافه هیچ چیزی جز تأخیر اضافه نمیکند.
|
||||||
|
const [term, setTerm] = useState('');
|
||||||
|
|
||||||
const cases = data?.data ?? [];
|
const cases = data?.data ?? [];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -92,12 +97,54 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 14, padding: '16px 0' }}>
|
<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>
|
</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');
|
const [open, setOpen] = useState(summary.status === 'active');
|
||||||
|
|
||||||
@@ -108,7 +155,12 @@ function CasePlan({ summary }: { summary: TreatmentCaseSummary }) {
|
|||||||
staleTime: 30_000,
|
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 (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -150,11 +202,24 @@ function CasePlan({ summary }: { summary: TreatmentCaseSummary }) {
|
|||||||
<button type="button" className="btn ghost sm" onClick={() => refetch()}>تلاش دوباره</button>
|
<button type="button" className="btn ghost sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
sessions.length === 0 ? (
|
||||||
{sessions.map((s) => (
|
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||||
<SessionCard key={s.uuid} session={s} summary={summary} resource={data?.data?.resource ?? null} />
|
جلسهای با «{term}» در این دوره پیدا نشد.
|
||||||
))}
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
@@ -162,10 +227,11 @@ function CasePlan({ summary }: { summary: TreatmentCaseSummary }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionCard({ session: s, summary, resource }: {
|
function SessionCard({ session: s, summary, resource, nationalCode }: {
|
||||||
session: PlanSession;
|
session: PlanSession;
|
||||||
summary: TreatmentCaseSummary;
|
summary: TreatmentCaseSummary;
|
||||||
resource: { uuid: string; name: string } | null;
|
resource: { uuid: string; name: string } | null;
|
||||||
|
nationalCode: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [booking, setBooking] = useState(false);
|
const [booking, setBooking] = useState(false);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -254,6 +320,11 @@ function SessionCard({ session: s, summary, resource }: {
|
|||||||
date={s.planned_at === null ? undefined : isoDay(s.planned_at)}
|
date={s.planned_at === null ? undefined : isoDay(s.planned_at)}
|
||||||
clinicUuid={clinicUuid}
|
clinicUuid={clinicUuid}
|
||||||
treatmentSessionUuid={s.uuid}
|
treatmentSessionUuid={s.uuid}
|
||||||
|
patient={{
|
||||||
|
name: summary.patient.name,
|
||||||
|
mobile: summary.patient.mobile,
|
||||||
|
national_code: nationalCode,
|
||||||
|
}}
|
||||||
onClose={() => setBooking(false)}
|
onClose={() => setBooking(false)}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
|
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: 'services', label: 'سرویسها', icon: (c) => <TabServices color={c} /> },
|
||||||
{ key: 'info', label: 'اطلاعات پرونده', icon: (c) => <TabInfo color={c} /> },
|
{ key: 'info', label: 'اطلاعات پرونده', icon: (c) => <TabInfo color={c} /> },
|
||||||
{ key: 'appointments', label: 'نوبتها', icon: (c) => <TabCalendar 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: 'payments', label: 'پرداختها', icon: (c) => <TabCard color={c} /> },
|
||||||
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
|
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
|
||||||
{ key: 'notes', label: 'یادداشتها', icon: (c) => <DocumentTextIcon style={{ width: 18, 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` یعنی محاسبهٔ لحظهٔ نمایش |
|
| `is_estimate` | `false` یعنی به واقعیتی گره خورده، `true` یعنی محاسبهٔ لحظهٔ نمایش |
|
||||||
| `areas[]` | نواحی با `parameters` (خواندههای دستگاه)، `resource`، `note`، زمانها |
|
| `areas[]` | نواحی با `parameters` (خواندههای دستگاه)، `resource`، `note`، زمانها |
|
||||||
|
|
||||||
|
`case.patient_national_code` هم میآید: از پروفایل و در نبودش از کاربر (همان COALESCE
|
||||||
|
که `PatientController` میکند). فرم ثبت نوبت با آن بیمار را از پیش پر میکند تا منشی
|
||||||
|
کسی را که همینجا معلوم است دوباره جستجو نکند.
|
||||||
|
|
||||||
پاسخ یک `resource` هم دارد: دستگاهی که جلسهٔ قبلِ همین دوره رویش انجام شده
|
پاسخ یک `resource` هم دارد: دستگاهی که جلسهٔ قبلِ همین دوره رویش انجام شده
|
||||||
(`NextSessionSlotFinder::preferredResource`). فرم ثبت نوبت بدونش کار نمیکند — سرویسِ
|
(`NextSessionSlotFinder::preferredResource`). فرم ثبت نوبت بدونش کار نمیکند — سرویسِ
|
||||||
دوره روی تقویم منبع رزرو میشود نه روی برنامهٔ پزشک. `null` یعنی هنوز هیچ جلسهای روی
|
دوره روی تقویم منبع رزرو میشود نه روی برنامهٔ پزشک. `null` یعنی هنوز هیچ جلسهای روی
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class TreatmentCaseController extends BaseController
|
|||||||
private readonly TreatmentCaseOpener $opener,
|
private readonly TreatmentCaseOpener $opener,
|
||||||
private readonly TreatmentCaseEditor $editor,
|
private readonly TreatmentCaseEditor $editor,
|
||||||
private readonly TreatmentPlanProjector $planner,
|
private readonly TreatmentPlanProjector $planner,
|
||||||
|
private readonly \App\UserProfile\Repository\UserProfileRepository $profiles,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
|
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
|
||||||
@@ -166,8 +167,19 @@ class TreatmentCaseController extends BaseController
|
|||||||
*/
|
*/
|
||||||
$resource = $this->slotFinder->preferredResource($case);
|
$resource = $this->slotFinder->preferredResource($case);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کد ملی برای پیشپرکردنِ فرم ثبت نوبت لازم است و روی پروفایل مینشیند، نه
|
||||||
|
* روی کاربر — همان COALESCE که `PatientController` هم میکند. بدونش منشی
|
||||||
|
* بیماری را دوباره جستجو میکند که همینجا معلوم است کیست.
|
||||||
|
*/
|
||||||
|
$patientUser = $case->getPatientRecord()->getUser();
|
||||||
|
$nationalCode = $this->profiles->findOneBy(['user' => $patientUser])?->getNationalCode()
|
||||||
|
?? $patientUser->getNationalCode();
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'case' => $case->toArray(),
|
'case' => $case->toArray() + [
|
||||||
|
'patient_national_code' => $nationalCode,
|
||||||
|
],
|
||||||
'resource' => $resource === null ? null : [
|
'resource' => $resource === null ? null : [
|
||||||
'uuid' => $resource->getUuid(),
|
'uuid' => $resource->getUuid(),
|
||||||
'name' => $resource->getName(),
|
'name' => $resource->getName(),
|
||||||
|
|||||||
Reference in New Issue
Block a user