import { 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, tehranWallClockToUnix, rialToToman, tomanToRial } 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"; import SearchableSelect from "./ui/SearchableSelect"; /** Row actions for the appointments table (Figma عملیات menu). */ type ModalKind = null | "info" | "move" | "transfer" | "replace"; const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time); /** * 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/patients?search=${encodeURIComponent(mobile)}&limit=1`, ); return res?.data?.[0]?.uuid ?? null; } /** * پروندهٔ بیمارِ یک نوبت — اگر هنوز ساخته نشده باشد، از روی خودِ نوبت ساخته می‌شود. * «ثبت سرویس» روی نوبتی که صاحبش هنوز پرونده ندارد نباید بن‌بست باشد؛ ساخت پرونده * روی سرور idempotent است و پروندهٔ موجود را برمی‌گرداند. */ export async function ensureRecordUuid(appointment: { patient_mobile?: string | null; patient_name?: string | null; patient_national_code?: string | null; }): Promise { const mobile = (appointment.patient_mobile ?? "").trim(); if (mobile === "") return null; const found = await findRecordUuid(mobile); if (found) return found; const res: any = await api.post("/api/v1/patient", { mobile, name: (appointment.patient_name ?? "").trim(), ...(appointment.patient_national_code ? { national_code: appointment.patient_national_code } : {}), }); return res?.data?.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 ensureRecordUuid(appointment); if (!recordUuid) { toast.error("این نوبت شماره تماس ندارد؛ ابتدا نوبت را ویرایش کنید"); return; } navigate(`/admin/patients/${recordUuid}/session/new`); } catch (e: any) { toast.error(e?.message || "خطا در یافتن پرونده بیمار"); } }; 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/patients?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 [depositToman, setDepositToman] = useState( rialToToman(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 ? tomanToRial(depositToman) : 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 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" />
)}
({ value: o.uuid, label: o.name ?? "" }))} value={sectionUuid || null} onChange={(v) => { setSectionUuid(v ? String(v) : ""); setItemUuid(""); }} placeholder="انتخاب بخش" isLoading={sectionsQ.isLoading} isClearable height={38} />
({ value: o.uuid, label: o.name ?? "" }))} value={itemUuid || null} onChange={(v) => setItemUuid(v ? String(v) : "")} placeholder="انتخاب زیر بخش" isDisabled={!sectionUuid} isLoading={itemsQ.isLoading} isClearable height={38} />
{depositRequired && ( )}
{depositRequired && (
)} {/* the replacement keeps the original slot — date/time locked */}
({ value: o.uuid, label: o.full_name ?? "" }))} value={staffUuid || null} onChange={(v) => setStaffUuid(v ? String(v) : "")} placeholder="انتخاب..." isLoading={staffQ.isLoading} isClearable height={38} />
({ value: v, label: l }))} value={status || null} onChange={(v) => setStatus((v ? String(v) : "") as Appointment["status"])} placeholder="انتخاب وضعیت" height={38} />