Completes the panel side. A clinic picks its practice domain in settings, where the copy says plainly that this is not the specialty label the public site shows; picking nothing stays valid and changes nothing. Cases and the unbooked queue share one page rather than two, because they answer the same question — which patient is where in their course and what is still owed. The queue explains why booking is not automatic instead of leaving the reader to wonder. Platform admins get domain CRUD with a column showing whether a domain has a dedicated workflow or falls back to the default, so the gap is visible rather than guessed at; the code field is locked after creation because workflows bind to it. Also puts the staff session screens in the sidebar — they were reachable only by typing the URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
6.8 KiB
TypeScript
174 lines
6.8 KiB
TypeScript
import { Link } from 'react-router-dom';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
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 } from '../lib/utils';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import type { TreatmentCaseSummary, StaffTreatmentSession } 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: '' });
|
|
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 })} />
|
|
: <UnbookedTab />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string) => void }) {
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['treatment-cases', status],
|
|
queryFn: () => api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
|
`/api/v1/treatment-cases${status ? `?status=${status}` : ''}`,
|
|
),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const cases = data?.data ?? [];
|
|
|
|
return (
|
|
<>
|
|
<div className="toolbar" style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
{[['', 'همه'], ['active', 'در جریان'], ['completed', 'تمام شده'], ['abandoned', 'رها شده']].map(([v, label]) => (
|
|
<button
|
|
key={v}
|
|
type="button"
|
|
className={`btn ${status === v ? 'primary' : 'secondary'} sm`}
|
|
onClick={() => onStatus(v)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
|
) : cases.length === 0 ? (
|
|
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
|
پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: 12 }}>
|
|
{cases.map((c) => (
|
|
<div key={c.uuid} className="card card-pad" style={{ display: 'grid', gap: 8 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, 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>
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
|
{c.completed_sessions} از {c.total_sessions} جلسه
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
|
<span>شروع: {formatDate(c.opened_at)}</span>
|
|
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
|
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
|
</div>
|
|
|
|
<progress
|
|
value={c.completed_sessions}
|
|
max={c.total_sessions}
|
|
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
|
|
style={{ width: '100%', height: 6 }}
|
|
/>
|
|
</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>
|
|
)}
|
|
|
|
<Link to="/admin/appointments/new" className="btn primary sm" style={{ justifySelf: 'start' }}>
|
|
ثبت نوبت این جلسه
|
|
</Link>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|