Files
clinicpro/assets/admin/pages/TreatmentCasesPage.tsx
T
hamedandClaude Opus 5 b78f7311cf feat(treatment): per-case operators, shown on the list and searchable
A treatment case said which doctor supervised it but never who actually did the
work, so the list could not answer the first question a manager asks about a
course: who performed it.

Two separate things now travel with the case. `performed_by` is history —
derived from the sessions' performedBy, so it only ever reports what happened.
`assigned_staff` is plan — a new treatment_case_staff table, editable from the
modal, saying who is meant to handle this patient's course. The card shows the
first and falls back to the second while nothing has been performed yet.

Search matches both. A manager typing an operator's name wants that person's
work, and work already done is part of it.

Assignment also narrows the operator queue: a case with assigned staff shows its
sessions only to those people, because a patient who started a multi-session
course with one operator should keep them. An unassigned case keeps the existing
protocol rule, and an empty list means "anyone the protocol allows" rather than
"nobody" — the same "no rows is not a restriction" convention used elsewhere.

Unlike areas, removing an operator erases nothing: a finished session carries its
real operator on itself and never consults this list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:55:01 +03:30

396 lines
17 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 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: '' });
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 })}
/>
: <UnbookedTab />}
</>
);
}
const STATUS_FILTERS = [
['', 'همه'],
['active', 'در جریان'],
['completed', 'تمام شده'],
['abandoned', 'رها شده'],
] as const;
function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }: {
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 می‌رود؛ وگرنه هر حرف یک ورودی
// تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود.
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 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>
) : (
<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 CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) {
const percent = c.total_sessions > 0
? Math.round((c.completed_sessions / c.total_sessions) * 100)
: 0;
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>}
{/* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی
جلسه‌ای انجام نشده، همان برنامه را نشان می‌دهیم. */}
{c.performed_by.length > 0 ? (
<span>انجام‌دهنده: {c.performed_by.map((s) => s.name).join('، ')}</span>
) : c.assigned_staff.length > 0 ? (
<span style={{ color: 'var(--text-3)' }}>
اپراتور: {c.assigned_staff.map((s) => s.name).join('، ')} (هنوز انجام نشده)
</span>
) : null}
{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>
);
}