feat(treatment): date-range filter on treatment cases, and time on the start stamp

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>
This commit is contained in:
hamed
2026-08-07 13:57:14 +03:30
co-authored by Claude Opus 5
parent 2bb76258f6
commit 73963020e2
6 changed files with 141 additions and 6 deletions
@@ -102,6 +102,28 @@ describe('صفحهٔ پرونده‌های درمان', () => {
expect(screen.queryByText(/پرونده‌ای یافت نشد/)).not.toBeInTheDocument();
});
it('بازهٔ تاریخ به‌صورت from و to می‌رود', async () => {
mockList([caseRow()]);
renderWithProviders(<TreatmentCasesPage />);
await screen.findByText('محمد رسولی');
// PersianDateInput تریگرش input نیست، پس با نام دسترس‌پذیرش پیدایش می‌کنیم.
fireEvent.click(screen.getByLabelText('شروع از تاریخ'));
expect(screen.getByLabelText('شروع تا تاریخ')).toBeInTheDocument();
});
it('تاریخ شروع ساعت هم دارد', async () => {
mockList([caseRow({ opened_at: 1_786_000_000 })]);
renderWithProviders(<TreatmentCasesPage />);
// formatDateTime ساعت را با «:» می‌آورد؛ formatDate نمی‌آورد.
const start = await screen.findByText(/^شروع:/);
expect(start.textContent).toMatch(/:/);
});
it('دکمهٔ ویرایش مودال را باز می‌کند', async () => {
mockList([caseRow()]);
+54 -5
View File
@@ -8,6 +8,7 @@ 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';
@@ -31,7 +32,7 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
* را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده».
*/
export default function TreatmentCasesPage() {
const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '' });
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 (
@@ -57,6 +58,10 @@ export default function TreatmentCasesPage() {
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 />}
</>
@@ -70,11 +75,16 @@ const STATUS_FILTERS = [
['abandoned', 'رها شده'],
] as const;
function CasesTab({ status, onStatus, search, onSearch }: {
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 می‌رود؛ وگرنه هر حرف یک ورودی
// تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود.
@@ -88,11 +98,13 @@ function CasesTab({ status, onStatus, search, onSearch }: {
const [editing, setEditing] = useState<string | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['treatment-cases', status, search],
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[]>>(
@@ -135,8 +147,42 @@ function CasesTab({ status, onStatus, search, onSearch }: {
</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 }} />)}
@@ -151,7 +197,9 @@ function CasesTab({ status, onStatus, search, onSearch }: {
<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 }}>
@@ -191,7 +239,8 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
<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>
{/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */}
<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>