refactor(admin): collapse session cards in the course detail

A finished session prints three areas with their device readings, so a course
with any history pushed the sessions that still need work off the screen.

Each session is a collapsible card now, with the scannable facts kept in the
head — number, status, date, staff, and an area count so opening is a decision
rather than a guess. History starts collapsed; a session that still needs
booking starts open, because its button is the reason it is on the page and
should not sit behind an extra click. A session with neither areas nor an
action has no body and renders as a plain row rather than an empty toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 17:12:28 +03:30
co-authored by Claude Opus 5
parent bc8003562a
commit 7a6bc313d2
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon,
CpuChipIcon, MagnifyingGlassIcon, XMarkIcon, CpuChipIcon, MagnifyingGlassIcon, XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
@@ -312,6 +312,14 @@ function SessionCard({ session: s, summary, resource, nationalCode }: {
nationalCode: string | null; nationalCode: string | null;
}) { }) {
const [booking, setBooking] = useState(false); const [booking, setBooking] = useState(false);
/**
* سابقه بسته می‌آید و کارِ پیشِ رو باز.
*
* جلسهٔ انجام‌شده سه ناحیه با خوانده‌هایشان دارد و بازِ پیش‌فرض، فهرست را دفن
* می‌کند؛ ولی جلسه‌ای که باید رزرو شود، دکمه‌اش دلیلِ وجودش است و پشت یک کلیک
* اضافه نباید بماند.
*/
const [open, setOpen] = useState(!SETTLED.includes(s.status));
const qc = useQueryClient(); const qc = useQueryClient();
const clinicUuid = useClinicContext(); const clinicUuid = useClinicContext();
@@ -322,26 +330,57 @@ function SessionCard({ session: s, summary, resource, nationalCode }: {
const bookable = !SETTLED.includes(s.status) && s.appointment === null && summary.status === 'active'; const bookable = !SETTLED.includes(s.status) && s.appointment === null && summary.status === 'active';
const areas = s.areas ?? []; const areas = s.areas ?? [];
// جلسه‌ای که نه ناحیه‌ای ثبت کرده و نه کاری برای انجام دارد، بدنه‌ای ندارد که باز
// شود؛ کلپسِ خالی فقط یک کلیک بی‌نتیجه است.
const hasBody = areas.length > 0 || bookable;
const head = (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', width: '100%' }}>
<strong style={{ fontSize: 13.5 }}>
جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)}
</strong>
<StatusBadge type="treatment-session" value={s.status} />
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>
{s.planned_at === null ? '—' : formatDateTime(s.planned_at)}
</span>
{/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */}
{s.is_estimate && <span className="badge gray" style={{ fontSize: 11 }}>تخمینی</span>}
{s.performed_by && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پرسنل: {s.performed_by.name}</span>
)}
{/* شمارهٔ نواحی در سرِ بسته می‌ماند تا باز کردن، حدس نباشد. */}
{areas.length > 0 && !open && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>{formatNumber(areas.length)} ناحیه</span>
)}
{hasBody && (
<ChevronDownIcon
style={{
width: 16, height: 16, marginInlineStart: 'auto', flexShrink: 0, color: 'var(--text-3)',
transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none',
}}
/>
)}
</div>
);
return ( return (
<div className="card card-pad" style={{ display: 'grid', gap: 8 }}> <div className="card card-pad" style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}> {hasBody ? (
<strong style={{ fontSize: 13.5 }}> <button
جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)} type="button"
</strong> onClick={() => setOpen((o) => !o)}
<StatusBadge type="treatment-session" value={s.status} /> aria-expanded={open}
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}> style={{
{s.planned_at === null ? '—' : formatDateTime(s.planned_at)} display: 'flex', width: '100%', padding: 0, background: 'none', border: 'none',
</span> cursor: 'pointer', font: 'inherit', color: 'var(--text)', textAlign: 'start',
{/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */} }}
{s.is_estimate && <span className="badge gray" style={{ fontSize: 11 }}>تخمینی</span>} >
{s.performed_by && ( {head}
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پرسنل: {s.performed_by.name}</span> </button>
)} ) : head}
</div>
{/* آنچه واقعاً انجام شد: ناحیه، دستگاه و خوانده‌هایش. */} {/* آنچه واقعاً انجام شد: ناحیه، دستگاه و خوانده‌هایش. */}
{areas.length > 0 && ( {open && areas.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}> <div style={{ display: 'grid', gap: 6 }}>
{areas.map((a) => ( {areas.map((a) => (
<div key={a.uuid} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 12.5 }}> <div key={a.uuid} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 12.5 }}>
@@ -364,7 +403,7 @@ function SessionCard({ session: s, summary, resource, nationalCode }: {
</div> </div>
)} )}
{bookable && ( {open && bookable && (
/* بدون دستگاه، فرم سرویسی برای انتخاب ندارد؛ دکمهٔ خاموشِ بی‌توضیح بدتر از /* بدون دستگاه، فرم سرویسی برای انتخاب ندارد؛ دکمهٔ خاموشِ بی‌توضیح بدتر از
نبودنش است، پس دلیلش نوشته می‌شود. */ نبودنش است، پس دلیلش نوشته می‌شود. */
resource === null ? ( resource === null ? (