The list could be narrowed by status and by search but not by when a case opened, which is the one axis a clinic actually reports on. `from` and `to` (YYYY-MM-DD) now bound `opened_at`, using the same strtotime day-boundary convention the appointment date filters already use under the app's global Tehran timezone. A malformed value is ignored rather than erroring — this is a filter, not a form field. Both bounds live in the URL via useUrlState, so back and refresh keep the range. The two date inputs and the "تا" between them are one nowrap unit; letting them wrap separately orphaned the word from its field on a 390px screen. The card's "شروع" showed only the Jalali date, so several cases opened on the same day were indistinguishable on that line too. It now uses formatDateTime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
387 lines
16 KiB
TypeScript
387 lines
16 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.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>
|
|
);
|
|
}
|