Files
clinicpro/assets/admin/pages/TreatmentCasesPage.tsx
T
hamedandClaude Opus 5 d3f7812d15 feat(admin): table/card views for treatment cases, matching the patients list
The list was cards only, so comparing courses across patients meant reading four
stacked blocks instead of scanning columns. It now has the same table/card
toggle the patients page uses, reusing that page's toggle icons and keeping the
choice in the URL so back and refresh hold it. Table is the default here: the
operator, status and session progress are the columns a manager scans.

Both views derive the operator the same way, through one helper — history
(performed_by) when a session has been done, otherwise the plan
(assigned_staff), labelled as not yet performed.

Also fixes a three-states slip in the operator picker I added last time: it
rendered "no staff defined" while the staff list was still loading, which is
what an empty list looks like from the user's side. Loading now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:13:27 +03:30

502 lines
21 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MagnifyingGlassIcon, PencilSquareIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PageHeader from '../components/ui/PageHeader';
import StatusBadge from '../components/ui/StatusBadge';
import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import PersianDateInput from '../components/ui/PersianDateInput';
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal';
import { PatientsGridView, PatientsCategoryView } from '../components/icons/FilesToolbarIcons';
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
const TABS = [
{ id: 'cases', label: 'پرونده‌های درمان' },
{ id: 'unbooked', label: 'جلسات بدون نوبت' },
] as const;
type TabId = typeof TABS[number]['id'];
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
active: 'در جریان',
completed: 'تمام شده',
abandoned: 'رها شده',
};
/**
* پرونده‌های درمان و کارِ باقی‌ماندهٔ منشی.
*
* صفِ «جلسات بدون نوبت» عمداً کنار فهرست پرونده‌هاست نه صفحهٔ جدا: هر دو یک سؤال
* را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده».
*/
export default function TreatmentCasesPage() {
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '', from: '', to: '', view: 'table' });
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId;
return (
<>
<PageHeader title="درمان‌های چندجلسه‌ای" />
<div className="tabs" style={{ marginBottom: 16 }}>
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={tab === t.id ? 'active' : ''}
onClick={() => setUrlState({ tab: t.id })}
>
{t.label}
</button>
))}
</div>
{tab === 'cases'
? <CasesTab
status={urlState.status}
onStatus={(s) => setUrlState({ status: s })}
search={urlState.q}
onSearch={(q) => setUrlState({ q })}
from={urlState.from}
onFrom={(from) => setUrlState({ from })}
to={urlState.to}
onTo={(to) => setUrlState({ to })}
view={urlState.view === 'card' ? 'card' : 'table'}
onView={(v) => setUrlState({ view: v })}
/>
: <UnbookedTab />}
</>
);
}
const STATUS_FILTERS = [
['', 'همه'],
['active', 'در جریان'],
['completed', 'تمام شده'],
['abandoned', 'رها شده'],
] as const;
function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo, view, onView }: {
status: string;
onStatus: (s: string) => void;
search: string;
onSearch: (s: string) => void;
/** بازهٔ تاریخِ باز شدن پرونده، `YYYY-MM-DD` میلادی. خالی = بدون کران. */
from: string;
onFrom: (s: string) => void;
to: string;
onTo: (s: string) => void;
/** جدولی یا کارتی — همان الگوی صفحهٔ پرونده‌ها، در URL تا «بازگشت» نما را نپراند. */
view: 'table' | 'card';
onView: (v: 'table' | 'card') => void;
}) {
// فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به URL می‌رود؛ وگرنه هر حرف یک ورودی
// تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود.
const [term, setTerm] = useState(search);
useEffect(() => setTerm(search), [search]);
useEffect(() => {
const t = setTimeout(() => { if (term !== search) onSearch(term); }, 350);
return () => clearTimeout(t);
}, [term]);
const [editing, setEditing] = useState<string | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['treatment-cases', status, search, from, to],
queryFn: () => {
const qs = new URLSearchParams();
if (status) qs.set('status', status);
if (search) qs.set('q', search);
if (from) qs.set('from', from);
if (to) qs.set('to', to);
const suffix = qs.toString();
return api.get<ApiResponse<TreatmentCaseSummary[]>>(
`/api/v1/treatment-cases${suffix ? `?${suffix}` : ''}`,
);
},
staleTime: 30_000,
});
const cases = data?.data ?? [];
return (
<>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
<div className="field" style={{ flex: '1 1 260px', maxWidth: 380 }}>
<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>
<div style={{
display: 'flex', flexShrink: 0, overflow: 'hidden',
border: '1px solid var(--border-2)', borderRadius: 4,
}}>
{([['table', 'نمایش جدولی', PatientsGridView], ['card', 'نمایش کارتی', PatientsCategoryView]] as const).map(
([v, label, Icon]) => (
<button
key={v}
type="button"
aria-label={label}
aria-pressed={view === v}
onClick={() => onView(v)}
style={{
padding: 8, border: 'none', cursor: 'pointer', display: 'grid', placeItems: 'center',
background: view === v ? 'var(--primary-soft)' : 'transparent',
}}
>
<Icon color={view === v ? 'var(--primary)' : 'var(--text-2)'} />
</button>
),
)}
</div>
<div className="seg">
{STATUS_FILTERS.map(([v, label]) => (
<button
key={v}
type="button"
className={status === v ? 'on' : ''}
aria-pressed={status === v}
onClick={() => onStatus(v)}
>
{label}
</button>
))}
</div>
{/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>شروع</span>
{/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش
می‌افتد و معلوم نیست کران بالا کدام است. */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'nowrap',
flex: '1 1 300px', minWidth: 260, maxWidth: 340,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<PersianDateInput value={from} onChange={onFrom} ariaLabel="شروع از تاریخ" placeholder="از تاریخ" />
</div>
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flexShrink: 0 }}>تا</span>
<div style={{ flex: 1, minWidth: 0 }}>
<PersianDateInput value={to} onChange={onTo} ariaLabel="شروع تا تاریخ" placeholder="تا تاریخ" />
</div>
</div>
{(from !== '' || to !== '') && (
<button
type="button"
className="btn ghost sm"
onClick={() => { onFrom(''); onTo(''); }}
>
پاک کردن بازه
</button>
)}
</div>
</div>
{from !== '' && to !== '' && from > to && (
<div className="card card-pad" style={{ marginBottom: 12, fontSize: 12.5, color: 'var(--danger)' }}>
«از تاریخ» بعد از «تا تاریخ» است، پس هیچ پرونده‌ای در این بازه نمی‌افتد.
</div>
)}
{isLoading ? (
<div style={{ display: 'grid', gap: 12 }}>
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 96 }} />)}
</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>
) : cases.length === 0 ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)', lineHeight: 1.9 }}>
{search
? `برای «${search}» پرونده‌ای پیدا نشد.`
: (from !== '' || to !== '')
? 'در این بازهٔ تاریخ پرونده‌ای باز نشده است.'
: 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
</div>
) : view === 'table' ? (
<CasesTable cases={cases} onEdit={setEditing} />
) : (
<div style={{ display: 'grid', gap: 12 }}>
{cases.map((c) => <CaseCard key={c.uuid} item={c} onEdit={() => setEditing(c.uuid)} />)}
</div>
)}
{editing !== null && (
<TreatmentCaseEditModal caseUuid={editing} onClose={() => setEditing(null)} />
)}
</>
);
}
/**
* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی جلسه‌ای
* انجام نشده، همان برنامه را نشان می‌دهیم. هر دو نما یک قاعده دارند.
*/
function operatorOf(c: TreatmentCaseSummary): { text: string; planned: boolean } | null {
if (c.performed_by.length > 0) {
return { text: c.performed_by.map((s) => s.name).join('، '), planned: false };
}
if (c.assigned_staff.length > 0) {
return { text: c.assigned_staff.map((s) => s.name).join('، '), planned: true };
}
return null;
}
const TABLE_HEADS = ['ردیف', 'بیمار', 'سرویس', 'پرسنل', 'وضعیت', 'جلسات', 'شروع', 'عملیات'];
function CasesTable({ cases, onEdit }: { cases: TreatmentCaseSummary[]; onEdit: (uuid: string) => void }) {
return (
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)', overflow: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr style={{ background: 'var(--surface-2)', color: 'var(--text-2)', fontSize: 13 }}>
{TABLE_HEADS.map((h) => (
<th key={h} style={{ padding: '12px 14px', textAlign: 'center', fontWeight: 600, whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{cases.map((c, i) => {
const operator = operatorOf(c);
return (
<tr key={c.uuid} style={{ borderTop: '1px solid var(--border)', fontSize: 13.5, textAlign: 'center' }}>
<td style={{ padding: '12px 14px', color: 'var(--text-3)' }}>{formatNumber(i + 1)}</td>
<td style={{ padding: '12px 14px' }}>
<div style={{ fontWeight: 600 }}>{c.patient.name || 'بیمار بدون نام'}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', direction: 'ltr' }}>{c.patient.mobile}</div>
</td>
<td style={{ padding: '12px 14px' }}>{c.service.name}</td>
<td style={{ padding: '12px 14px', color: operator?.planned ? 'var(--text-3)' : 'var(--text)' }}>
{operator === null ? '—' : operator.planned ? `${operator.text} (هنوز انجام نشده)` : operator.text}
</td>
<td style={{ padding: '12px 14px' }}>
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
</span>
</td>
<td style={{ padding: '12px 14px', whiteSpace: 'nowrap' }}>
{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)}
</td>
<td style={{ padding: '12px 14px', whiteSpace: 'nowrap' }}>{formatDateTime(c.opened_at)}</td>
<td style={{ padding: '12px 14px' }}>
<button
type="button"
className="mini-btn"
aria-label={`ویرایش پروندهٔ ${c.patient.name ?? ''}`}
onClick={() => onEdit(c.uuid)}
style={{ color: 'var(--accent)' }}
>
<PencilSquareIcon style={{ width: 18, height: 18 }} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) {
const percent = c.total_sessions > 0
? Math.round((c.completed_sessions / c.total_sessions) * 100)
: 0;
const operator = operatorOf(c);
return (
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا می‌شوند. */}
<strong style={{ fontSize: 14 }}>{c.patient.name || 'بیمار بدون نام'}</strong>
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
</span>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه
</span>
<button type="button" className="btn secondary sm" style={{ marginInlineStart: 'auto' }} onClick={onEdit}>
<PencilSquareIcon style={{ width: 15, height: 15 }} />
ویرایش
</button>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
<span>{c.service.name}</span>
<span style={{ direction: 'ltr' }}>{c.patient.mobile}</span>
{/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */}
<span>شروع: {formatDateTime(c.opened_at)}</span>
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
{operator !== null && (
operator.planned
? <span style={{ color: 'var(--text-3)' }}>اپراتور: {operator.text} (هنوز انجام نشده)</span>
: <span>انجام‌دهنده: {operator.text}</span>
)}
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
</div>
{/* `<progress>` نیتیو ظاهر مرورگر را می‌گیرد و با توکن‌های تم نمی‌خواند. */}
<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)',
transition: 'width .3s var(--ease)',
}} />
</div>
</div>
);
}
function UnbookedTab() {
const { data, isLoading } = useQuery({
queryKey: ['treatment-sessions-unbooked'],
queryFn: () => api.get<ApiResponse<StaffTreatmentSession[]>>('/api/v1/treatment-sessions/unbooked?within_days=14'),
staleTime: 30_000,
});
const sessions = data?.data ?? [];
return (
<>
<p style={{ margin: '0 0 12px', fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
جلساتی که سررسیدشان رسیده و هنوز نوبت نگرفته‌اند. رزرو عمداً خودکار نیست وقتِ مناسب را
باید با خود بیمار هماهنگ کرد.
</p>
{isLoading ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : sessions.length === 0 ? (
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
جلسه‌ای در انتظار رزرو نیست.
</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{sessions.map((s) => (
<div key={s.uuid} className="card card-pad" style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 14 }}>{s.service_name}</strong>
<StatusBadge type="treatment-session" value={s.status} />
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
جلسهٔ {s.session_number} از {s.total_sessions}
</span>
</div>
{s.due_at !== null && (
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
سررسید: {formatDate(s.due_at)}
</span>
)}
<SlotSuggestions sessionUuid={s.uuid} />
</div>
))}
</div>
)}
</>
);
}
/**
* وقت‌های آزادِ همان دستگاهی که جلسهٔ قبلی رویش انجام شد.
*
* پیشنهاد است نه رزرو: منشی با بیمار هماهنگ می‌کند و بعد از فرم عادی نوبت ثبتش
* می‌کند. خودکار رزرو کردن یعنی سیستم به‌جای بیمار تصمیم بگیرد و بعد او نیاید.
*/
function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
const [open, setOpen] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ['session-slot-suggestions', sessionUuid],
queryFn: () => api.get<ApiResponse<SlotSuggestionResponse>>(
`/api/v1/treatment-session/${sessionUuid}/slot-suggestions?days=14`,
),
enabled: open,
staleTime: 60_000,
});
if (!open) {
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className="btn secondary sm" onClick={() => setOpen(true)}>
پیشنهاد وقت
</button>
<Link to="/admin/appointments/new" className="btn primary sm">ثبت نوبت این جلسه</Link>
</div>
);
}
const days = data?.data?.days ?? [];
return (
<div style={{ display: 'grid', gap: 8 }}>
{isLoading && <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>در حال جست‌وجوی وقت...</span>}
{isError && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
دستگاهی برای پیشنهاد وقت مشخص نیست این جلسه هنوز روی هیچ دستگاهی انجام نشده.
</span>
)}
{!isLoading && !isError && days.length === 0 && (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
در دو هفتهٔ آینده وقت آزادی روی این دستگاه نیست.
</span>
)}
{days.slice(0, 3).map((day) => (
<div key={day.date} style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 12.5, minWidth: 96, color: 'var(--text-2)' }}>
{formatDate(Math.floor(new Date(day.date).getTime() / 1000))}
</span>
{day.slots.slice(0, 6).map((slot) => (
<Link
key={slot.start}
to={`/admin/appointments/new?slot_start=${slot.start}&resource_uuid=${data?.data?.resource_uuid ?? ''}`}
className="btn secondary sm"
title={formatDateTime(slot.start)}
>
{slot.start_time}
</Link>
))}
</div>
))}
<button type="button" className="btn ghost sm" onClick={() => setOpen(false)} style={{ justifySelf: 'start' }}>
بستن
</button>
</div>
);
}