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:
@@ -102,6 +102,28 @@ describe('صفحهٔ پروندههای درمان', () => {
|
|||||||
expect(screen.queryByText(/پروندهای یافت نشد/)).not.toBeInTheDocument();
|
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 () => {
|
it('دکمهٔ ویرایش مودال را باز میکند', async () => {
|
||||||
mockList([caseRow()]);
|
mockList([caseRow()]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import PageHeader from '../components/ui/PageHeader';
|
|||||||
import StatusBadge from '../components/ui/StatusBadge';
|
import StatusBadge from '../components/ui/StatusBadge';
|
||||||
import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
|
import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
|
||||||
import { useUrlState } from '../hooks/useUrlState';
|
import { useUrlState } from '../hooks/useUrlState';
|
||||||
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||||
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal';
|
import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal';
|
||||||
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
|
import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types';
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
|||||||
* را جواب میدهند — «کدام بیمار در چه مرحلهای است و چه کاری مانده».
|
* را جواب میدهند — «کدام بیمار در چه مرحلهای است و چه کاری مانده».
|
||||||
*/
|
*/
|
||||||
export default function TreatmentCasesPage() {
|
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;
|
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +58,10 @@ export default function TreatmentCasesPage() {
|
|||||||
onStatus={(s) => setUrlState({ status: s })}
|
onStatus={(s) => setUrlState({ status: s })}
|
||||||
search={urlState.q}
|
search={urlState.q}
|
||||||
onSearch={(q) => setUrlState({ q })}
|
onSearch={(q) => setUrlState({ q })}
|
||||||
|
from={urlState.from}
|
||||||
|
onFrom={(from) => setUrlState({ from })}
|
||||||
|
to={urlState.to}
|
||||||
|
onTo={(to) => setUrlState({ to })}
|
||||||
/>
|
/>
|
||||||
: <UnbookedTab />}
|
: <UnbookedTab />}
|
||||||
</>
|
</>
|
||||||
@@ -70,11 +75,16 @@ const STATUS_FILTERS = [
|
|||||||
['abandoned', 'رها شده'],
|
['abandoned', 'رها شده'],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function CasesTab({ status, onStatus, search, onSearch }: {
|
function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo }: {
|
||||||
status: string;
|
status: string;
|
||||||
onStatus: (s: string) => void;
|
onStatus: (s: string) => void;
|
||||||
search: string;
|
search: string;
|
||||||
onSearch: (s: string) => void;
|
onSearch: (s: string) => void;
|
||||||
|
/** بازهٔ تاریخِ باز شدن پرونده، `YYYY-MM-DD` میلادی. خالی = بدون کران. */
|
||||||
|
from: string;
|
||||||
|
onFrom: (s: string) => void;
|
||||||
|
to: string;
|
||||||
|
onTo: (s: string) => void;
|
||||||
}) {
|
}) {
|
||||||
// فیلد جستجو محلی میماند و فقط مقدار نهایی به URL میرود؛ وگرنه هر حرف یک ورودی
|
// فیلد جستجو محلی میماند و فقط مقدار نهایی به URL میرود؛ وگرنه هر حرف یک ورودی
|
||||||
// تاریخچه میسازد و «بازگشت» بیمعنی میشود.
|
// تاریخچه میسازد و «بازگشت» بیمعنی میشود.
|
||||||
@@ -88,11 +98,13 @@ function CasesTab({ status, onStatus, search, onSearch }: {
|
|||||||
const [editing, setEditing] = useState<string | null>(null);
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['treatment-cases', status, search],
|
queryKey: ['treatment-cases', status, search, from, to],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (status) qs.set('status', status);
|
if (status) qs.set('status', status);
|
||||||
if (search) qs.set('q', search);
|
if (search) qs.set('q', search);
|
||||||
|
if (from) qs.set('from', from);
|
||||||
|
if (to) qs.set('to', to);
|
||||||
const suffix = qs.toString();
|
const suffix = qs.toString();
|
||||||
|
|
||||||
return api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
return api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||||
@@ -135,8 +147,42 @@ function CasesTab({ status, onStatus, search, onSearch }: {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{from !== '' && to !== '' && from > to && (
|
||||||
|
<div className="card card-pad" style={{ marginBottom: 12, fontSize: 12.5, color: 'var(--danger)' }}>
|
||||||
|
«از تاریخ» بعد از «تا تاریخ» است، پس هیچ پروندهای در این بازه نمیافتد.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 96 }} />)}
|
{[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 }}>
|
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||||
{search
|
{search
|
||||||
? `برای «${search}» پروندهای پیدا نشد.`
|
? `برای «${search}» پروندهای پیدا نشد.`
|
||||||
: 'پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
|
: (from !== '' || to !== '')
|
||||||
|
? 'در این بازهٔ تاریخ پروندهای باز نشده است.'
|
||||||
|
: 'پروندهای یافت نشد. پرونده وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<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)' }}>
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||||
<span>{c.service.name}</span>
|
<span>{c.service.name}</span>
|
||||||
<span style={{ direction: 'ltr' }}>{c.patient.mobile}</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.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ single-session again. Idempotent: deleting a service that has no protocol still
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه |
|
| `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه |
|
||||||
| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس |
|
| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس |
|
||||||
|
| `from` | `YYYY-MM-DD` میلادی — پروندههایی که از ابتدای این روز به بعد باز شدهاند |
|
||||||
|
| `to` | `YYYY-MM-DD` میلادی — تا انتهای این روز |
|
||||||
|
|
||||||
|
بازه روی `opened_at` است نه سررسید جلسه. تایمزون تهران (`config/bootstrap_tz.php`).
|
||||||
|
مقدارِ بدفرم بیصدا نادیده گرفته میشود، نه خطا — فیلتر است نه ورودی فرم.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,10 +52,34 @@ class TreatmentCaseController extends BaseController
|
|||||||
|
|
||||||
return $this->success(array_map(
|
return $this->success(array_map(
|
||||||
static fn (TreatmentCase $c): array => $c->toArray(),
|
static fn (TreatmentCase $c): array => $c->toArray(),
|
||||||
$this->cases->findForTenant($entityType, $entityId, $status, $q !== '' ? $q : null),
|
$this->cases->findForTenant(
|
||||||
|
$entityType,
|
||||||
|
$entityId,
|
||||||
|
$status,
|
||||||
|
$q !== '' ? $q : null,
|
||||||
|
$this->dayBoundary($request->query->get('from'), '00:00:00'),
|
||||||
|
$this->dayBoundary($request->query->get('to'), '23:59:59'),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `YYYY-MM-DD` میلادی → ثانیهٔ ابتدای/انتهای همان روز.
|
||||||
|
*
|
||||||
|
* تایمزون سراسری اپلیکیشن تهران است (`config/bootstrap_tz.php`)، پس همان
|
||||||
|
* `strtotime` که بقیهٔ فیلترهای تاریخِ نوبتها استفاده میکنند اینجا هم درست است.
|
||||||
|
*/
|
||||||
|
private function dayBoundary(mixed $value, string $time): ?int
|
||||||
|
{
|
||||||
|
if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ts = strtotime($value . ' ' . $time);
|
||||||
|
|
||||||
|
return $ts === false ? null : $ts;
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
|
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
|
||||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
|||||||
int $entityId,
|
int $entityId,
|
||||||
?string $status = null,
|
?string $status = null,
|
||||||
?string $q = null,
|
?string $q = null,
|
||||||
|
?int $openedFrom = null,
|
||||||
|
?int $openedTo = null,
|
||||||
): array {
|
): array {
|
||||||
$qb = $this->createQueryBuilder('c')
|
$qb = $this->createQueryBuilder('c')
|
||||||
->where('c.entityType = :type')
|
->where('c.entityType = :type')
|
||||||
@@ -61,6 +63,16 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
|||||||
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// بازه روی تاریخِ باز شدن پرونده است، نه سررسید جلسه: سؤالِ این فهرست
|
||||||
|
// «چه پروندههایی در این بازه شروع شدند» است.
|
||||||
|
if ($openedFrom !== null) {
|
||||||
|
$qb->andWhere('c.openedAt >= :from')->setParameter('from', $openedFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($openedTo !== null) {
|
||||||
|
$qb->andWhere('c.openedAt <= :to')->setParameter('to', $openedTo);
|
||||||
|
}
|
||||||
|
|
||||||
if ($q !== null && $q !== '') {
|
if ($q !== null && $q !== '') {
|
||||||
$qb->join('c.patientRecord', 'pr')
|
$qb->join('c.patientRecord', 'pr')
|
||||||
->join('pr.user', 'u')
|
->join('pr.user', 'u')
|
||||||
|
|||||||
@@ -218,4 +218,27 @@ class TreatmentCaseEditTest extends ApiTestCase
|
|||||||
|
|
||||||
self::assertSame($case->getId(), $this->cases()->findForTenant($type, $id, null, $name)[0]->getId());
|
self::assertSame($case->getId(), $this->cases()->findForTenant($type, $id, null, $name)[0]->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** فیلتر بازه روی تاریخِ باز شدن پرونده است، نه سررسید جلسه. */
|
||||||
|
public function testDateRangeFiltersByOpenedAt(): void
|
||||||
|
{
|
||||||
|
[$case, $clinic] = $this->scenario('بازه ' . uniqid());
|
||||||
|
|
||||||
|
$type = 'clinic';
|
||||||
|
$id = (int) $clinic->getId();
|
||||||
|
$openedAt = $case->getOpenedAt();
|
||||||
|
|
||||||
|
// بازهای که همان لحظه را در بر میگیرد
|
||||||
|
self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null, $openedAt - 60, $openedAt + 60));
|
||||||
|
|
||||||
|
// کرانِ پایین بعد از پرونده
|
||||||
|
self::assertSame([], $this->cases()->findForTenant($type, $id, null, null, $openedAt + 60, null));
|
||||||
|
|
||||||
|
// کرانِ بالا قبل از پرونده
|
||||||
|
self::assertSame([], $this->cases()->findForTenant($type, $id, null, null, null, $openedAt - 60));
|
||||||
|
|
||||||
|
// یکطرفه هم باید کار کند
|
||||||
|
self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null, $openedAt - 60, null));
|
||||||
|
self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null, null, $openedAt + 60));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user