From 5d5089244b612e2341f62917fc633b6024fda240 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 18:27:29 +0330 Subject: [PATCH] feat: add mobile-based patient lookup for appointment booking - Implemented a new endpoint `/api/v1/my/appointment/patient-lookup` to search for patients by mobile number before booking an appointment. - Updated the `NewAppointmentModal` component to utilize the new patient lookup feature, allowing for direct booking if the patient is found with a national code. - Enhanced the appointment booking form to handle mobile input normalization and display relevant fields based on the search results. - Added tests for the new patient lookup functionality, ensuring proper behavior for found and not found cases, as well as validation for mobile input. - Updated sidebar tests to reflect changes in the sidebar component structure and functionality. --- .../admin/components/AppointmentActions.tsx | 1337 ++++++++++++----- .../admin/components/layout/Sidebar.test.tsx | 113 +- assets/admin/components/layout/Sidebar.tsx | 71 +- .../pages/AppointmentBookingModal.test.tsx | 85 ++ assets/admin/pages/AppointmentsPage.tsx | 122 +- docs/api/appointment.md | 40 + .../Controller/MyAppointmentsController.php | 34 + tests/Appointment/PatientLookupTest.php | 81 + 8 files changed, 1416 insertions(+), 467 deletions(-) create mode 100644 assets/admin/pages/AppointmentBookingModal.test.tsx create mode 100644 tests/Appointment/PatientLookupTest.php diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx index 3452cb9d..9424409e 100644 --- a/assets/admin/components/AppointmentActions.tsx +++ b/assets/admin/components/AppointmentActions.tsx @@ -1,35 +1,47 @@ -import React, { useEffect, useRef, useState } from 'react'; -import ReactDOM from 'react-dom'; -import { useNavigate } from 'react-router-dom'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { - EllipsisHorizontalIcon, PencilIcon, PlusIcon, EyeIcon, - ArrowsRightLeftIcon, ArrowDownOnSquareIcon, ArrowPathIcon, - ClockIcon, PhoneIcon, TagIcon, UserIcon, WalletIcon, Squares2X2Icon, -} from '@heroicons/react/24/outline'; -import { toast } from 'sonner'; -import { api } from '../lib/api'; -import type { ApiResponse } from '../lib/api'; -import type { Appointment } from '../types'; -import { formatRial } from '../lib/utils'; -import Modal from './ui/Modal'; -import PersianDateInput from './ui/PersianDateInput'; -import PriceInput from './ui/PriceInput'; -import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown'; + ArrowDownOnSquareIcon, + ArrowPathIcon, + ArrowsRightLeftIcon, + ClockIcon, + EllipsisHorizontalIcon, + EyeIcon, + PencilIcon, + PhoneIcon, + PlusIcon, + Squares2X2Icon, + TagIcon, + UserIcon, + WalletIcon, +} from "@heroicons/react/24/outline"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import React, { useEffect, useRef, useState } from "react"; +import ReactDOM from "react-dom"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import type { ApiResponse } from "../lib/api"; +import { api } from "../lib/api"; +import { formatRial } from "../lib/utils"; +import type { Appointment } from "../types"; +import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown"; +import Modal from "./ui/Modal"; +import PersianDateInput from "./ui/PersianDateInput"; +import PriceInput from "./ui/PriceInput"; /** Row actions for the appointments table (Figma عملیات menu). */ -type ModalKind = null | 'info' | 'move' | 'transfer' | 'replace'; +type ModalKind = null | "info" | "move" | "transfer" | "replace"; const toEpoch = (isoDate: string, time: string) => - Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000); + Math.floor(new Date(`${isoDate}T${time || "00:00"}`).getTime() / 1000); /** * Resolve the patient-record uuid behind an appointment via the patient list * search (mobile is unique per user). Returns null when no record exists yet. */ export async function findRecordUuid(mobile: string): Promise { - const res: any = await api.get(`/api/v1/patient?search=${encodeURIComponent(mobile)}&limit=1`); - return res?.data?.[0]?.uuid ?? null; + const res: any = await api.get( + `/api/v1/patient?search=${encodeURIComponent(mobile)}&limit=1`, + ); + return res?.data?.[0]?.uuid ?? null; } /** @@ -37,249 +49,590 @@ export async function findRecordUuid(mobile: string): Promise { * patient's wallet tab, where the manual top-up modal lives. */ export function WalletChargeLink({ mobile }: { mobile?: string }) { - const navigate = useNavigate(); - const go = async () => { - if (!mobile || mobile.trim().length < 10) { toast.error('ابتدا شماره تماس مراجعه کننده را وارد کنید'); return; } - try { - const recordUuid = await findRecordUuid(mobile.trim()); - if (!recordUuid) { toast.error('پرونده‌ای برای این بیمار یافت نشد'); return; } - navigate(`/admin/patients/${recordUuid}?tab=wallet`); - } catch { toast.error('خطا در یافتن پرونده بیمار'); } - }; - return ( - - ); + const navigate = useNavigate(); + const go = async () => { + if (!mobile || mobile.trim().length < 10) { + toast.error("ابتدا شماره تماس مراجعه کننده را وارد کنید"); + return; + } + try { + const recordUuid = await findRecordUuid(mobile.trim()); + if (!recordUuid) { + toast.error("پرونده‌ای برای این بیمار یافت نشد"); + return; + } + navigate(`/admin/patients/${recordUuid}?tab=wallet`); + } catch { + toast.error("خطا در یافتن پرونده بیمار"); + } + }; + return ( + + ); } -export default function AppointmentActionsMenu({ appointment, queryKey }: { - appointment: Appointment; queryKey: unknown[]; +export default function AppointmentActionsMenu({ + appointment, + queryKey, +}: { + appointment: Appointment; + queryKey: unknown[]; }) { - const [open, setOpen] = useState(false); - const [modal, setModal] = useState(null); - const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); - const btnRef = useRef(null); - const menuRef = useRef(null); - const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [modal, setModal] = useState(null); + const [menuPos, setMenuPos] = useState<{ + top: number; + right: number; + } | null>(null); + const btnRef = useRef(null); + const menuRef = useRef(null); + const navigate = useNavigate(); - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - const t = e.target as Node; - if (!btnRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false); + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const t = e.target as Node; + if (!btnRef.current?.contains(t) && !menuRef.current?.contains(t)) + setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const openMenu = () => { + if (!open && btnRef.current) { + const r = btnRef.current.getBoundingClientRect(); + setMenuPos({ + top: r.bottom + 4, + right: window.innerWidth - r.right, + }); + } + setOpen((o) => !o); }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [open]); - const openMenu = () => { - if (!open && btnRef.current) { - const r = btnRef.current.getBoundingClientRect(); - setMenuPos({ top: r.bottom + 4, right: window.innerWidth - r.right }); - } - setOpen(o => !o); - }; + const goToServiceRegistration = async () => { + setOpen(false); + try { + const recordUuid = await findRecordUuid(appointment.patient_mobile); + if (!recordUuid) { + toast.error("پرونده‌ای برای این بیمار یافت نشد"); + return; + } + navigate(`/admin/patients/${recordUuid}/session/new`); + } catch { + toast.error("خطا در یافتن پرونده بیمار"); + } + }; - const goToServiceRegistration = async () => { - setOpen(false); - try { - const recordUuid = await findRecordUuid(appointment.patient_mobile); - if (!recordUuid) { toast.error('پرونده‌ای برای این بیمار یافت نشد'); return; } - navigate(`/admin/patients/${recordUuid}/session/new`); - } catch { toast.error('خطا در یافتن پرونده بیمار'); } - }; + const items: { + label: string; + icon: React.ElementType; + onClick: () => void; + }[] = [ + { + label: "ویرایش", + icon: PencilIcon, + onClick: () => { + setOpen(false); + navigate(`/admin/appointments/${appointment.uuid}/edit`); + }, + }, + { + label: "ثبت سرویس", + icon: PlusIcon, + onClick: goToServiceRegistration, + }, + { + label: "مشاهده", + icon: EyeIcon, + onClick: () => { + setOpen(false); + setModal("info"); + }, + }, + { + label: "جا به جایی نوبت", + icon: ArrowsRightLeftIcon, + onClick: () => { + setOpen(false); + setModal("move"); + }, + }, + { + label: appointment.is_reserve + ? "انتقال به لیست نوبت‌ها" + : "انتقال به لیست رزرو", + icon: ArrowDownOnSquareIcon, + onClick: () => { + setOpen(false); + setModal("transfer"); + }, + }, + { + label: "جایگزینی نوبت", + icon: ArrowPathIcon, + onClick: () => { + setOpen(false); + setModal("replace"); + }, + }, + ]; - const items: { label: string; icon: React.ElementType; onClick: () => void }[] = [ - { label: 'ویرایش', icon: PencilIcon, onClick: () => { setOpen(false); navigate(`/admin/appointments/${appointment.uuid}/edit`); } }, - { label: 'ثبت سرویس', icon: PlusIcon, onClick: goToServiceRegistration }, - { label: 'مشاهده', icon: EyeIcon, onClick: () => { setOpen(false); setModal('info'); } }, - { label: 'جا به جایی نوبت', icon: ArrowsRightLeftIcon, onClick: () => { setOpen(false); setModal('move'); } }, - { label: appointment.is_reserve ? 'انتقال به لیست نوبت‌ها' : 'انتقال به لیست رزرو', icon: ArrowDownOnSquareIcon, onClick: () => { setOpen(false); setModal('transfer'); } }, - { label: 'جایگزینی نوبت', icon: ArrowPathIcon, onClick: () => { setOpen(false); setModal('replace'); } }, - ]; - - return ( - <> - - - {open && menuPos && ReactDOM.createPortal( -
- {items.map(({ label, icon: Icon, onClick }) => ( - - ))} -
, - document.body, - )} - {modal === 'info' && setModal(null)} />} - {modal === 'move' && setModal(null)} />} - {modal === 'transfer' && setModal(null)} />} - {modal === 'replace' && setModal(null)} />} - - ); + {open && + menuPos && + ReactDOM.createPortal( +
+ {items.map(({ label, icon: Icon, onClick }) => ( + + ))} +
, + document.body, + )} + + {modal === "info" && ( + setModal(null)} + /> + )} + {modal === "move" && ( + setModal(null)} + /> + )} + {modal === "transfer" && ( + setModal(null)} + /> + )} + {modal === "replace" && ( + setModal(null)} + /> + )} + + ); } // ───────────────────────────────────────────────────────────────────────────── // مشاهده — appointment info + patient wallet balance (Figma appointments-info) // ───────────────────────────────────────────────────────────────────────────── -function InfoRow({ icon: Icon, label, value, ltr }: { icon: React.ElementType; label: string; value?: string | null; ltr?: boolean }) { - return ( -
- - {label} - - {value || '—'} -
- ); +function InfoRow({ + icon: Icon, + label, + value, + ltr, +}: { + icon: React.ElementType; + label: string; + value?: string | null; + ltr?: boolean; +}) { + return ( +
+ + {label} + + + {value || "—"} + +
+ ); } -export function AppointmentInfoModal({ appointment: a, queryKey, onClose }: { - appointment: Appointment; queryKey: unknown[]; onClose: () => void; +export function AppointmentInfoModal({ + appointment: a, + queryKey, + onClose, +}: { + appointment: Appointment; + queryKey: unknown[]; + onClose: () => void; }) { - const navigate = useNavigate(); + const navigate = useNavigate(); - // record uuid → wallet balance + «مشاهده پرونده» target (both need the record) - const recordQ = useQuery({ - queryKey: ['appt-record', a.patient_mobile], - queryFn: () => findRecordUuid(a.patient_mobile), - }); - const walletQ = useQuery>({ - queryKey: ['appt-wallet', recordQ.data], - queryFn: () => api.get(`/api/v1/patient/${recordQ.data}/wallet`), - enabled: !!recordQ.data, - }); + // record uuid → wallet balance + «مشاهده پرونده» target (both need the record) + const recordQ = useQuery({ + queryKey: ["appt-record", a.patient_mobile], + queryFn: () => findRecordUuid(a.patient_mobile), + }); + const walletQ = useQuery>({ + queryKey: ["appt-wallet", recordQ.data], + queryFn: () => api.get(`/api/v1/patient/${recordQ.data}/wallet`), + enabled: !!recordQ.data, + }); - const durationMin = Math.max(0, Math.round((a.slot_end - a.slot_start) / 60)); + const durationMin = Math.max( + 0, + Math.round((a.slot_end - a.slot_start) / 60), + ); - return ( - -
- - - - - - - + return ( + +
+ + + + + + + -
- -
+
+ +
- -
-
- ); + +
+
+ ); } // ───────────────────────────────────────────────────────────────────────────── // جا به جایی نوبت — pick a new date + start/end time // ───────────────────────────────────────────────────────────────────────────── -export function MoveAppointmentModal({ appointment: a, queryKey, onClose }: { - appointment: Appointment; queryKey: unknown[]; onClose: () => void; +export function MoveAppointmentModal({ + appointment: a, + queryKey, + onClose, +}: { + appointment: Appointment; + queryKey: unknown[]; + onClose: () => void; }) { - const qc = useQueryClient(); - const [date, setDate] = useState(a.appointment_date); - const [start, setStart] = useState(a.appointment_time); - const [end, setEnd] = useState(a.end_time); + const qc = useQueryClient(); + const [date, setDate] = useState(a.appointment_date); + const [start, setStart] = useState(a.appointment_time); + const [end, setEnd] = useState(a.end_time); - const move = useMutation({ - mutationFn: () => api.patch(`/api/v1/appointment/${a.uuid}`, { - slot_start: toEpoch(date, start), slot_end: toEpoch(date, end), version: a.version, - }), - onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success('نوبت جا به جا شد'); onClose(); }, - onError: (e: any) => toast.error(e.message || 'خطا در جا به جایی نوبت'), - }); + const move = useMutation({ + mutationFn: () => + api.patch(`/api/v1/appointment/${a.uuid}`, { + slot_start: toEpoch(date, start), + slot_end: toEpoch(date, end), + version: a.version, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey }); + toast.success("نوبت جا به جا شد"); + onClose(); + }, + onError: (e: any) => toast.error(e.message || "خطا در جا به جایی نوبت"), + }); - return ( - -
- -
-
-
- -
setStart(e.target.value)} dir="ltr" />
-
-
- -
setEnd(e.target.value)} dir="ltr" />
-
-
- -
-
- ); + return ( + +
+ +
+ +
+
+
+ +
+ setStart(e.target.value)} + dir="ltr" + /> +
+
+
+ +
+ setEnd(e.target.value)} + dir="ltr" + /> +
+
+
+ +
+
+ ); } // ───────────────────────────────────────────────────────────────────────────── // انتقال به لیست رزرو (و بازگشت) — flips is_reserve for a chosen day // ───────────────────────────────────────────────────────────────────────────── -export function TransferReserveModal({ appointment: a, queryKey, onClose }: { - appointment: Appointment; queryKey: unknown[]; onClose: () => void; +export function TransferReserveModal({ + appointment: a, + queryKey, + onClose, +}: { + appointment: Appointment; + queryKey: unknown[]; + onClose: () => void; }) { - const qc = useQueryClient(); - const [date, setDate] = useState(a.appointment_date); - const toReserve = !a.is_reserve; + const qc = useQueryClient(); + const [date, setDate] = useState(a.appointment_date); + const toReserve = !a.is_reserve; - const transfer = useMutation({ - mutationFn: () => { - const day = toEpoch(date, '00:00'); - return api.patch(`/api/v1/appointment/${a.uuid}`, toReserve - // reserve entries are day-level: midnight-to-midnight, no slot occupation - ? { is_reserve: true, slot_start: day, slot_end: day, version: a.version } - : { is_reserve: false, slot_start: toEpoch(date, a.appointment_time), slot_end: toEpoch(date, a.end_time), version: a.version }); - }, - onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success(toReserve ? 'به لیست رزرو منتقل شد' : 'به لیست نوبت‌ها منتقل شد'); onClose(); }, - onError: (e: any) => toast.error(e.message || 'خطا در انتقال'), - }); + const transfer = useMutation({ + mutationFn: () => { + const day = toEpoch(date, "00:00"); + return api.patch( + `/api/v1/appointment/${a.uuid}`, + toReserve + ? // reserve entries are day-level: midnight-to-midnight, no slot occupation + { + is_reserve: true, + slot_start: day, + slot_end: day, + version: a.version, + } + : { + is_reserve: false, + slot_start: toEpoch(date, a.appointment_time), + slot_end: toEpoch(date, a.end_time), + version: a.version, + }, + ); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey }); + toast.success( + toReserve + ? "به لیست رزرو منتقل شد" + : "به لیست نوبت‌ها منتقل شد", + ); + onClose(); + }, + onError: (e: any) => toast.error(e.message || "خطا در انتقال"), + }); - return ( - -
-
- ! - {toReserve - ? 'نوبت از لیست نوبت های تایید شده حذف شده و به لیست نوبت های رزرو شده منتقل می شود.' - : 'نوبت از لیست رزرو حذف شده و به لیست نوبت های تایید شده منتقل می شود.'} -
- -
-
- - -
-
-
- ); + return ( + +
+
+ + ! + + {toReserve + ? "نوبت از لیست نوبت ها حذف شده و به لیست نوبت های رزرو شده منتقل می شود." + : "نوبت از لیست رزرو حذف شده و به لیست نوبت ها منتقل می شود."} +
+ +
+ +
+
+ + +
+
+
+ ); } // ───────────────────────────────────────────────────────────────────────────── @@ -288,167 +641,387 @@ export function TransferReserveModal({ appointment: a, queryKey, onClose }: { // locked date/time, پرسنل, وضعیت, توضیحات) // ───────────────────────────────────────────────────────────────────────────── -interface PickerOption { uuid: string; name?: string; full_name?: string } -interface PickedPatient { uuid: string; user_name?: string; user_mobile?: string } +interface PickerOption { + uuid: string; + name?: string; + full_name?: string; +} +interface PickedPatient { + uuid: string; + user_name?: string; + user_mobile?: string; +} -export function ReplaceAppointmentModal({ appointment: a, queryKey, onClose }: { - appointment: Appointment; queryKey: unknown[]; onClose: () => void; +export function ReplaceAppointmentModal({ + appointment: a, + queryKey, + onClose, +}: { + appointment: Appointment; + queryKey: unknown[]; + onClose: () => void; }) { - const qc = useQueryClient(); - - // patient: search an existing record or enter a new person - const [patientSearch, setPatientSearch] = useState(''); - const [picked, setPicked] = useState(null); - const [name, setName] = useState(''); - const [mobile, setMobile] = useState(''); - const patientsQ = useQuery>({ - queryKey: ['replace-patients', patientSearch], - queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`), - enabled: patientSearch.trim().length >= 2, - }); - - // service specs + staff + status - const [sectionUuid, setSectionUuid] = useState(a.service_section?.uuid ?? ''); - const [itemUuid, setItemUuid] = useState(a.service_item?.uuid ?? ''); - const [staffUuid, setStaffUuid] = useState(a.staff?.uuid ?? ''); - const [status, setStatus] = useState(a.status); - const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') }); - const itemsQ = useQuery>({ - queryKey: ['service-items', sectionUuid], - queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), - enabled: !!sectionUuid, - }); - const staffQ = useQuery>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') }); - - // deposit - const [depositRequired, setDepositRequired] = useState(!!a.deposit_required); - const [depositRials, setDepositRials] = useState(a.deposit_amount_rials ?? 0); - const [note, setNote] = useState(''); - - const effectiveName = picked?.user_name || name.trim(); - const effectiveMobile = picked?.user_mobile || mobile.trim(); - - const replace = useMutation({ - mutationFn: () => api.patch(`/api/v1/appointment/${a.uuid}`, { - patient_name: effectiveName, - patient_mobile: effectiveMobile, - service_section_uuid: sectionUuid, - service_item_uuid: itemUuid, - staff_uuid: staffUuid, - deposit_required: depositRequired, - deposit_amount_rials: depositRequired ? depositRials : null, - ...(note.trim() ? { note: note.trim() } : {}), - ...(status !== a.status ? { status } : {}), - version: a.version, - }), - onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success('نوبت جایگزین شد'); onClose(); }, - onError: (e: any) => toast.error(e.message || 'خطا در جایگزینی نوبت'), - }); - - const label = { fontSize: 12.5, color: 'var(--text-3)' } as const; - const sel = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' } as const; - const lockedField = { margin: '6px 0 12px', opacity: 0.6 } as const; - const patients = patientsQ.data?.data ?? []; - - const statusOptions: [string, string][] = [ - ['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'], - ['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'], - ]; - - return ( - -
- -
- { setPicked(null); setPatientSearch(e.target.value); }} - placeholder="جستجوی نام، شماره تماس، شماره پرونده..." /> -
- {!picked && patients.length > 0 && ( -
- {patients.map(p => ( - - ))} -
- )} - {picked === null && ( -
-
setName(e.target.value)} placeholder="نام و نام خانوادگی" />
-
setMobile(e.target.value)} placeholder="شماره تماس" dir="ltr" />
-
- )} - -
-
- - -
-
- - -
-
- -
- - {depositRequired && } -
- {depositRequired && ( -
- -
-
- )} - - {/* the replacement keeps the original slot — date/time locked */} -
-
- -
-
-
- -
-
-
- - - - - - - - -
-