diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index 77a39caa..c28c4088 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -1,12 +1,24 @@ import { Bars3Icon, + BanknotesIcon, + BellIcon, + CalendarDaysIcon, + ChatBubbleLeftEllipsisIcon, + ChatBubbleLeftRightIcon, CheckCircleIcon, + ChevronDownIcon, ChevronRightIcon, + ClipboardDocumentCheckIcon, + ClipboardDocumentListIcon, ClockIcon, + CreditCardIcon, + DocumentTextIcon, FolderOpenIcon, FunnelIcon, MagnifyingGlassIcon, + PaperClipIcon, PencilIcon, + PhoneArrowUpRightIcon, PhoneIcon, PlusIcon, Squares2X2Icon, @@ -37,6 +49,7 @@ import { formatRial, } from "../lib/utils"; import type { + PatientAppointment, PatientRecord, PatientSession, ServiceItem, @@ -73,17 +86,76 @@ const PAYMENT_LABELS: Record = { pending: "در انتظار", }; -const PAYMENT_BADGE: Record = { - cash: "green", - card: "blue", - insurance: "purple", - online: "blue", - pending: "amber", +const APPT_STATUS_LABELS: Record = { + pending: "در انتظار", + confirmed: "تایید شده", + completed: "انجام شده", + cancelled_by_doctor: "لغو توسط پزشک", + cancelled_by_user: "لغو توسط بیمار", + no_show: "عدم مراجعه", + expired: "منقضی", }; +const APPT_STATUS_BADGE: Record = { + pending: "amber", + confirmed: "blue", + completed: "green", + cancelled_by_doctor: "red", + cancelled_by_user: "red", + no_show: "gray", + expired: "gray", +}; + +const APPT_ACTIVE_STATUSES = ["pending", "confirmed"]; + const EMPTY_RECORDS: PatientRecord[] = []; const EMPTY_SESSIONS: PatientSession[] = []; +const PATIENT_TABS = [ + { key: "records", label: "پرونده پزشکی", icon: ClipboardDocumentListIcon }, + { key: "attach", label: "ضمیمه", icon: PaperClipIcon }, + { key: "callcenter", label: "کال سنتر", icon: PhoneArrowUpRightIcon }, + { key: "messages", label: "پیام ها", icon: ChatBubbleLeftRightIcon }, + { key: "wallet", label: "کیف پول", icon: BanknotesIcon }, + { key: "payments", label: "پرداخت ها", icon: CreditCardIcon }, + { key: "appointments", label: "نوبت ها", icon: CalendarDaysIcon }, + { key: "info", label: "اطلاعات پرونده", icon: DocumentTextIcon }, + { key: "visits", label: "مراجعات", icon: ClipboardDocumentCheckIcon }, +] as const; + +const PLACEHOLDER_TABS: Record = { + records: "پرونده پزشکی", + attach: "ضمیمه", + callcenter: "کال سنتر", + messages: "پیام‌ها", + wallet: "کیف پول", +}; + +function BannerInfoRow({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: React.ReactNode; +}) { + return ( +
+
+ {icon} +
+ {label} + {value} +
+ ); +} + function getPatientName(record?: PatientRecord | null) { return record?.user_name || "—"; } @@ -116,7 +188,18 @@ function MyPatientsPageInner() { const [page, setPage] = useState(1); const [search, setSearch] = useState(""); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); - const [detailTab, setDetailTab] = useState<"info" | "services" | "sessions">("info"); + const [detailTab, setDetailTab] = useState< + | "info" + | "visits" + | "appointments" + | "payments" + | "records" + | "attach" + | "callcenter" + | "messages" + | "wallet" + >("info"); + const [expandedVisit, setExpandedVisit] = useState(null); const [invoiceUuid, setInvoiceUuid] = useState(null); const [sessionPage, setSessionPage] = useState(1); const [sessionModal, setSessionModal] = useState(false); @@ -207,6 +290,14 @@ function MyPatientsPageInner() { enabled: !!selectedRecord, }); + const { data: appointmentsData, isLoading: appointmentsLoading } = useQuery< + ApiResponse + >({ + queryKey: ["patient-appointments", selectedRecord?.uuid], + queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}/appointments`), + enabled: !!selectedRecord, + }); + const { data: sectionsData } = useQuery>({ queryKey: ["service-sections"], queryFn: () => api.get("/api/v1/service-sections"), @@ -233,11 +324,20 @@ function MyPatientsPageInner() { const records = recordsData?.data ?? EMPTY_RECORDS; const sessions = sessionsData?.data ?? EMPTY_SESSIONS; + const appointments = appointmentsData?.data ?? []; const totalRec = recordsData?.meta?.totalRecords ?? 0; const totalSes = sessionsData?.meta?.totalRecords ?? 0; const selectedPatientName = getPatientName(selectedRecord); const selectedPatientPhone = getPatientPhone(selectedRecord); + // نوبت بعدی: نزدیک‌ترین نوبت آینده که لغو نشده + const nowSec = Math.floor(Date.now() / 1000); + const nextAppointment = appointments + .filter((a) => a.starts_at >= nowSec && APPT_ACTIVE_STATUSES.includes(a.status)) + .sort((a, b) => a.starts_at - b.starts_at)[0]; + // برای بنر: پرونده «تکمیل نشده» اگر مراجعه‌ی تسویه‌نشده دارد + const hasUnpaid = sessions.some((s) => s.is_paid === false || (s.patient_debt_rials ?? 0) > 0); + const sectionOptions = (sectionsData?.data ?? []).map((s) => ({ value: s.uuid, label: s.name, @@ -924,88 +1024,117 @@ function MyPatientsPageInner() { } /> - {/* بنر اطلاعات بیمار */} + {/* بنر اطلاعات بیمار — مطابق فیگما */}
-
- {(selectedPatientName === "—" - ? "?" - : selectedPatientName - ).charAt(0)} -
-
-
- {selectedPatientName} + {/* راست: نام، شماره پرونده، برچسب‌ها */} +
+
+ + {selectedPatientName} + + {hasUnpaid ? ( + + تکمیل نشده + + ) : ( + + تکمیل شده + + )}
-
+ شماره پرونده: {fileNumber(selectedRecord)} +
+
+ + افزودن + + برچسب ها: +
+
+ + {/* وسط: شماره تماس و تاریخ تشکیل پرونده */} +
+ } + label="شماره تماس:" + value={{selectedPatientPhone}} + /> + } + label="تاریخ تشکیل پرونده:" + value={formatDate(selectedRecord.created_at)} + /> +
+ + {/* چپ: نوبت بعدی و دکمه یادداشت */} +
+
+ + + نوبت بعدی:{" "} + + {nextAppointment ? formatDate(nextAppointment.starts_at) : "—"} + + +
+
-
-
-
- {totalSes} -
-
مراجعه
+ یادداشت + +
-
- {([["info", "اطلاعات پرونده"], ["services", "سرویس‌ها"], ["sessions", `تاریخچه مراجعات (${totalSes})`]] as const).map(([key, label]) => ( - - ))} +
+ {PATIENT_TABS.map(({ key, label, icon: Icon }) => { + const active = detailTab === key; + return ( + + ); + })}
{detailTab === "info" && patientProfile && ( @@ -1125,7 +1254,7 @@ function MyPatientsPageInner() {
- {detailTab === "services" && ( + {detailTab === "visits" && ( sessionsLoading ? (
در حال بارگذاری…
) : ( @@ -1135,7 +1264,7 @@ function MyPatientsPageInner() { className="cp-btn-primary" onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)} > - سرویس جدید + مراجعه جدید
{sessions.length === 0 ? (
- هنوز سرویسی برای این بیمار ثبت نشده است. + هنوز مراجعه‌ای برای این بیمار ثبت نشده است.
) : ( -
- {sessions.map((s) => ( - settleSessionMut.mutate(uuid)} - onViewInvoice={(uuid) => setInvoiceUuid(uuid)} - /> - ))} -
+ <> +
+ {sessions.map((s) => ( + setExpandedVisit(s.uuid)} + /> + ))} +
+ {totalSes > 20 && ( +
+ +
+ )} + )} ) )} + {detailTab === "payments" && ( + sessionsLoading ? ( +
در حال بارگذاری…
+ ) : sessions.length === 0 ? ( +
+ پرداختی برای این بیمار ثبت نشده است. +
+ ) : ( +
+ + + + + + + + + + + + + {sessions.map((s) => { + const debt = s.patient_debt_rials ?? 0; + const desc = (s.services ?? []).map((x) => x.service_name).join("، ") || "ویزیت"; + return ( + + + + + + + + + ); + })} + +
تاریخشرحمبلغمانده بدهیوضعیت
{formatDate(s.created_at)}{desc}{formatRial(s.final_price_rials)} 0 ? "var(--danger)" : "var(--success)" }}> + {debt > 0 ? formatRial(debt) : "ندارد"} + + {s.is_paid + ? پرداخت شده + : در انتظار} + + {s.is_paid ? ( + s.invoice_uuid && ( + + ) + ) : ( + + )} +
+
+ ) + )} + + {detailTab === "appointments" && ( + appointmentsLoading ? ( +
در حال بارگذاری…
+ ) : appointments.length === 0 ? ( +
+ نوبتی برای این بیمار ثبت نشده است. +
+ ) : ( +
+ + + + + + + + + + + + {appointments.map((a) => ( + + + + + + + + ))} + +
تاریخ و ساعتپزشکخدمتمبلغوضعیت
{formatDateTime(a.starts_at)}{a.doctor_name || "—"}{a.service_name || "—"}{a.price_rials != null ? formatRial(a.price_rials) : "—"} + + {APPT_STATUS_LABELS[a.status] ?? a.status} + +
+
+ ) + )} + + {PLACEHOLDER_TABS[detailTab] && ( +
+
+ {PLACEHOLDER_TABS[detailTab]} +
+
این بخش به‌زودی اضافه می‌شود.
+
+ )} + + s.uuid === expandedVisit) ?? null} + onClose={() => setExpandedVisit(null)} + settling={settleSessionMut.isPending} + onSettle={(uuid) => settleSessionMut.mutate(uuid)} + onViewInvoice={(uuid) => { setExpandedVisit(null); setInvoiceUuid(uuid); }} + onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }} + /> + setInvoiceUuid(null)}> {!invoice ? (
در حال بارگذاری…
@@ -1187,83 +1437,6 @@ function MyPatientsPageInner() { )}
- {detailTab === "sessions" && ( -
-
- تاریخچه مراجعات -
- {sessionsLoading ? ( -
- در حال بارگذاری... -
- ) : sessions.length === 0 ? ( -
- -
- مراجعه‌ای ثبت نشده است -
- -
- ) : ( - sessions.map((s) => ( - - )) - )} - {totalSes > 20 && ( -
- -
- )} -
- )} - {/* Modal مراجعه جدید */} void; - onViewInvoice: (uuid: string) => void; + onOpen: () => void; }) { const services = session.services ?? []; - const title = services[0]?.service_name || "ویزیت"; - const subtitle = services.length + const summary = services.length ? services.map((s) => s.service_name).join(" - ") : "ویزیت"; - const assistant = services.find((s) => s.staff_name)?.staff_name || null; const debt = session.patient_debt_rials ?? 0; const paid = !!session.is_paid; - const row = ( - label: string, - value: React.ReactNode, - danger = false, - ) => ( -
- {label} - {value} -
- ); - - const divider =
; - return (
- {/* سرِ کارت: سرویس‌ها + وضعیت + منو */} + {/* سرِ کارت: تاریخ + خلاصه + وضعیت */}
-
-
- {title} - {subtitle} -
-
- {paid - ? - : } -
+
+ {formatDate(session.created_at)} + {summary} +
+
+ {paid + ? + : }
-
- {divider} +
-
- {row("انجام دهنده:", session.doctor_name || "—")} - {row("دستیار:", assistant || "—")} - {row("تاریخ:", formatDate(session.created_at))} - {session.notes && ( -
- توضیحات:{" "} - {session.notes} -
- )} +
+ مبلغ + {formatRial(session.final_price_rials)} +
+
+ مانده بدهی + 0 ? "var(--danger)" : "var(--success)" }}> + {debt > 0 ? formatRial(debt) : "ندارد"} +
- {divider} - -
- {row("هزینه:", formatRial(session.final_price_rials))} - {row("مانده بدهی:", debt > 0 ? formatRial(debt) : "ندارد", debt > 0)} -
- -
- {paid ? ( - session.invoice_uuid ? ( - - ) : ( - - ) - ) : ( - - )} +
+ مشاهده سرویس‌ها +
); } -function SessionRow({ +// جزئیات یک مراجعه: سرویس‌های انجام‌شده + خلاصه مالی + پرداخت/ویرایش. +function VisitDetailModal({ session, + onClose, + settling, + onSettle, + onViewInvoice, onEdit, }: { - session: PatientSession; + session: PatientSession | null; + onClose: () => void; + settling: boolean; + onSettle: (uuid: string) => void; + onViewInvoice: (uuid: string) => void; onEdit: (s: PatientSession) => void; }) { - const [open, setOpen] = useState(false); - const badgeColor = PAYMENT_BADGE[session.payment_method] ?? "gray"; + if (!session) return null; + const services = session.services ?? []; + const assistant = services.find((s) => s.staff_name)?.staff_name || null; + const debt = session.patient_debt_rials ?? 0; + const paid = !!session.is_paid; + const baseDiscount = parseFloat(session.base_insurance_discount_percent) || 0; + const suppDiscount = parseFloat(session.supplementary_discount_percent) || 0; + + const metaRow = (label: string, value: React.ReactNode, danger = false) => ( +
+ {label} + {value} +
+ ); return ( -
+ + {paid ? ( + session.invoice_uuid ? ( + + ) : ( + + ) + ) : ( + + )} + + } > -
setOpen(!open)} - > -
-
- - {formatDateTime(session.created_at)} - - - {PAYMENT_LABELS[session.payment_method] ?? - session.payment_method} - - - {formatRial(session.final_price_rials)} - +
+
+ {metaRow("انجام دهنده:", session.doctor_name || "—")} + {metaRow("دستیار:", assistant || "—")} + {metaRow("تاریخ:", formatDateTime(session.created_at))} + {metaRow( + "وضعیت پرداخت:", + paid + ? پرداخت شده + : در انتظار, + )} + {session.notes && ( +
+ توضیحات: {session.notes} +
+ )}
- -
- {open && ( -
-
-
-
- قیمت ویزیت -
-
- {formatRial(session.visit_price_rials)} -
-
-
-
- تخفیف بیمه پایه -
-
- {formatNumber( - parseFloat( - session.base_insurance_discount_percent, - ), - )} - ٪ -
-
-
-
- جمع خدمات -
-
- {formatRial(session.services_total_rials)} -
-
- {session.notes && ( -
+
+ + {/* سرویس‌های انجام‌شده */} +
+
سرویس‌های انجام‌شده
+ {services.length === 0 ? ( +
فقط ویزیت — سرویس اضافه‌ای ثبت نشده است.
+ ) : ( +
+ {services.map((sv) => (
- یادداشت +
+
{sv.service_name}
+
+ {sv.staff_name ? `${sv.staff_name} · ` : ""}تعداد: {formatNumber(sv.quantity)} +
+
+
+ {formatRial(sv.line_total_rials)} +
-
- {session.notes} -
-
- )} -
+ ))} +
+ )}
- )} -
+ +
+ + {/* خلاصه مالی */} +
+ {metaRow("قیمت ویزیت:", formatRial(session.visit_price_rials))} + {baseDiscount > 0 && metaRow("تخفیف بیمه پایه:", `${formatNumber(baseDiscount)}٪`)} + {suppDiscount > 0 && metaRow("تخفیف بیمه تکمیلی:", `${formatNumber(suppDiscount)}٪`)} + {session.services_total_rials > 0 && metaRow("جمع خدمات:", formatRial(session.services_total_rials))} +
+ مبلغ نهایی + {formatRial(session.final_price_rials)} +
+ {metaRow("مانده بدهی:", debt > 0 ? formatRial(debt) : "ندارد", debt > 0)} +
+
+ ); } diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 7a04fdb0..551f4297 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -502,6 +502,17 @@ export interface SessionServiceLine { created_at: number; } +export interface PatientAppointment { + uuid: string; + starts_at: number; + ends_at: number | null; + status: string; + doctor_name: string | null; + service_name: string | null; + price_rials: number | null; + created_at: number; +} + export interface PatientSession { uuid: string; record_uuid: string; diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index 204d8505..84b8daf7 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -128,6 +128,29 @@ class AppointmentRepository extends ServiceEntityRepository return $this->findBy($criteria, ['slotStart' => 'DESC']); } + /** + * نوبت‌های یک بیمار (کاربر) که با پزشک(های) مشخص گرفته شده‌اند — برای نمایش در + * پروندهٔ بیمار. اگر لیست پزشک خالی باشد، آرایهٔ خالی برمی‌گرداند. + * + * @param int[] $doctorIds + * @return Appointment[] + */ + public function findByUserAndDoctorIds(User $user, array $doctorIds): array + { + if ($doctorIds === []) { + return []; + } + + return $this->createQueryBuilder('a') + ->where('a.user = :user') + ->andWhere('a.doctor IN (:doctorIds)') + ->setParameter('user', $user) + ->setParameter('doctorIds', $doctorIds) + ->orderBy('a.slotStart', 'DESC') + ->getQuery() + ->getResult(); + } + /** Whether the user had a confirmed appointment with this doctor within the last $sinceDays days. */ public function hasRecentConfirmed(User $user, Doctor $doctor, int $sinceDays = 30): bool { diff --git a/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php b/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php index 4a852165..24bf2e30 100644 --- a/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php +++ b/src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php @@ -46,6 +46,26 @@ class ClinicDoctorInvitationRepository extends ServiceEntityRepository ->getResult(); } + /** + * شناسهٔ پزشکانی که دعوت پذیرفته‌شده در این کلینیک دارند. + * + * @return int[] + */ + public function acceptedDoctorIdsByClinic(int $clinicId): array + { + $rows = $this->createQueryBuilder('i') + ->select('IDENTITY(i.doctor) AS doctorId') + ->where('i.clinic = :clinicId') + ->andWhere('i.status = :accepted') + ->andWhere('i.doctor IS NOT NULL') + ->setParameter('clinicId', $clinicId) + ->setParameter('accepted', ClinicDoctorInvitation::STATUS_ACCEPTED) + ->getQuery() + ->getScalarResult(); + + return array_map(static fn(array $r) => (int) $r['doctorId'], $rows); + } + public function save(ClinicDoctorInvitation $invitation): void { $em = $this->getEntityManager(); diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 1759ee3f..ff6102c7 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -47,6 +47,8 @@ class PatientController extends BaseController private readonly InvoiceService $invoiceService, private readonly ClaimService $claimService, private readonly InvoiceRepository $invoiceRepo, + private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo, + private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo, private readonly LoggerInterface $logger, ) {} @@ -318,6 +320,37 @@ class PatientController extends BaseController ); } + #[Route('/api/v1/patient/{uuid}/appointments', methods: ['GET'])] + public function appointments(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $this->assertPatientGate($entityType, $entityId); + + $record = $this->recordRepo->findByUuid($uuid); + if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); + } + + // نوبت‌های این بیمار فقط با پزشک(های) همین ارائه‌دهنده نمایش داده می‌شوند تا + // نوبت‌های او با کلینیک‌های دیگر نشت نکند. + $doctorIds = $entityType === 'doctor' + ? [$entityId] + : $this->invitationRepo->acceptedDoctorIdsByClinic($entityId); + + $appointments = $this->appointmentRepo->findByUserAndDoctorIds($record->getUser(), $doctorIds); + + return $this->success(array_map(fn(\App\Appointment\Entity\Appointment $a) => [ + 'uuid' => $a->getUuid(), + 'starts_at' => $a->getSlotStart(), + 'ends_at' => $a->getSlotEnd(), + 'status' => $a->getStatus(), + 'doctor_name' => $a->getDoctor()->getName(), + 'service_name' => null, + 'price_rials' => null, + 'created_at' => $a->getSlotStart(), + ], $appointments)); + } + /** * خروجی session به‌همراه خلاصه‌ی صورتحساب: uuid فاکتور (در صورت وجود) و * مانده‌ی بدهیِ سهم بیمار. اگر session تسویه شده باشد (payment_method != pending)