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'; /** Row actions for the appointments table (Figma عملیات menu). */ type ModalKind = null | 'info' | 'move' | 'transfer' | 'replace'; const toEpoch = (isoDate: string, time: string) => 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; } /** * «شارژ کیف پول» 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[]; }) { 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); }; 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 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)} />} ); } // ───────────────────────────────────────────────────────────────────────────── // مشاهده — 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 || '—'}
); } export function AppointmentInfoModal({ appointment: a, queryKey, onClose }: { appointment: Appointment; queryKey: unknown[]; onClose: () => void; }) { 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, }); const durationMin = Math.max(0, Math.round((a.slot_end - a.slot_start) / 60)); return (
); } // ───────────────────────────────────────────────────────────────────────────── // جا به جایی نوبت — pick a new date + start/end time // ───────────────────────────────────────────────────────────────────────────── 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 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" />
); } // ───────────────────────────────────────────────────────────────────────────── // انتقال به لیست رزرو (و بازگشت) — flips is_reserve for a chosen day // ───────────────────────────────────────────────────────────────────────────── 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 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 ? 'نوبت از لیست نوبت های تایید شده حذف شده و به لیست نوبت های رزرو شده منتقل می شود.' : 'نوبت از لیست رزرو حذف شده و به لیست نوبت های تایید شده منتقل می شود.'}
); } // ───────────────────────────────────────────────────────────────────────────── // جایگزینی نوبت — 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: 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 */}