The list had no way to tell two cases apart. TreatmentCase::toArray() carried
no patient, so four cases of the same service rendered as four identical
cards — same service, same supervisor, same date, same areas. Search would have
been meaningless without fixing that first, so the payload now carries the
patient (name, mobile, record number) and the card leads with the name.
Search: `?q=` on the list endpoint, matching patient name, mobile, national
code, record number and service name — the same keys a secretary already types
into the booking form. It lives in the URL via useUrlState, debounced, so back
and refresh keep the view.
Edit: PATCH /api/v1/treatment-case/{uuid} covering status, supervising doctor,
areas and session count, driven from a modal on the list. Rules live in
TreatmentCaseEditor, not the controller, around one boundary: no edit may
overwrite work already done. An area with session records cannot be removed, and
the session count cannot drop below the sessions that are booked or finished —
both 409, both tested. Reopening a closed case clears closed_at.
`areas[]` now also exposes `category_uuid`; the edit form selects catalog
categories, while `uuid` identifies the snapshot row.
Page fixes from the redesign checklist: the status filter was a hand-rolled
primary/secondary button pair, now `.seg` with `.on`; the raw `<progress>` bar
took the browser's own appearance and ignored the theme tokens, now a token-
styled bar with an explicit progressbar role; session counts go through
formatNumber; a failed request rendered as "no cases found", which reads as an
empty clinic rather than a broken one, and an empty search now says so in its
own words.
Adds the test files neither the page nor the case editor had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
13 KiB
TypeScript
338 lines
13 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 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: '' });
|
|
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 })}
|
|
/>
|
|
: <UnbookedTab />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const STATUS_FILTERS = [
|
|
['', 'همه'],
|
|
['active', 'در جریان'],
|
|
['completed', 'تمام شده'],
|
|
['abandoned', 'رها شده'],
|
|
] as const;
|
|
|
|
function CasesTab({ status, onStatus, search, onSearch }: {
|
|
status: string;
|
|
onStatus: (s: string) => void;
|
|
search: string;
|
|
onSearch: (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],
|
|
queryFn: () => {
|
|
const qs = new URLSearchParams();
|
|
if (status) qs.set('status', status);
|
|
if (search) qs.set('q', search);
|
|
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>
|
|
|
|
{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}» پروندهای پیدا نشد.`
|
|
: 'پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
|
|
</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>شروع: {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>` نیتیو ظاهر مرورگر را میگیرد و با توکنهای تم نمیخواند. */}
|
|
<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>
|
|
);
|
|
}
|