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 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. */ 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; } 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 // ───────────────────────────────────────────────────────────────────────────── export function ReplaceAppointmentModal({ appointment: a, queryKey, onClose }: { appointment: Appointment; queryKey: unknown[]; onClose: () => void; }) { const qc = useQueryClient(); const [name, setName] = useState(''); const [mobile, setMobile] = useState(''); const [note, setNote] = useState(''); const replace = useMutation({ mutationFn: () => api.patch(`/api/v1/appointment/${a.uuid}`, { patient_name: name.trim(), patient_mobile: mobile.trim(), ...(note.trim() ? { note: note.trim() } : {}), version: a.version, }), onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success('نوبت جایگزین شد'); onClose(); }, onError: (e: any) => toast.error(e.message || 'خطا در جایگزینی نوبت'), }); return (
setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" />
setMobile(e.target.value)} placeholder="شماره تماس" dir="ltr" />