From 73963020e244c2c3c831ba7a00455bd23a7c0b5c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 13:57:14 +0330 Subject: [PATCH] feat(treatment): date-range filter on treatment cases, and time on the start stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../admin/pages/TreatmentCasesPage.test.tsx | 22 +++++++ assets/admin/pages/TreatmentCasesPage.tsx | 59 +++++++++++++++++-- docs/api/treatment.md | 5 ++ .../Controller/TreatmentCaseController.php | 26 +++++++- .../Repository/TreatmentCaseRepository.php | 12 ++++ tests/Treatment/TreatmentCaseEditTest.php | 23 ++++++++ 6 files changed, 141 insertions(+), 6 deletions(-) diff --git a/assets/admin/pages/TreatmentCasesPage.test.tsx b/assets/admin/pages/TreatmentCasesPage.test.tsx index d993a799..f7c71086 100644 --- a/assets/admin/pages/TreatmentCasesPage.test.tsx +++ b/assets/admin/pages/TreatmentCasesPage.test.tsx @@ -102,6 +102,28 @@ describe('صفحهٔ پرونده‌های درمان', () => { expect(screen.queryByText(/پرونده‌ای یافت نشد/)).not.toBeInTheDocument(); }); + it('بازهٔ تاریخ به‌صورت from و to می‌رود', async () => { + mockList([caseRow()]); + + renderWithProviders(); + await screen.findByText('محمد رسولی'); + + // PersianDateInput تریگرش input نیست، پس با نام دسترس‌پذیرش پیدایش می‌کنیم. + fireEvent.click(screen.getByLabelText('شروع از تاریخ')); + + expect(screen.getByLabelText('شروع تا تاریخ')).toBeInTheDocument(); + }); + + it('تاریخ شروع ساعت هم دارد', async () => { + mockList([caseRow({ opened_at: 1_786_000_000 })]); + + renderWithProviders(); + + // formatDateTime ساعت را با «:» می‌آورد؛ formatDate نمی‌آورد. + const start = await screen.findByText(/^شروع:/); + expect(start.textContent).toMatch(/:/); + }); + it('دکمهٔ ویرایش مودال را باز می‌کند', async () => { mockList([caseRow()]); diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx index 57ce75f6..db0645a2 100644 --- a/assets/admin/pages/TreatmentCasesPage.tsx +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -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 = { * را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده». */ 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 })} /> : } @@ -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(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>( @@ -135,8 +147,42 @@ function CasesTab({ status, onStatus, search, onSearch }: { ))} + + {/* بازه روی تاریخِ باز شدن پرونده است — همان چیزی که در کارت زیر «شروع» می‌آید. */} +
+ شروع + {/* دو تاریخ و «تا»ی بینشان یک واحدند: اگر جدا بشکنند، «تا» از فیلدش + می‌افتد و معلوم نیست کران بالا کدام است. */} +
+
+ +
+ تا +
+ +
+
+ {(from !== '' || to !== '') && ( + + )} +
+ {from !== '' && to !== '' && from > to && ( +
+ «از تاریخ» بعد از «تا تاریخ» است، پس هیچ پرونده‌ای در این بازه نمی‌افتد. +
+ )} + {isLoading ? (
{[0, 1].map((i) =>
)} @@ -151,7 +197,9 @@ function CasesTab({ status, onStatus, search, onSearch }: {
{search ? `برای «${search}» پرونده‌ای پیدا نشد.` - : 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'} + : (from !== '' || to !== '') + ? 'در این بازهٔ تاریخ پرونده‌ای باز نشده است.' + : 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
) : (
@@ -191,7 +239,8 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
{c.service.name} {c.patient.mobile} - شروع: {formatDate(c.opened_at)} + {/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */} + شروع: {formatDateTime(c.opened_at)} {c.supervisor && پزشک ناظر: {c.supervisor.name}} {c.areas.length > 0 && نواحی: {c.areas.map((a) => a.name).join('، ')}}
diff --git a/docs/api/treatment.md b/docs/api/treatment.md index 56ff4f5b..3407acff 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -210,6 +210,11 @@ single-session again. Idempotent: deleting a service that has no protocol still |---|---| | `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه | | `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس | +| `from` | `YYYY-MM-DD` میلادی — پرونده‌هایی که از ابتدای این روز به بعد باز شده‌اند | +| `to` | `YYYY-MM-DD` میلادی — تا انتهای این روز | + +بازه روی `opened_at` است نه سررسید جلسه. تایم‌زون تهران (`config/bootstrap_tz.php`). +مقدارِ بدفرم بی‌صدا نادیده گرفته می‌شود، نه خطا — فیلتر است نه ورودی فرم. ```json { diff --git a/src/Treatment/Controller/TreatmentCaseController.php b/src/Treatment/Controller/TreatmentCaseController.php index 58dc879c..2065182f 100644 --- a/src/Treatment/Controller/TreatmentCaseController.php +++ b/src/Treatment/Controller/TreatmentCaseController.php @@ -52,10 +52,34 @@ class TreatmentCaseController extends BaseController return $this->success(array_map( 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'])] public function show(#[CurrentUser] User $user, string $uuid): JsonResponse { diff --git a/src/Treatment/Repository/TreatmentCaseRepository.php b/src/Treatment/Repository/TreatmentCaseRepository.php index 3f6cb2c7..224be829 100644 --- a/src/Treatment/Repository/TreatmentCaseRepository.php +++ b/src/Treatment/Repository/TreatmentCaseRepository.php @@ -49,6 +49,8 @@ class TreatmentCaseRepository extends ServiceEntityRepository int $entityId, ?string $status = null, ?string $q = null, + ?int $openedFrom = null, + ?int $openedTo = null, ): array { $qb = $this->createQueryBuilder('c') ->where('c.entityType = :type') @@ -61,6 +63,16 @@ class TreatmentCaseRepository extends ServiceEntityRepository $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 !== '') { $qb->join('c.patientRecord', 'pr') ->join('pr.user', 'u') diff --git a/tests/Treatment/TreatmentCaseEditTest.php b/tests/Treatment/TreatmentCaseEditTest.php index 6363b634..ba9a0037 100644 --- a/tests/Treatment/TreatmentCaseEditTest.php +++ b/tests/Treatment/TreatmentCaseEditTest.php @@ -218,4 +218,27 @@ class TreatmentCaseEditTest extends ApiTestCase 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)); + } }