import { Bars3Icon, CheckCircleIcon, ChevronRightIcon, FolderOpenIcon, MagnifyingGlassIcon, PencilIcon, PhoneIcon, PlusIcon, Squares2X2Icon, UserPlusIcon, UsersIcon, } 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" | "sessions">("info"); 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 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 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 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", "اطلاعات پرونده"], ["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 : ثبت نشده}
))}
)} {detailTab === "sessions" && (
تاریخچه مراجعات
{sessionsLoading ? (
در حال بارگذاری...
) : sessions.length === 0 ? (
مراجعه‌ای ثبت نشده است
) : ( sessions.map((s) => ( )) )} {totalSes > 20 && (
)}
)} {/* Modal مراجعه جدید */} setSessionModal(false)} title="ثبت مراجعه جدید" size="lg" >
بیمه و مبلغ ویزیت
پرداخت و یادداشت