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:
@@ -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 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 28 }}>
|
||||
<InfoLine icon={<FilesServiceNotification />} label="نوبت بعدی:" value={nextAppointment ? formatDate(nextAppointment) : '—'} />
|
||||
<InfoLine
|
||||
icon={<FilesServiceNotification />}
|
||||
label={nextAppointment || !nextSession ? 'نوبت بعدی:' : 'جلسهٔ بعدی:'}
|
||||
value={
|
||||
nextAppointment ? formatDate(nextAppointment)
|
||||
: nextSession ? formatDate(nextSession)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button" onClick={onAddNote}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--accent)', color: 'var(--on-primary)', border: 'none', borderRadius: 10, padding: '0 18px', height: 36, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDownIcon, ClipboardDocumentListIcon, CpuChipIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ChevronLeftIcon, ChevronRightIcon, 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';
|
||||
@@ -8,7 +11,8 @@ import Pagination from '../ui/Pagination';
|
||||
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
||||
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
||||
import { useClinicContext } from '../../hooks/useClinicContext';
|
||||
import { formatDateTime, formatNumber } from '../../lib/utils';
|
||||
import { useUrlState } from '../../hooks/useUrlState';
|
||||
import { formatDate, formatDateTime, formatNumber } from '../../lib/utils';
|
||||
import type { TreatmentCaseSummary, SessionAreaRecord } from '../../types';
|
||||
|
||||
interface PlanSession {
|
||||
@@ -25,7 +29,6 @@ interface PlanSession {
|
||||
|
||||
interface PlanResponse {
|
||||
case: TreatmentCaseSummary & { patient_national_code: string | null };
|
||||
/** دستگاهِ دوره — همان که جلسهٔ قبل رویش انجام شد. */
|
||||
resource: { uuid: string; name: string } | null;
|
||||
sessions: PlanSession[];
|
||||
}
|
||||
@@ -36,6 +39,18 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||
abandoned: 'رها شده',
|
||||
};
|
||||
|
||||
const SESSION_STATUS_TEXT: Record<string, string> = {
|
||||
planned: 'برنامهریزی شده',
|
||||
booked: 'زمانبندی شده',
|
||||
in_progress: 'در حال انجام',
|
||||
done: 'انجام شد',
|
||||
cancelled: 'لغو شده',
|
||||
no_show: 'غیبت',
|
||||
};
|
||||
|
||||
const SETTLED = ['done', 'cancelled', 'no_show'];
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
/** `planned_at` (ثانیه) → `YYYY-MM-DD` میلادی، همان چیزی که مودال میخواهد. */
|
||||
function isoDay(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
@@ -43,13 +58,31 @@ function isoDay(ts: number): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** جستجو روی همان چیزهایی که در کارت دیده میشوند، نه فیلدهای پنهان. */
|
||||
function matches(s: PlanSession, serviceName: string, term: string): boolean {
|
||||
if (term === '') return true;
|
||||
|
||||
return [
|
||||
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(' ').includes(term);
|
||||
}
|
||||
|
||||
/**
|
||||
* درمانهای چندجلسهایِ همین بیمار، با تقویم کل دوره.
|
||||
* دورههای درمانِ همین بیمار.
|
||||
*
|
||||
* تاریخها از `/plan` میآیند و بخشیشان تخمینیاند — همانجا برچسب میخورند تا با
|
||||
* نوبتِ ثبتشده اشتباه گرفته نشوند. کارت نوبت نیست؛ دکمهاش نوبت میسازد.
|
||||
* دو نما دارد و نه یکی: فهرستِ کارتِ دورهها، و با کلیک، جزئیاتِ همان دوره. یک دورهٔ
|
||||
* شصتجلسهای بازشده کنار بقیه، صفحه را غیرقابلخواندن میکرد.
|
||||
*
|
||||
* انتخاب در URL مینشیند تا «بازگشت» مرورگر همان دوره را برگرداند.
|
||||
*/
|
||||
export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string }) {
|
||||
const [urlState, setUrlState] = useUrlState({ case: '', view: 'sessions' });
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['patient-treatment-cases', recordUuid],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||
@@ -59,16 +92,13 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// جستجو در همین صفحه فیلتر میشود نه روی سرور: `/plan` کل دوره را یکجا میدهد و
|
||||
// برای چند ده جلسه رفتوبرگشت اضافه هیچ چیزی جز تأخیر اضافه نمیکند.
|
||||
const [term, setTerm] = useState('');
|
||||
|
||||
const cases = data?.data ?? [];
|
||||
const cases = data?.data ?? [];
|
||||
const selected = cases.find((c) => c.uuid === urlState.case) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, padding: '16px 0' }}>
|
||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 96 }} />)}
|
||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 108 }} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +106,7 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'center', padding: 32 }}>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--danger)' }}>خواندن درمانهای این بیمار ناموفق بود.</span>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--danger)' }}>خواندن دورههای این بیمار ناموفق بود.</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
);
|
||||
@@ -87,22 +117,150 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 8, justifyItems: 'center', padding: 40 }}>
|
||||
<ClipboardDocumentListIcon style={{ width: 40, height: 40, color: 'var(--text-3)' }} />
|
||||
<span style={{ fontSize: 13.5, color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.9 }}>
|
||||
این بیمار درمان چندجلسهای ندارد.
|
||||
این بیمار دورهٔ درمان ندارد.
|
||||
<br />
|
||||
پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.
|
||||
دوره وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selected !== null) {
|
||||
return (
|
||||
<CaseDetail
|
||||
summary={selected}
|
||||
view={urlState.view === 'upcoming' ? 'upcoming' : 'sessions'}
|
||||
onView={(v) => setUrlState({ view: v })}
|
||||
onBack={() => setUrlState({ case: '', view: 'sessions' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gap: 12, padding: '16px 0',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
}}>
|
||||
{cases.map((c) => (
|
||||
<CaseCard key={c.uuid} summary={c} onOpen={() => setUrlState({ case: c.uuid, view: 'sessions' })} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** کارتِ خلاصهٔ یک دوره — کل کارت دکمه است، نه لینکی در گوشهاش. */
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="card card-pad"
|
||||
style={{
|
||||
display: 'grid', gap: 10, textAlign: 'start', cursor: 'pointer',
|
||||
font: 'inherit', color: 'var(--text)', width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 14 }}>{c.service.name}</strong>
|
||||
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
|
||||
</span>
|
||||
<ChevronLeftIcon style={{ width: 16, height: 16, marginInlineStart: 'auto', color: 'var(--text-3)' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 4, fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
<span>{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه انجام شده</span>
|
||||
<span>شروع: {formatDate(c.opened_at)}</span>
|
||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||
{operator !== '' && <span>پرسنل: {operator}</span>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={c.completed_sessions}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={c.total_sessions}
|
||||
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
|
||||
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{
|
||||
width: `${percent}%`, height: '100%', borderRadius: 999,
|
||||
background: c.status === 'completed' ? 'var(--success)' : 'var(--primary)',
|
||||
}} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<ApiResponse<PlanResponse>>(`/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 (
|
||||
<div style={{ display: 'grid', gap: 14, padding: '16px 0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn secondary sm" onClick={onBack}>
|
||||
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
||||
همهٔ دورهها
|
||||
</button>
|
||||
<strong style={{ fontSize: 14 }}>{summary.service.name}</strong>
|
||||
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="seg" style={{ alignSelf: 'start' }}>
|
||||
{([['sessions', 'جزئیات دوره'], ['upcoming', `نوبتهای آینده (${formatNumber(upcomingCount)})`]] as const).map(
|
||||
([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={view === v ? 'on' : ''}
|
||||
aria-pressed={view === v}
|
||||
onClick={() => onView(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="سرویس، پرسنل، وضعیت یا شمارهٔ جلسه"
|
||||
placeholder="پرسنل، وضعیت، ناحیه یا شمارهٔ جلسه"
|
||||
aria-label="جستجوی جلسات"
|
||||
/>
|
||||
{term !== '' && (
|
||||
@@ -112,115 +270,35 @@ export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cases.map((c) => <CasePlan key={c.uuid} summary={c} term={term.trim()} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-case-plan', summary.uuid],
|
||||
queryFn: () => api.get<ApiResponse<PlanResponse>>(`/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 (
|
||||
<div className="card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', width: '100%',
|
||||
padding: 'var(--card-pad)', background: 'none', border: 'none', cursor: 'pointer',
|
||||
font: 'inherit', color: 'var(--text)', textAlign: 'start',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 14 }}>{summary.service.name}</strong>
|
||||
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
|
||||
</span>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه
|
||||
</span>
|
||||
{summary.supervisor && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پزشک ناظر: {summary.supervisor.name}</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 16, height: 16, marginInlineStart: 'auto', flexShrink: 0,
|
||||
transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ padding: '0 var(--card-pad) var(--card-pad)' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال خواندن تقویم دوره…</div>
|
||||
) : isError ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>خواندن تقویم این دوره ناموفق بود.</span>
|
||||
<button type="button" className="btn ghost sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</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>
|
||||
)
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 72 }} />)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--danger)' }}>خواندن تقویم این دوره ناموفق بود.</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
{term !== ''
|
||||
? `جلسهای با «${term}» پیدا نشد.`
|
||||
: view === 'upcoming'
|
||||
? 'جلسهٔ باقیماندهای در این دوره نیست.'
|
||||
: 'این دوره جلسهای ندارد.'}
|
||||
</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>
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
display: 'grid', gap: 8, padding: '12px 14px',
|
||||
borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
||||
}}>
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 13.5 }}>
|
||||
جلسهٔ {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)}
|
||||
</span>
|
||||
{/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */}
|
||||
{s.is_estimate && (
|
||||
<span className="badge gray" style={{ fontSize: 11 }}>تخمینی</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>
|
||||
)}
|
||||
@@ -282,7 +354,9 @@ function SessionCard({ session: s, summary, resource, nationalCode }: {
|
||||
</span>
|
||||
)}
|
||||
{a.parameters && Object.entries(a.parameters).map(([k, v]) => (
|
||||
<span key={k} style={{ color: 'var(--text-3)' }}>{k}: <b style={{ color: 'var(--text-2)' }}>{String(v)}</b></span>
|
||||
<span key={k} style={{ color: 'var(--text-3)' }}>
|
||||
{k}: <b style={{ color: 'var(--text-2)' }}>{String(v)}</b>
|
||||
</span>
|
||||
))}
|
||||
{a.note && <span style={{ color: 'var(--text-3)' }}>یادداشت: {a.note}</span>}
|
||||
</div>
|
||||
@@ -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'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user