From 165ececab4b6289b5da7d5c8b60fc370ecce5253 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 14 Jul 2026 00:26:47 +0330 Subject: [PATCH] feat(appointments): close the three remaining design gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. شارژ کیف پول is now functional end-to-end. New owner-gated POST /api/v1/patient/{uuid}/wallet/charge creates a manual credit WalletTransaction (computed balance_after); the patient detail's wallet tab gains a top-up modal (PriceInput + description) and supports ?tab= deep links. The deposit sections of the create drawer, the edit page and the replace modal link to it via WalletChargeLink (record resolved by mobile). 2. جایگزینی نوبت now matches appointments-replace.pdf: patient search-or-new, بخش/سرویس/پرسنل selects prefilled from the appointment, deposit toggle + amount + charge link, read-only original date/time, status pick and notes — all through the general PATCH. 3. The confirmed-appointments table is paginated (20/page, client-side so the schedule view and doctor-tab derivation keep the whole day), resetting on date/doctor/filter changes. The page-local STATUS_META also adopts the design labels plus following_up/salon for the schedule cards. Co-Authored-By: Claude Fable 5 --- .../components/AppointmentActions.test.tsx | 33 +++- .../admin/components/AppointmentActions.tsx | 165 ++++++++++++++++-- .../components/NewAppointmentDrawer.test.tsx | 7 + .../admin/components/NewAppointmentDrawer.tsx | 12 +- assets/admin/pages/AppointmentEditPage.tsx | 14 +- .../AppointmentsPage.pagination.test.tsx | 51 ++++++ assets/admin/pages/AppointmentsPage.tsx | 46 +++-- assets/admin/pages/PatientDetailPage.test.tsx | 32 +++- assets/admin/pages/PatientDetailPage.tsx | 49 +++++- docs/api/patient.md | 7 + src/Patient/Controller/PatientController.php | 34 ++++ tests/Patient/PatientFinancialsTest.php | 30 +++- 12 files changed, 439 insertions(+), 41 deletions(-) create mode 100644 assets/admin/pages/AppointmentsPage.pagination.test.tsx diff --git a/assets/admin/components/AppointmentActions.test.tsx b/assets/admin/components/AppointmentActions.test.tsx index 253ddb50..0c9cd8a5 100644 --- a/assets/admin/components/AppointmentActions.test.tsx +++ b/assets/admin/components/AppointmentActions.test.tsx @@ -85,15 +85,40 @@ describe('AppointmentActionsMenu (عملیات نوبت)', () => { }))); }); - it('replace modal swaps the patient on the slot', async () => { + it('replace modal swaps the patient and keeps the slot locked', async () => { openMenu(); fireEvent.click(screen.getByText('جایگزینی نوبت')); - expect(await screen.findByPlaceholderText('نام و نام خانوادگی مراجعه کننده')).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'ساغر صابری' } }); + expect(await screen.findByPlaceholderText('نام و نام خانوادگی')).toBeInTheDocument(); + // the original slot is shown read-only + expect(screen.getByDisplayValue('2024-12-31')).toBeDisabled(); + expect(screen.getByDisplayValue('09:00')).toBeDisabled(); + // prefilled from the appointment's current specs + expect((screen.getByLabelText('وضعیت') as HTMLSelectElement).value).toBe('confirmed'); + + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی'), { target: { value: 'ساغر صابری' } }); fireEvent.change(screen.getByPlaceholderText('شماره تماس'), { target: { value: '09356619438' } }); fireEvent.click(screen.getByText('ثبت نوبت')); await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ - patient_name: 'ساغر صابری', patient_mobile: '09356619438', version: 3, + patient_name: 'ساغر صابری', patient_mobile: '09356619438', + service_section_uuid: 's1', service_item_uuid: 'i1', staff_uuid: 'st1', + version: 3, + }))); + }); + + it('replace modal picks an existing patient from the record search', async () => { + get.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [ + { uuid: 'rec9', user_name: 'پریسا همتی', user_mobile: '09120009999' }, + ] }); + return Promise.resolve({ success: true, data: [] }); + }); + openMenu(); + fireEvent.click(screen.getByText('جایگزینی نوبت')); + fireEvent.change(await screen.findByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'پریسا' } }); + fireEvent.click(await screen.findByText('پریسا همتی')); + fireEvent.click(screen.getByText('ثبت نوبت')); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ + patient_name: 'پریسا همتی', patient_mobile: '09120009999', }))); }); }); diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx index bf803ef3..3452cb9d 100644 --- a/assets/admin/components/AppointmentActions.tsx +++ b/assets/admin/components/AppointmentActions.tsx @@ -14,6 +14,7 @@ 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'; /** Row actions for the appointments table (Figma عملیات menu). */ @@ -26,11 +27,32 @@ const toEpoch = (isoDate: string, time: string) => * 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. */ -async function findRecordUuid(mobile: string): Promise { +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; } +/** + * «شارژ کیف پول» accent link (appointment create/edit forms) — deep-links the + * 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 ( + + ); +} + export default function AppointmentActionsMenu({ appointment, queryKey }: { appointment: Appointment; queryKey: unknown[]; }) { @@ -262,44 +284,167 @@ export function TransferReserveModal({ appointment: a, queryKey, onClose }: { // ───────────────────────────────────────────────────────────────────────────── // جایگزینی نوبت — put a different patient into the same slot +// (appointments-replace.pdf: patient search-or-new, بخش/سرویس, deposit, +// locked date/time, پرسنل, وضعیت, توضیحات) // ───────────────────────────────────────────────────────────────────────────── +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; }) { 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: name.trim(), patient_mobile: mobile.trim(), + 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 (
- -
- setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" /> + +
+ { setPicked(null); setPatientSearch(e.target.value); }} + placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
- -
- setMobile(e.target.value)} placeholder="شماره تماس" dir="ltr" /> + {!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 */} +
+
+ +
+
+
+ +
+
+
+ + + + + + + +