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 */} +
+
+ +
+
+
+ +
+
+
+ + + + + + + +