refactor(admin): course cards with a detail view, and fill the banner's next date

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 17:04:55 +03:30
co-authored by Claude Opus 5
parent 8672608696
commit f61b79b67e
3 changed files with 254 additions and 138 deletions
+26
View File
@@ -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<ApiResponse<{ uuid: string; status: string }[]>>({
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<ApiResponse<{ sessions: { status: string; planned_at: number | null }[] }>>({
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')}
/>