Files
clinicpro/assets/admin/pages/TreatmentCasesPage.tsx
T
hamedandClaude Opus 5 c7a3b88b32 feat(treatment): bind an appointment to a chosen session, and free it on cancel
Two holes in how a course's later appointments were made.

The link from the unbooked queue carried nothing — `/admin/appointments/new`
with no parameters — so the secretary retyped the patient and the service, and
which case the appointment joined was inferred from the service they happened to
pick. A patient with two open courses had no way to say which one they meant,
and picking the wrong service silently opened a third case. (The suggestion link
did pass slot_start and resource_uuid, but the create page never read either.)

POST /api/v1/my/appointment now takes an optional treatment_session_uuid.
SessionBookingLink validates it — same tenant, still unbooked, case open, same
patient — and reserves that session. Confirm-time attachment steps aside when
the appointment already holds a session. The booking form states in words which
session, which course and which patient it is about to book, read from a new
GET /api/v1/treatment-session/{uuid}.

Nothing ever detached a session from its appointment, so a cancelled booking
left the session `booked` forever, and since findNextUnbooked requires
"has no appointment", it could never return to the queue. Cancellation and
no-show now release it back to `planned`. A finished session is history and is
left alone.

The system still never books the next appointment by itself — it only suggests.

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

506 lines
22 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?session=${sessionUuid}`} 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?session=${sessionUuid}&date=${day.date}`}
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>
);
}