From de52d668c0c23631bf26f9b054bbf2035d099a51 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 16:21:31 +0330 Subject: [PATCH] feat(admin): 'next appointments' tab in the patient file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A patient's file had no view of their multi-session courses: which ones they have, when the remaining sessions fall, or what was recorded in the sessions already done. All of it lived on a tenant-wide page. The tab lists the patient's courses and, per course, a card for every session with its date, its status, and — for finished ones — the areas treated with the device readings a staff member logged. Estimated dates are labelled as such, so a projection is never read as a booking. Each unbooked session carries a button that opens the same NewAppointmentModal used elsewhere, seeded with that session's date, and now binds the resulting appointment to that exact session via a new treatmentSessionUuid prop — a patient can have several open courses, and without it the attachment falls back to guessing from the service. The modal opens in resource mode, not doctor mode: a course's service is booked against the device's calendar, so useDoctorBookingServices returns nothing for the supervising doctor and the picker would render 'no bookable services'. The plan response now carries the course's device for exactly this. A course with no device yet says so instead of offering a button that cannot work. Co-Authored-By: Claude Opus 5 --- .../appointments/NewAppointmentModal.tsx | 7 + .../patient/PatientTreatmentTab.tsx | 266 ++++++++++++++++++ assets/admin/pages/PatientDetailPage.tsx | 6 +- docs/api/treatment.md | 5 + .../Controller/TreatmentCaseController.php | 12 + 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 assets/admin/components/patient/PatientTreatmentTab.tsx diff --git a/assets/admin/components/appointments/NewAppointmentModal.tsx b/assets/admin/components/appointments/NewAppointmentModal.tsx index cbaa6c2c..e3afe56c 100644 --- a/assets/admin/components/appointments/NewAppointmentModal.tsx +++ b/assets/admin/components/appointments/NewAppointmentModal.tsx @@ -41,6 +41,7 @@ export interface BookingResource { */ export default function NewAppointmentModal({ slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null, + treatmentSessionUuid = null, }: { slot: BookingSlot; onClose: () => void; @@ -51,6 +52,11 @@ export default function NewAppointmentModal({ /** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */ clinicUuid?: string | null; resource?: BookingResource | null; + /** + * رزروِ صریحِ یک جلسهٔ درمان. بیمار می‌تواند چند دورهٔ باز داشته باشد، پس بدون این، + * اتصال از روی سرویس حدس زده می‌شود و سرویسِ اشتباه یک پروندهٔ موازی می‌سازد. + */ + treatmentSessionUuid?: string | null; }) { const [mobile, setMobile] = useState(''); const [lookup, setLookup] = useState(null); @@ -158,6 +164,7 @@ export default function NewAppointmentModal({ patient_national_code: effectiveNationalCode, ...(clinicUuid ? { clinic_uuid: clinicUuid } : {}), ...(staffUuid ? { staff_uuid: staffUuid } : {}), + ...(treatmentSessionUuid ? { treatment_session_uuid: treatmentSessionUuid } : {}), ...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}), // منبع: مدت را سرور از سرویس‌های همین منبع می‌سازد، پس ساعت پایان حدس نیست. ...(resource ? { diff --git a/assets/admin/components/patient/PatientTreatmentTab.tsx b/assets/admin/components/patient/PatientTreatmentTab.tsx new file mode 100644 index 00000000..53c79ddc --- /dev/null +++ b/assets/admin/components/patient/PatientTreatmentTab.tsx @@ -0,0 +1,266 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { ChevronDownIcon, ClipboardDocumentListIcon, CpuChipIcon } from '@heroicons/react/24/outline'; +import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; +import StatusBadge from '../ui/StatusBadge'; +import NewAppointmentModal from '../appointments/NewAppointmentModal'; +import { useResourceBookingServices } from '../../hooks/useResourceBookingServices'; +import { useClinicContext } from '../../hooks/useClinicContext'; +import { formatDateTime, formatNumber } from '../../lib/utils'; +import type { TreatmentCaseSummary, SessionAreaRecord } from '../../types'; + +interface PlanSession { + uuid: string; + session_number: number; + total_sessions: number; + status: string; + planned_at: number | null; + is_estimate: boolean; + appointment: { uuid: string; slot_start: number } | null; + performed_by: { uuid: string; name: string } | null; + areas?: SessionAreaRecord[]; +} + +interface PlanResponse { + case: TreatmentCaseSummary; + /** دستگاهِ دوره — همان که جلسهٔ قبل رویش انجام شد. */ + resource: { uuid: string; name: string } | null; + sessions: PlanSession[]; +} + +const CASE_STATUS_LABEL: Record = { + active: 'در جریان', + completed: 'تمام شده', + abandoned: 'رها شده', +}; + +/** `planned_at` (ثانیه) → `YYYY-MM-DD` میلادی، همان چیزی که مودال می‌خواهد. */ +function isoDay(ts: number): string { + const d = new Date(ts * 1000); + + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** + * درمان‌های چندجلسه‌ایِ همین بیمار، با تقویم کل دوره. + * + * تاریخ‌ها از `/plan` می‌آیند و بخشی‌شان تخمینی‌اند — همان‌جا برچسب می‌خورند تا با + * نوبتِ ثبت‌شده اشتباه گرفته نشوند. کارت نوبت نیست؛ دکمه‌اش نوبت می‌سازد. + */ +export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string }) { + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['patient-treatment-cases', recordUuid], + queryFn: () => api.get>( + `/api/v1/treatment-cases?record=${encodeURIComponent(recordUuid)}`, + ), + enabled: recordUuid !== '', + staleTime: 30_000, + }); + + const cases = data?.data ?? []; + + if (isLoading) { + return ( +
+ {[0, 1].map((i) =>
)} +
+ ); + } + + if (isError) { + return ( +
+ خواندن درمان‌های این بیمار ناموفق بود. + +
+ ); + } + + if (cases.length === 0) { + return ( +
+ + + این بیمار درمان چندجلسه‌ای ندارد. +
+ پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود. +
+
+ ); + } + + return ( +
+ {cases.map((c) => )} +
+ ); +} + +function CasePlan({ summary }: { summary: TreatmentCaseSummary }) { + // دورهٔ باز پیش‌فرض باز است و دورهٔ بسته جمع — کارِ پیشِ رو مهم‌تر از سابقه است. + const [open, setOpen] = useState(summary.status === 'active'); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['treatment-case-plan', summary.uuid], + queryFn: () => api.get>(`/api/v1/treatment-case/${summary.uuid}/plan`), + enabled: open, + staleTime: 30_000, + }); + + const sessions = data?.data?.sessions ?? []; + + return ( +
+ + + {open && ( +
+ {isLoading ? ( +
در حال خواندن تقویم دوره…
+ ) : isError ? ( +
+ خواندن تقویم این دوره ناموفق بود. + +
+ ) : ( +
+ {sessions.map((s) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + +function SessionCard({ session: s, summary, resource }: { + session: PlanSession; + summary: TreatmentCaseSummary; + resource: { uuid: string; name: string } | null; +}) { + const [booking, setBooking] = useState(false); + const qc = useQueryClient(); + const clinicUuid = useClinicContext(); + + const supervisor = summary.supervisor; + // سرویس‌ها از تقویم منبع می‌آیند نه از برنامهٔ پزشک — دورهٔ لیزر روی دستگاه رزرو + // می‌شود و `useDoctorBookingServices` برای این پزشک چیزی برنمی‌گرداند. + const { services } = useResourceBookingServices(booking ? resource?.uuid : null); + + const settled = ['done', 'cancelled', 'no_show'].includes(s.status); + const bookable = !settled && s.appointment === null && summary.status === 'active'; + const areas = s.areas ?? []; + + return ( +
+
+ + جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)} + + + + {s.planned_at === null ? '—' : formatDateTime(s.planned_at)} + + {/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */} + {s.is_estimate && ( + تخمینی + )} + {s.performed_by && ( + پرسنل: {s.performed_by.name} + )} +
+ + {/* آنچه واقعاً انجام شد: ناحیه، دستگاه و خوانده‌هایش. */} + {areas.length > 0 && ( +
+ {areas.map((a) => ( +
+ {a.area.name} + + {a.resource && ( + + + {a.resource.name} + + )} + {a.parameters && Object.entries(a.parameters).map(([k, v]) => ( + {k}: {String(v)} + ))} + {a.note && یادداشت: {a.note}} +
+ ))} +
+ )} + + {bookable && ( + /* بدون دستگاه، فرم سرویسی برای انتخاب ندارد؛ دکمهٔ خاموشِ بی‌توضیح بدتر از + نبودنش است، پس دلیلش نوشته می‌شود. */ + resource === null ? ( + + هنوز هیچ جلسه‌ای از این دوره روی دستگاهی انجام نشده — نوبت را از صفحهٔ نوبت‌ها ثبت کنید. + + ) : ( + + ) + )} + + {booking && resource && ( + setBooking(false)} + onSuccess={() => { + qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] }); + qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] }); + }} + /> + )} +
+ ); +} diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 393224eb..e62d599b 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -41,13 +41,15 @@ import { GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS, } from '../lib/patientForm'; import { usePermissions } from '../hooks/usePermissions'; +import PatientTreatmentTab from '../components/patient/PatientTreatmentTab'; -type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records'; +type TabKey = 'services' | 'info' | 'appointments' | 'treatment' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records'; const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [ { key: 'services', label: 'سرویس‌ها', icon: (c) => }, { key: 'info', label: 'اطلاعات پرونده', icon: (c) => }, { key: 'appointments', label: 'نوبت‌ها', icon: (c) => }, + { key: 'treatment', label: 'نوبت‌های بعدی', icon: (c) => }, { key: 'payments', label: 'پرداخت‌ها', icon: (c) => }, { key: 'wallet', label: 'کیف پول', icon: (c) => }, { key: 'notes', label: 'یادداشت‌ها', icon: (c) => }, @@ -258,6 +260,8 @@ export default function PatientDetailPage() {
) : tab === 'appointments' ? ( + ) : tab === 'treatment' ? ( + ) : tab === 'payments' ? ( ) : tab === 'wallet' ? ( diff --git a/docs/api/treatment.md b/docs/api/treatment.md index dd4e5287..f86873cf 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -296,6 +296,11 @@ single-session again. Idempotent: deleting a service that has no protocol still | `is_estimate` | `false` یعنی به واقعیتی گره خورده، `true` یعنی محاسبهٔ لحظهٔ نمایش | | `areas[]` | نواحی با `parameters` (خوانده‌های دستگاه)، `resource`، `note`، زمان‌ها | +پاسخ یک `resource` هم دارد: دستگاهی که جلسهٔ قبلِ همین دوره رویش انجام شده +(`NextSessionSlotFinder::preferredResource`). فرم ثبت نوبت بدونش کار نمی‌کند — سرویسِ +دوره روی تقویم منبع رزرو می‌شود نه روی برنامهٔ پزشک. `null` یعنی هنوز هیچ جلسه‌ای روی +دستگاهی انجام نشده. + **`planned_at` ذخیره نمی‌شود.** `TreatmentScheduler` فقط سررسید جلسهٔ بعدی را می‌نویسد؛ بقیهٔ زنجیره را `TreatmentPlanProjector` در لحظهٔ خواندن می‌سازد. لنگرِ هر جلسه به‌ترتیب: زمان اتمام، زمان نوبت، `due_at` نوشته‌شده. دوره‌ای که هیچ‌کدام را diff --git a/src/Treatment/Controller/TreatmentCaseController.php b/src/Treatment/Controller/TreatmentCaseController.php index 28b73ae9..f116d641 100644 --- a/src/Treatment/Controller/TreatmentCaseController.php +++ b/src/Treatment/Controller/TreatmentCaseController.php @@ -158,8 +158,20 @@ class TreatmentCaseController extends BaseController { $case = $this->requireCase($user, $uuid); + /** + * دستگاهِ دوره — همان که جلسهٔ قبل رویش انجام شد. + * + * فرم ثبت نوبت بدون آن کار نمی‌کند: سرویسِ دوره روی تقویم منبع رزرو می‌شود نه + * روی برنامهٔ پزشک، پس مودال باید در حالت منبع باز شود. + */ + $resource = $this->slotFinder->preferredResource($case); + return $this->success([ 'case' => $case->toArray(), + 'resource' => $resource === null ? null : [ + 'uuid' => $resource->getUuid(), + 'name' => $resource->getName(), + ], 'sessions' => array_map( static fn (array $row): array => $row['session']->toArray(withAreas: true) + [ 'planned_at' => $row['planned_at'],