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'],