From f61b79b67e951c568f1b13a6b01ae8f1cf58fea7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 17:04:55 +0330 Subject: [PATCH] refactor(admin): course cards with a detail view, and fill the banner's next date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab stacked every course's full session list on one page. A protocol allows sixty steps, so one open course was enough to bury the others. Courses are cards now — service, progress, supervising doctor, staff — and opening one replaces the list with its detail: a back button, two tabs (the whole course, or only what is still to come), search and paging inside each. The choice lives in the URL so browser-back returns to the same course. Booking stays where the work is: the button sits on the session card inside the upcoming tab, not on a separate page. The patient banner's 'نوبت بعدی' read '—' for anyone mid-course, because it only looked at booked appointments and a course's later sessions have none yet. It now falls back to the next session of the active course and relabels itself 'جلسهٔ بعدی' when it does — a planned session is not a booking, and the banner should not call it one. Co-Authored-By: Claude Opus 5 --- assets/admin/components/PatientCaseBanner.tsx | 19 +- .../patient/PatientTreatmentTab.tsx | 347 +++++++++++------- assets/admin/pages/PatientDetailPage.tsx | 26 ++ 3 files changed, 254 insertions(+), 138 deletions(-) diff --git a/assets/admin/components/PatientCaseBanner.tsx b/assets/admin/components/PatientCaseBanner.tsx index e5ee622c..5fcd76bc 100644 --- a/assets/admin/components/PatientCaseBanner.tsx +++ b/assets/admin/components/PatientCaseBanner.tsx @@ -44,13 +44,20 @@ const InfoLine = ({ icon, label, value }: { icon: React.ReactNode; label: string * FileServicesHeader (name + status chip, file number, tags, contact/date, * next appointment, یادداشت button). */ -export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, hasDebt, noShows, onAddNote }: { +export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, nextSession, hasDebt, noShows, onAddNote }: { name: string; recordNumber?: string | null; mobile?: string | null; createdAt?: number; tags?: Tag[]; nextAppointment?: number | null; + /** + * جلسهٔ بعدیِ دوره وقتی نوبتی رزرو نشده. + * + * جدا از `nextAppointment` است و باید هم باشد: این هنوز نوبت نیست، برنامه است. + * یکی کردنشان یعنی بنر چیزی را «نوبت» بنامد که کسی رزروش نکرده. + */ + nextSession?: number | null; hasDebt?: boolean; /** خلاصهٔ عدم حضور در پنجرهٔ سیاست — `null` یعنی هنوز نیامده. */ noShows?: { count: number; threshold: number; window_days: number; at_risk: boolean } | null; @@ -106,7 +113,15 @@ export default function PatientCaseBanner({ name, recordNumber, mobile, createdA {/* left — next appointment + note */}
- } label="نوبت بعدی:" value={nextAppointment ? formatDate(nextAppointment) : '—'} /> + } + label={nextAppointment || !nextSession ? 'نوبت بعدی:' : 'جلسهٔ بعدی:'} + value={ + nextAppointment ? formatDate(nextAppointment) + : nextSession ? formatDate(nextSession) + : '—' + } + />
); @@ -87,22 +117,150 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
- این بیمار درمان چندجلسه‌ای ندارد. + این بیمار دورهٔ درمان ندارد.
- پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود. + دوره وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.
); } + if (selected !== null) { + return ( + setUrlState({ view: v })} + onBack={() => setUrlState({ case: '', view: 'sessions' })} + /> + ); + } + + return ( +
+ {cases.map((c) => ( + setUrlState({ case: c.uuid, view: 'sessions' })} /> + ))} +
+ ); +} + +/** کارتِ خلاصهٔ یک دوره — کل کارت دکمه است، نه لینکی در گوشه‌اش. */ +function CaseCard({ summary: c, onOpen }: { summary: TreatmentCaseSummary; onOpen: () => void }) { + const percent = c.total_sessions > 0 + ? Math.round((c.completed_sessions / c.total_sessions) * 100) + : 0; + + const operator = c.performed_by.length > 0 + ? c.performed_by.map((s) => s.name).join('، ') + : c.assigned_staff.map((s) => s.name).join('، '); + + return ( + + ); +} + +function CaseDetail({ summary, view, onView, onBack }: { + summary: TreatmentCaseSummary; + view: 'sessions' | 'upcoming'; + onView: (v: 'sessions' | 'upcoming') => void; + onBack: () => void; +}) { + const [term, setTerm] = useState(''); + const [page, setPage] = useState(1); + useEffect(() => setPage(1), [term, view]); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['treatment-case-plan', summary.uuid], + queryFn: () => api.get>(`/api/v1/treatment-case/${summary.uuid}/plan`), + staleTime: 30_000, + }); + + const all = data?.data?.sessions ?? []; + const scoped = view === 'upcoming' ? all.filter((s) => !SETTLED.includes(s.status)) : all; + const sessions = scoped.filter((s) => matches(s, summary.service.name, term.trim())); + const paged = sessions.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + const upcomingCount = all.filter((s) => !SETTLED.includes(s.status)).length; + return (
+
+ + {summary.service.name} + + {CASE_STATUS_LABEL[summary.status]} + + + {formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه + +
+ +
+ {([['sessions', 'جزئیات دوره'], ['upcoming', `نوبت‌های آینده (${formatNumber(upcomingCount)})`]] as const).map( + ([v, label]) => ( + + ), + )} +
+
setTerm(e.target.value)} - placeholder="سرویس، پرسنل، وضعیت یا شمارهٔ جلسه" + placeholder="پرسنل، وضعیت، ناحیه یا شمارهٔ جلسه" aria-label="جستجوی جلسات" /> {term !== '' && ( @@ -112,115 +270,35 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string )}
- {cases.map((c) => )} -
- ); -} - -const PAGE_SIZE = 8; - -const SESSION_STATUS_TEXT: Record = { - 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>(`/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 ( -
- - - {open && ( -
- {isLoading ? ( -
در حال خواندن تقویم دوره…
- ) : isError ? ( -
- خواندن تقویم این دوره ناموفق بود. - -
- ) : ( - sessions.length === 0 ? ( -
- جلسه‌ای با «{term}» در این دوره پیدا نشد. -
- ) : ( -
- {paged.map((s) => ( - - ))} - -
- ) - )} + {isLoading ? ( +
+ {[0, 1].map((i) =>
)} +
+ ) : isError ? ( +
+ خواندن تقویم این دوره ناموفق بود. + +
+ ) : sessions.length === 0 ? ( +
+ {term !== '' + ? `جلسه‌ای با «${term}» پیدا نشد.` + : view === 'upcoming' + ? 'جلسهٔ باقی‌مانده‌ای در این دوره نیست.' + : 'این دوره جلسه‌ای ندارد.'} +
+ ) : ( +
+ {paged.map((s) => ( + + ))} +
)}
@@ -239,18 +317,14 @@ function SessionCard({ session: s, summary, resource, nationalCode }: { 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 bookable = !SETTLED.includes(s.status) && s.appointment === null && summary.status === 'active'; const areas = s.areas ?? []; return ( -
+
جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)} @@ -260,9 +334,7 @@ function SessionCard({ session: s, summary, resource, nationalCode }: { {s.planned_at === null ? '—' : formatDateTime(s.planned_at)} {/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */} - {s.is_estimate && ( - تخمینی - )} + {s.is_estimate && تخمینی} {s.performed_by && ( پرسنل: {s.performed_by.name} )} @@ -282,7 +354,9 @@ function SessionCard({ session: s, summary, resource, nationalCode }: { )} {a.parameters && Object.entries(a.parameters).map(([k, v]) => ( - {k}: {String(v)} + + {k}: {String(v)} + ))} {a.note && یادداشت: {a.note}}
@@ -329,6 +403,7 @@ function SessionCard({ session: s, summary, resource, nationalCode }: { onSuccess={() => { qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] }); qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] }); + qc.invalidateQueries({ queryKey: ['patient-appointments'] }); }} /> )} diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 2b4ca8b9..f38dfa4a 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -171,6 +171,31 @@ export default function PatientDetailPage() { const hasDebt = sessions.some((s) => !s.is_paid); const nowSec = Math.floor(Date.now() / 1000); + /** + * وقتی نوبتی رزرو نشده، جلسهٔ بعدیِ دورهٔ درمان را نشان می‌دهیم — با برچسبِ خودش. + * + * بیمارِ دوره‌ای معمولاً نوبتِ بعدی‌اش هنوز گرفته نشده؛ خالی گذاشتنِ بنر یعنی + * صفحه چیزی را که می‌داند نمی‌گوید. + */ + const treatmentQ = useQuery>({ + queryKey: ['patient-treatment-cases', uuid], + queryFn: () => api.get(`/api/v1/treatment-cases?record=${uuid}`), + enabled: !!uuid, + staleTime: 30_000, + }); + const activeCase = (treatmentQ.data?.data ?? []).find((c) => c.status === 'active') ?? null; + + const planQ = useQuery>({ + queryKey: ['treatment-case-plan', activeCase?.uuid], + queryFn: () => api.get(`/api/v1/treatment-case/${activeCase!.uuid}/plan`), + enabled: activeCase !== null, + staleTime: 30_000, + }); + + const nextSession = (planQ.data?.data?.sessions ?? []) + .filter((s) => !['done', 'cancelled', 'no_show'].includes(s.status) && (s.planned_at ?? 0) >= nowSec) + .sort((a, b) => (a.planned_at ?? 0) - (b.planned_at ?? 0))[0]?.planned_at ?? null; + const nextAppointment = (appointmentsQ.data?.data ?? []) .filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled')) .sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null; @@ -186,6 +211,7 @@ export default function PatientDetailPage() { createdAt={(record as any)?.created_at} tags={(record as any)?.tags} nextAppointment={nextAppointment} + nextSession={nextSession} hasDebt={hasDebt} onAddNote={() => setTab('notes')} />