561 lines
23 KiB
TypeScript
561 lines
23 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon,
|
|
CpuChipIcon, MagnifyingGlassIcon, MinusIcon, PencilSquareIcon, PlusIcon, XMarkIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { api } from '../../lib/api';
|
|
import type { ApiResponse } from '../../lib/api';
|
|
import StatusBadge from '../ui/StatusBadge';
|
|
import TreatmentCaseEditModal from '../TreatmentCaseEditModal';
|
|
import Pagination from '../ui/Pagination';
|
|
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
|
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
|
import { useClinicContext } from '../../hooks/useClinicContext';
|
|
import { useUrlState } from '../../hooks/useUrlState';
|
|
import { formatDate, formatDateTime, formatNumber } from '../../lib/utils';
|
|
import type { TreatmentCaseSummary, SessionAreaRecord } from '../../types';
|
|
|
|
interface PlanSession {
|
|
uuid: string;
|
|
session_number: number;
|
|
total_sessions: number;
|
|
status: string;
|
|
planned_at: number | null;
|
|
is_estimate: boolean;
|
|
appointment: { uuid: string; slot_start: number } | null;
|
|
performed_by: { uuid: string; name: string } | null;
|
|
areas?: SessionAreaRecord[];
|
|
}
|
|
|
|
interface PlanResponse {
|
|
case: TreatmentCaseSummary & { patient_national_code: string | null };
|
|
resource: { uuid: string; name: string } | null;
|
|
sessions: PlanSession[];
|
|
}
|
|
|
|
/**
|
|
* آینهٔ `TreatmentProtocol::MIN_STEPS/MAX_STEPS` در بکاند.
|
|
*
|
|
* قفلِ واقعی سمت سرور است؛ این فقط جلوی درخواستی را میگیرد که جوابش از پیش معلوم
|
|
* است — دکمهای که میدانیم ۴۲۲ میگیرد نباید فعال بماند.
|
|
*/
|
|
const MIN_SESSIONS = 2;
|
|
const MAX_SESSIONS = 60;
|
|
|
|
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
|
active: 'در جریان',
|
|
completed: 'تمام شده',
|
|
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);
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* دورههای درمانِ همین بیمار.
|
|
*
|
|
* دو نما دارد و نه یکی: فهرستِ کارتِ دورهها، و با کلیک، جزئیاتِ همان دوره. یک دورهٔ
|
|
* شصتجلسهای بازشده کنار بقیه، صفحه را غیرقابلخواندن میکرد.
|
|
*
|
|
* انتخاب در 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[]>>(
|
|
`/api/v1/treatment-cases?record=${encodeURIComponent(recordUuid)}`,
|
|
),
|
|
enabled: recordUuid !== '',
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
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: 108 }} />)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (cases.length === 0) {
|
|
return (
|
|
<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);
|
|
const [editing, setEditing] = useState(false);
|
|
const qc = useQueryClient();
|
|
useEffect(() => setPage(1), [term, view]);
|
|
|
|
/**
|
|
* کم/زیاد کردن جلسات همین دوره.
|
|
*
|
|
* پیش از این فقط از صفحهٔ «دورههای درمان» و پشت یک مودال ممکن بود، در حالی که
|
|
* تصمیمش سرِ پروندهٔ بیمار گرفته میشود. سرور جلسهٔ انجامشده یا نوبتدار را حذف
|
|
* نمیکند و ۴۰۹ برمیگرداند؛ پیام همان خطا نشان داده میشود چون دلیلش را دقیقتر
|
|
* از هر متن ثابتی میگوید (چند جلسه قفل است).
|
|
*/
|
|
const setTotalSessions = useMutation({
|
|
mutationFn: (total: number) =>
|
|
api.patch<ApiResponse<unknown>>(`/api/v1/treatment-case/${summary.uuid}`, { total_sessions: total }),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
|
|
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message || 'تغییر تعداد جلسات ناموفق بود'),
|
|
});
|
|
|
|
const total = summary.total_sessions;
|
|
const completed = summary.completed_sessions;
|
|
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
const busy = setTotalSessions.isPending;
|
|
// کفِ واقعی، جلسات قفلشده است؛ سرور همان را با ۴۰۹ میگوید ولی دکمهٔ فعالِ
|
|
// بیاثر بدتر از دکمهٔ خاموش است.
|
|
const minAllowed = Math.max(MIN_SESSIONS, completed);
|
|
|
|
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>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
style={{ marginInlineStart: 'auto' }}
|
|
onClick={() => setEditing(true)}
|
|
>
|
|
<PencilSquareIcon style={{ width: 15, height: 15 }} />
|
|
ویرایش دوره
|
|
</button>
|
|
</div>
|
|
|
|
{/* تعداد جلسات — تصمیمی که سرِ همین صفحه گرفته میشود، پس همینجا هم تغییر میکند. */}
|
|
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: 13, fontWeight: 700 }}>تعداد جلسات</span>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<button
|
|
type="button"
|
|
className="mini-btn"
|
|
aria-label="کم کردن یک جلسه"
|
|
disabled={busy || total <= minAllowed}
|
|
onClick={() => setTotalSessions.mutate(total - 1)}
|
|
>
|
|
<MinusIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<span
|
|
aria-live="polite"
|
|
style={{ minWidth: 32, textAlign: 'center', fontSize: 15, fontWeight: 700 }}
|
|
>
|
|
{formatNumber(total)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="mini-btn"
|
|
aria-label="افزودن یک جلسه"
|
|
disabled={busy || total >= MAX_SESSIONS}
|
|
onClick={() => setTotalSessions.mutate(total + 1)}
|
|
>
|
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
</div>
|
|
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
|
{formatNumber(completed)} از {formatNumber(total)} جلسه انجام شده
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
role="progressbar"
|
|
aria-valuenow={completed}
|
|
aria-valuemin={0}
|
|
aria-valuemax={total}
|
|
aria-label={`پیشرفت دوره: ${completed} از ${total}`}
|
|
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
|
|
>
|
|
<div style={{
|
|
width: `${percent}%`, height: '100%',
|
|
background: summary.status === 'completed' ? 'var(--success)' : 'var(--primary)',
|
|
}} />
|
|
</div>
|
|
|
|
{total <= minAllowed && (
|
|
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
|
|
{completed >= total
|
|
? 'همهٔ جلسهها انجام شدهاند؛ کم کردن ممکن نیست.'
|
|
: `کمتر از ${formatNumber(MIN_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="پرسنل، وضعیت، ناحیه یا شمارهٔ جلسه"
|
|
aria-label="جستجوی جلسات"
|
|
/>
|
|
{term !== '' && (
|
|
<button type="button" className="mini-btn" aria-label="پاک کردن جستجو" onClick={() => setTerm('')}>
|
|
<XMarkIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{editing && (
|
|
<TreatmentCaseEditModal caseUuid={summary.uuid} onClose={() => setEditing(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SessionCard({ session: s, summary, resource, nationalCode }: {
|
|
session: PlanSession;
|
|
summary: TreatmentCaseSummary;
|
|
resource: { uuid: string; name: string } | null;
|
|
nationalCode: string | null;
|
|
}) {
|
|
const [booking, setBooking] = useState(false);
|
|
/**
|
|
* سابقه بسته میآید و کارِ پیشِ رو باز.
|
|
*
|
|
* جلسهٔ انجامشده سه ناحیه با خواندههایشان دارد و بازِ پیشفرض، فهرست را دفن
|
|
* میکند؛ ولی جلسهای که باید رزرو شود، دکمهاش دلیلِ وجودش است و پشت یک کلیک
|
|
* اضافه نباید بماند.
|
|
*/
|
|
const [open, setOpen] = useState(!SETTLED.includes(s.status));
|
|
const qc = useQueryClient();
|
|
const clinicUuid = useClinicContext();
|
|
|
|
const supervisor = summary.supervisor;
|
|
// سرویسها از تقویم منبع میآیند نه از برنامهٔ پزشک — دورهٔ لیزر روی دستگاه رزرو
|
|
// میشود و برنامهٔ پزشکِ ناظر سرویسی برای انتخاب ندارد.
|
|
const { services } = useResourceBookingServices(booking ? resource?.uuid : null);
|
|
|
|
const bookable = !SETTLED.includes(s.status) && s.appointment === null && summary.status === 'active';
|
|
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 (
|
|
<div className="card card-pad" style={{ display: 'grid', gap: 8 }}>
|
|
{hasBody ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((o) => !o)}
|
|
aria-expanded={open}
|
|
style={{
|
|
display: 'flex', width: '100%', padding: 0, background: 'none', border: 'none',
|
|
cursor: 'pointer', font: 'inherit', color: 'var(--text)', textAlign: 'start',
|
|
}}
|
|
>
|
|
{head}
|
|
</button>
|
|
) : head}
|
|
|
|
{/* آنچه واقعاً انجام شد: ناحیه، دستگاه و خواندههایش. */}
|
|
{open && areas.length > 0 && (
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
{areas.map((a) => (
|
|
<div key={a.uuid} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 12.5 }}>
|
|
<span style={{ fontWeight: 600 }}>{a.area.name}</span>
|
|
<StatusBadge type="treatment-area" value={a.status} />
|
|
{a.resource && (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-3)' }}>
|
|
<CpuChipIcon style={{ width: 13, height: 13 }} />
|
|
{a.resource.name}
|
|
</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>
|
|
))}
|
|
{a.note && <span style={{ color: 'var(--text-3)' }}>یادداشت: {a.note}</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{open && bookable && (
|
|
/* بدون دستگاه، فرم سرویسی برای انتخاب ندارد؛ دکمهٔ خاموشِ بیتوضیح بدتر از
|
|
نبودنش است، پس دلیلش نوشته میشود. */
|
|
resource === null ? (
|
|
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
|
|
هنوز هیچ جلسهای از این دوره روی دستگاهی انجام نشده — نوبت را از صفحهٔ نوبتها ثبت کنید.
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn primary sm"
|
|
style={{ justifySelf: 'start' }}
|
|
onClick={() => setBooking(true)}
|
|
>
|
|
ثبت نوبت این جلسه
|
|
</button>
|
|
)
|
|
)}
|
|
|
|
{booking && resource && (
|
|
<NewAppointmentModal
|
|
slot={{
|
|
start: 0, end: 0, start_time: '', end_time: '',
|
|
doctor_uuid: supervisor?.uuid ?? '', doctor_name: supervisor?.name ?? '',
|
|
}}
|
|
resource={resource}
|
|
services={services}
|
|
date={s.planned_at === null ? undefined : isoDay(s.planned_at)}
|
|
clinicUuid={clinicUuid}
|
|
treatmentSessionUuid={s.uuid}
|
|
patient={{
|
|
name: summary.patient.name,
|
|
mobile: summary.patient.mobile,
|
|
national_code: nationalCode,
|
|
}}
|
|
onClose={() => setBooking(false)}
|
|
onSuccess={() => {
|
|
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
|
|
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
|
|
qc.invalidateQueries({ queryKey: ['patient-appointments'] });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|