import { Bars3Icon, CheckCircleIcon, ChevronRightIcon, FolderOpenIcon, MagnifyingGlassIcon, PencilIcon, PhoneIcon, PlusIcon, Squares2X2Icon, UserPlusIcon, UserIcon, UsersIcon, EllipsisHorizontalIcon, EyeIcon, } from "@heroicons/react/24/outline"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import React, { useCallback, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { z } from "zod"; import FeatureGate from "../components/ui/FeatureGate"; import Modal from "../components/ui/Modal"; import PageHeader from "../components/ui/PageHeader"; import Pagination from "../components/ui/Pagination"; import SearchableSelect from "../components/ui/SearchableSelect"; import type { ApiResponse, PaginatedResponse } from "../lib/api"; import { api } from "../lib/api"; import { formatDate, formatDateTime, formatNumber, formatRial, } from "../lib/utils"; import type { PatientRecord, PatientSession, ServiceItem, ServiceSection, } from "../types"; const sessionSchema = z.object({ visit_price_rials: z.coerce.number().min(0), base_insurance_discount_percent: z.coerce.number().min(0).max(100), supplementary_discount_percent: z.coerce.number().min(0).max(100), insurance_base_id: z.coerce.number().optional(), insurance_supplementary_id: z.coerce.number().optional(), payment_method: z.enum(["cash", "card", "insurance", "online", "pending"]), notes: z.string().optional(), }); type SessionFormData = z.infer; interface PricingInsurance { insurance_id: number; insurance_name: string; type: string; patient_share_rials: number | null; } interface InsurancePricing { free_visit_price_rials: number; insurances: PricingInsurance[]; } const PAYMENT_LABELS: Record = { cash: "نقدی", card: "کارت", insurance: "بیمه", online: "آنلاین", pending: "در انتظار", }; const PAYMENT_BADGE: Record = { cash: "green", card: "blue", insurance: "purple", online: "blue", pending: "amber", }; const EMPTY_RECORDS: PatientRecord[] = []; const EMPTY_SESSIONS: PatientSession[] = []; function getPatientName(record?: PatientRecord | null) { return record?.user_name || "—"; } function getPatientPhone(record?: PatientRecord | null) { return record?.user_mobile || "—"; } function getPatientNationalCode(record?: PatientRecord | null) { return record?.user_national_code || null; } function calcFinalPrice( visitPrice: number, baseDiscount: number, suppDiscount: number, servicesTotal: number, ) { const afterBase = visitPrice * (1 - baseDiscount / 100); const afterSupp = afterBase * (1 - suppDiscount / 100); return Math.round(afterSupp) + servicesTotal; } function MyPatientsPageInner() { const qc = useQueryClient(); const navigate = useNavigate(); const [selectedRecord, setSelectedRecord] = useState( null, ); const [page, setPage] = useState(1); const [search, setSearch] = useState(""); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [detailTab, setDetailTab] = useState<"info" | "services" | "sessions">("info"); const [invoiceUuid, setInvoiceUuid] = useState(null); const [sessionPage, setSessionPage] = useState(1); const [sessionModal, setSessionModal] = useState(false); const [editSession, setEditSession] = useState(null); const form = useForm({ resolver: zodResolver(sessionSchema), defaultValues: { visit_price_rials: 0, base_insurance_discount_percent: 0, supplementary_discount_percent: 0, payment_method: "cash", }, }); const watchVisit = form.watch("visit_price_rials") ?? 0; const watchBase = form.watch("base_insurance_discount_percent") ?? 0; const watchSupp = form.watch("supplementary_discount_percent") ?? 0; const [selectedServices, setSelectedServices] = useState< { service_item_uuid: string; name: string; price_rials: number }[] >([]); const [sectionUuid, setSectionUuid] = useState(""); const [itemUuid, setItemUuid] = useState(""); const [baseInsuranceId, setBaseInsuranceId] = useState(""); const [suppInsuranceId, setSuppInsuranceId] = useState(""); const [createRecordOpen, setCreateRecordOpen] = useState(false); const [searchMobile, setSearchMobile] = useState(""); const [foundUser, setFoundUser] = useState<{ uuid: string; name: string | null; mobile: string; national_code?: string | null; } | null>(null); const [recordNationalCode, setRecordNationalCode] = useState(""); const [searchError, setSearchError] = useState(""); const [notFound, setNotFound] = useState(false); const [newPatientName, setNewPatientName] = useState(""); const mobileInputRef = useRef(null); const EMPTY_EDIT = { name: "", family: "", national_code: "", gender: "", blood_type: "", marital_status: "", job: "", home_phone: "", work_phone: "", address: "", basic_insurance_id: "", supplementary_insurance_id: "", }; const [editOpen, setEditOpen] = useState(false); const [editForm, setEditForm] = useState>(EMPTY_EDIT); const setEditField = (k: string, v: string) => setEditForm((f) => ({ ...f, [k]: v })); const servicesTotal = selectedServices.reduce( (sum, s) => sum + s.price_rials, 0, ); const finalPrice = calcFinalPrice( Number(watchVisit), Number(watchBase), Number(watchSupp), servicesTotal, ); const limit = 20; const { data: recordsData, isLoading } = useQuery< PaginatedResponse >({ queryKey: ["patients", page, search], queryFn: () => api.get( `/api/v1/patients?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`, ), }); const { data: recordDetail } = useQuery>({ queryKey: ["patient-detail", selectedRecord?.uuid], queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}`), enabled: !!selectedRecord, }); const patientProfile = (recordDetail?.data as PatientRecord | undefined)?.profile ?? null; const { data: sessionsData, isLoading: sessionsLoading } = useQuery< PaginatedResponse >({ queryKey: ["patient-sessions", selectedRecord?.uuid, sessionPage], queryFn: () => api.get( `/api/v1/patient/${selectedRecord!.uuid}/sessions?page=${sessionPage}&limit=20`, ), enabled: !!selectedRecord, }); const { data: sectionsData } = useQuery>({ queryKey: ["service-sections"], queryFn: () => api.get("/api/v1/service-sections"), }); const { data: itemsData } = useQuery>({ queryKey: ["service-items-for-session", sectionUuid], queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), enabled: !!sectionUuid, }); const { data: pricingData } = useQuery>({ queryKey: ["insurance-pricing"], queryFn: () => api.get("/api/v1/insurance-pricing"), enabled: !!selectedRecord, }); const { data: invoiceData } = useQuery>({ queryKey: ["invoice", invoiceUuid], queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`), enabled: !!invoiceUuid, }); const invoice = (invoiceData?.data as any)?.data ?? (invoiceData?.data as any) ?? null; const records = recordsData?.data ?? EMPTY_RECORDS; const sessions = sessionsData?.data ?? EMPTY_SESSIONS; const totalRec = recordsData?.meta?.totalRecords ?? 0; const totalSes = sessionsData?.meta?.totalRecords ?? 0; const selectedPatientName = getPatientName(selectedRecord); const selectedPatientPhone = getPatientPhone(selectedRecord); const sectionOptions = (sectionsData?.data ?? []).map((s) => ({ value: s.uuid, label: s.name, })); const itemOptions = (itemsData?.data ?? []) .filter((i) => i.active) .map((i) => ({ value: i.uuid, label: `${i.name} — ${formatRial(i.price_rials)}`, })); const pricing = (pricingData?.data as InsurancePricing | undefined) ?? undefined; const freeVisitPrice = pricing?.free_visit_price_rials ?? 0; const baseInsuranceOptions = (pricing?.insurances ?? []) .filter((i) => i.type === "basic") .map((i) => ({ value: String(i.insurance_id), label: i.insurance_name })); const suppInsuranceOptions = (pricing?.insurances ?? []) .filter((i) => i.type === "supplementary") .map((i) => ({ value: String(i.insurance_id), label: i.insurance_name })); // درصد تخفیف معادلِ سهم بیمار بر اساس قیمت آزاد. share=null یعنی پوشش ندارد (۰٪). const shareToDiscountPercent = (insuranceId: string): number => { if (!insuranceId || freeVisitPrice <= 0) return 0; const ins = pricing?.insurances.find((i) => String(i.insurance_id) === insuranceId); if (!ins || ins.patient_share_rials == null) return 0; const covered = Math.max(0, freeVisitPrice - ins.patient_share_rials); return Math.round((covered / freeVisitPrice) * 1000) / 10; }; const applyBaseInsurance = (insuranceId: string) => { setBaseInsuranceId(insuranceId); form.setValue("insurance_base_id", insuranceId ? Number(insuranceId) : undefined); if (insuranceId && freeVisitPrice > 0) { form.setValue("visit_price_rials", freeVisitPrice); form.setValue("base_insurance_discount_percent", shareToDiscountPercent(insuranceId)); } }; const applySuppInsurance = (insuranceId: string) => { setSuppInsuranceId(insuranceId); form.setValue("insurance_supplementary_id", insuranceId ? Number(insuranceId) : undefined); if (insuranceId) { form.setValue("supplementary_discount_percent", shareToDiscountPercent(insuranceId)); } }; const openEditInfo = () => { const p = patientProfile; setEditForm({ name: p?.name ?? "", family: p?.family ?? "", national_code: p?.national_code ?? "", gender: p?.gender ?? "", blood_type: p?.blood_type ?? "", marital_status: p?.marital_status ?? "", job: p?.job ?? "", home_phone: p?.home_phone ?? "", work_phone: p?.work_phone ?? "", address: p?.address ?? "", basic_insurance_id: p?.basic_insurance_id != null ? String(p.basic_insurance_id) : "", supplementary_insurance_id: p?.supplementary_insurance_id != null ? String(p.supplementary_insurance_id) : "", }); setEditOpen(true); }; const updatePatientMut = useMutation({ mutationFn: (body: object) => api.patch(`/api/v1/patient/${selectedRecord!.uuid}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ["patient-detail", selectedRecord?.uuid] }); qc.invalidateQueries({ queryKey: ["patients"] }); setEditOpen(false); toast.success("اطلاعات بیمار به‌روزرسانی شد"); }, onError: (e: any) => { toast.error(e?.message || "خطا در به‌روزرسانی اطلاعات بیمار"); }, }); const submitEditInfo = () => { if (!editForm.name.trim()) { toast.error("نام بیمار الزامی است"); return; } updatePatientMut.mutate({ name: editForm.name.trim(), family: editForm.family.trim() || null, national_code: editForm.national_code.trim(), gender: editForm.gender || null, blood_type: editForm.blood_type.trim() || null, marital_status: editForm.marital_status || null, job: editForm.job.trim() || null, home_phone: editForm.home_phone.trim() || null, work_phone: editForm.work_phone.trim() || null, address: editForm.address.trim() || null, basic_insurance_id: editForm.basic_insurance_id ? Number(editForm.basic_insurance_id) : null, supplementary_insurance_id: editForm.supplementary_insurance_id ? Number(editForm.supplementary_insurance_id) : null, }); }; const settleSessionMut = useMutation({ mutationFn: (uuid: string) => api.patch(`/api/v1/session/${uuid}`, { payment_method: "cash" }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["patient-sessions", selectedRecord?.uuid] }); toast.success("پرداخت ثبت شد"); }, onError: (e: any) => toast.error(e?.message || "خطا در ثبت پرداخت"), }); const createSessionMut = useMutation({ mutationFn: (body: object) => api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ["patient-sessions", selectedRecord?.uuid], }); setSessionModal(false); form.reset(); setSelectedServices([]); setBaseInsuranceId(""); setSuppInsuranceId(""); toast.success("مراجعه ثبت شد"); }, onError: (e: any) => toast.error(e.message), }); const updateSessionMut = useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: object }) => api.patch(`/api/v1/session/${uuid}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ["patient-sessions", selectedRecord?.uuid], }); setEditSession(null); toast.success("مراجعه ویرایش شد"); }, onError: (e: any) => toast.error(e.message), }); const searchUserMut = useMutation({ mutationFn: (mobile: string) => api.get( `/api/v1/patient/search-user?mobile=${encodeURIComponent(mobile)}`, ), onSuccess: (res: any) => { setFoundUser(res?.data); setRecordNationalCode(res?.data?.national_code ?? ""); setNotFound(false); setSearchError(""); }, onError: () => { setFoundUser(null); setNotFound(true); setSearchError(""); }, }); const createRecordMut = useMutation({ mutationFn: (body: Record) => api.post("/api/v1/patient", body), onSuccess: () => { qc.invalidateQueries({ queryKey: ["patients"] }); setCreateRecordOpen(false); setSearchMobile(""); setFoundUser(null); setRecordNationalCode(""); setNotFound(false); setNewPatientName(""); setSearchError(""); toast.success("پرونده بیمار ایجاد شد"); }, onError: (e: any) => toast.error(e.message), }); const handleSearchMobile = () => { const digits = searchMobile.replace(/\D/g, ""); if (!/^09\d{9}$/.test(digits)) { setSearchError("شماره موبایل معتبر نیست"); return; } setSearchError(""); setFoundUser(null); setNotFound(false); searchUserMut.mutate(digits); }; const handleCreateRecord = () => { if (foundUser) { createRecordMut.mutate({ user_uuid: foundUser.uuid, national_code: recordNationalCode || undefined, }); } else if (notFound) { createRecordMut.mutate({ mobile: searchMobile.replace(/\D/g, ""), name: newPatientName.trim(), national_code: recordNationalCode || undefined, }); } }; const handleCreateRecordClose = () => { setCreateRecordOpen(false); setSearchMobile(""); setFoundUser(null); setRecordNationalCode(""); setNotFound(false); setNewPatientName(""); setSearchError(""); }; const handleAddService = () => { if (!itemUuid) return; const found = itemsData?.data?.find((i) => i.uuid === itemUuid); if ( !found || selectedServices.some((s) => s.service_item_uuid === found.uuid) ) return; setSelectedServices((p) => [ ...p, { service_item_uuid: found.uuid, name: found.name, price_rials: found.price_rials, }, ]); setItemUuid(""); }; const handleSubmitSession = form.handleSubmit((d) => { createSessionMut.mutate({ ...d, services: selectedServices.map((s) => ({ service_item_uuid: s.service_item_uuid, })), }); }); const handleSearch = useCallback((v: string) => { setSearch(v); setPage(1); }, []); if (!selectedRecord) { return ( <> setCreateRecordOpen(true)} > پرونده جدید } />
handleSearch(e.target.value)} placeholder="جستجو بر اساس نام، شماره موبایل یا کد ملی..." />
{records.length === 0 && !isLoading ? (
{search ? "بیماری یافت نشد" : "هنوز بیماری ثبت نشده است"}
{search ? "عبارت جستجو را تغییر دهید" : "پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد می‌شود"}
) : ( <>
{records.map((r) => { const open = () => { setSelectedRecord(r); setSessionPage(1); }; return viewMode === "grid" ? : ; })}
)} {/* Modal ایجاد پرونده دستی */} } >
بیمار باید قبلاً در سیستم ثبت‌نام کرده باشد. شماره موبایل او را وارد و جستجو کنید.
{ const digits = e.target.value.replace( /\D/g, "", ); setSearchMobile(digits); setFoundUser(null); setSearchError(""); }} onKeyDown={(e) => { if (e.key === "Enter") handleSearchMobile(); }} />
{searchError && ( {searchError} )}
{foundUser && (
{(foundUser.name ?? "؟").charAt(0)}
{foundUser.name ?? "بدون نام"}
{foundUser.mobile}
یافت شد
)} {notFound && (
کاربری با این شماره یافت نشد. برای ثبت بیمار جدید، نام را وارد کنید.
setNewPatientName(e.target.value) } placeholder="مثال: محمد محمدی" autoFocus />
setRecordNationalCode( e.target.value.replace(/\D/g, ""), ) } placeholder="۱۰ رقم" />
)}
); } return ( <> } /> {/* بنر اطلاعات بیمار */}
{(selectedPatientName === "—" ? "?" : selectedPatientName ).charAt(0)}
{selectedPatientName}
{selectedPatientPhone}
{totalSes}
مراجعه
{([["info", "اطلاعات پرونده"], ["services", "سرویس‌ها"], ["sessions", `تاریخچه مراجعات (${totalSes})`]] as const).map(([key, label]) => ( ))}
{detailTab === "info" && patientProfile && (
اطلاعات بیمار
{([ ["نام کامل", patientProfile.full_name], ["کد ملی", patientProfile.national_code], ["جنسیت", patientProfile.gender === "male" ? "مرد" : patientProfile.gender === "female" ? "زن" : patientProfile.gender], ["تاریخ تولد", patientProfile.date_of_birth ? formatDate(patientProfile.date_of_birth) : null], ["گروه خونی", patientProfile.blood_type], ["وضعیت تأهل", patientProfile.marital_status], ["شغل", patientProfile.job], ["موبایل", patientProfile.mobile], ["تلفن منزل", patientProfile.home_phone], ["بیمه پایه", patientProfile.basic_insurance_name], ["بیمه تکمیلی", patientProfile.supplementary_insurance_name], ["آدرس", patientProfile.address], ] as [string, string | null | undefined][]).map(([label, value]) => (
{label}
{value ? value : ثبت نشده}
))}
)} setEditOpen(false)} footer={ <> } >
setEditField("name", e.target.value)} />
setEditField("family", e.target.value)} />
setEditField("national_code", e.target.value.replace(/\D/g, ""))} />
setEditField("blood_type", e.target.value)} />
setEditField("job", e.target.value)} />
setEditField("home_phone", e.target.value)} />
setEditField("work_phone", e.target.value)} />