import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useSearchParams, Link } from 'react-router-dom'; import { ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon, PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon, ArrowUpTrayIcon, TrashIcon, DocumentIcon, } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { PatientRecord } from '../types'; import { useAuthStore } from '../stores/authStore'; import { formatDate, formatRial, formatTime, formatNumber } from '../lib/utils'; import DataTable, { type Column } from '../components/ui/DataTable'; import type { WalletTxn } from '../hooks/usePatientWallet'; import type { WalletModalSubmit } from '../components/WalletTransactionModal'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import PersianDateInput from '../components/ui/PersianDateInput'; import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner'; import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard'; import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard'; import SessionPaymentAccordion, { type SessionPaymentData } from '../components/SessionPaymentAccordion'; import InvoiceSummaryModal from '../components/InvoiceSummaryModal'; import SearchableSelect from '../components/ui/SearchableSelect'; import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons'; import { TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody, } from '../components/icons/FilesServiceIcons'; import PatientRecordInfoForm from '../components/PatientRecordInfoForm'; import WalletTransactionModal from '../components/WalletTransactionModal'; import { usePatientWallet } from '../hooks/usePatientWallet'; import { profileToFormValues, formValuesToPayload, GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS, } from '../lib/patientForm'; type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records'; const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [ { key: 'services', label: 'سرویس‌ها', icon: (c) => }, { key: 'info', label: 'اطلاعات پرونده', icon: (c) => }, { key: 'appointments', label: 'نوبت‌ها', icon: (c) => }, { key: 'payments', label: 'پرداخت‌ها', icon: (c) => }, { key: 'wallet', label: 'کیف پول', icon: (c) => }, { key: 'messages', label: 'پیام‌ها', icon: (c) => }, { key: 'callcenter', label: 'کال سنتر', icon: (c) => }, { key: 'attach', label: 'ضمیمه', icon: (c) => }, { key: 'records', label: 'پرونده پزشکی', icon: (c) => }, ]; function Placeholder({ label }: { label: string }) { return (
محتوای «{label}» به‌زودی تکمیل می‌شود.
); } /** پرونده — the tabbed patient case-file (Figma "جزئیات پرونده"). */ export default function PatientDetailPage() { const { uuid } = useParams<{ uuid: string }>(); // ?tab=wallet etc. lets other pages (e.g. appointment forms) deep-link a tab const [searchParams] = useSearchParams(); const requested = searchParams.get('tab') as TabKey | null; const [tab, setTab] = useState( requested && TABS.some((t) => t.key === requested) ? requested : 'services', ); const { data, isLoading } = useQuery>({ queryKey: ['patient', uuid], queryFn: () => api.get(`/api/v1/patient/${uuid}`), enabled: !!uuid, }); const record = data?.data; const qc = useQueryClient(); const [invoiceUuid, setInvoiceUuid] = useState(null); const [settleTarget, setSettleTarget] = useState(null); // ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ────────────── // استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینه‌های فرم. const [editProvinceId, setEditProvinceId] = useState(null); const seededProvince = useRef(false); useEffect(() => { if (!seededProvince.current && record?.profile) { setEditProvinceId(record.profile.province_id ?? null); seededProvince.current = true; } }, [record]); const provincesQ = useQuery({ queryKey: ['provinces'], queryFn: () => api.get('/api/v1/provinces'), staleTime: 600_000, }); const citiesQ = useQuery({ queryKey: ['cities', editProvinceId], queryFn: () => api.get(`/api/v1/cities${editProvinceId ? `?province_id=${editProvinceId}` : ''}`), staleTime: 300_000, }); const pricingQ = useQuery>({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), enabled: !!uuid, }); const locOpts = (raw: any) => (raw?.data?.data ?? raw?.data ?? []).map((x: any) => ({ value: Number(x.id), label: x.name })); const baseInsuranceOptions = ((pricingQ.data?.data as any)?.insurances ?? []) .filter((i: any) => i.type === 'basic') .map((i: any) => ({ value: String(i.insurance_id), label: i.insurance_name })); const suppInsuranceOptions = ((pricingQ.data?.data as any)?.insurances ?? []) .filter((i: any) => i.type === 'supplementary') .map((i: any) => ({ value: String(i.insurance_id), label: i.insurance_name })); const updateProfileMut = useMutation({ mutationFn: (body: object) => api.patch(`/api/v1/patient/${uuid}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient', uuid] }); toast.success('اطلاعات بیمار به‌روزرسانی شد'); }, onError: (e: any) => toast.error(e?.message || 'خطا در به‌روزرسانی اطلاعات بیمار'), }); // sessions + appointments load eagerly so the banner (debt / next visit) is ready. const sessionsQ = useQuery>({ queryKey: ['patient-sessions', uuid], queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions`), enabled: !!uuid, }); const appointmentsQ = useQuery>({ queryKey: ['patient-appointments', uuid], queryFn: () => api.get(`/api/v1/patient/${uuid}/appointments`), enabled: !!uuid, }); const sessions = sessionsQ.data?.data ?? []; const hasDebt = sessions.some((s) => !s.is_paid); const nowSec = Math.floor(Date.now() / 1000); const nextAppointment = (appointmentsQ.data?.data ?? []) .filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled')) .sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null; const settle = useMutation({ mutationFn: ({ sessionUuid, method }: { sessionUuid: string; method: string }) => api.patch(`/api/v1/session/${sessionUuid}`, { payment_method: method }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] }); // پرداخت از کیف پول موجودی را کم می‌کند → دفتر کیف پول را هم تازه کن. qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] }); toast.success('پرداخت ثبت شد'); setSettleTarget(null); }, onError: (e: any) => toast.error(e?.message || 'خطا در ثبت پرداخت'), }); return (
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */} setTab('messages')} /> {/* tab bar */}
{TABS.map((t) => { const on = t.key === tab; return ( ); })}
{/* tab content */} {isLoading ? (
در حال بارگذاری...
) : tab === 'info' ? (
updateProfileMut.mutate(formValuesToPayload(v))} isSubmitting={updateProfileMut.isPending} onProvinceChange={setEditProvinceId} />
) : tab === 'services' ? (
{/* filter + new-service (tauri ServiceCardsSection header) */}
سرویس جدید
{sessionsQ.isLoading ? (
در حال بارگذاری...
) : sessions.length === 0 ? (
سرویسی ثبت نشده است
) : (
{sessions.map((s) => ( setSettleTarget(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} /> ))}
)}
) : tab === 'appointments' ? ( ) : tab === 'payments' ? ( ) : tab === 'wallet' ? ( ) : tab === 'callcenter' ? ( ) : tab === 'attach' ? ( ) : tab === 'records' ? ( ) : tab === 'messages' ? ( ) : ( t.key === tab)!.label} /> )} setInvoiceUuid(null)} /> {/* انتخاب روش پرداختِ مراجعه (نقدی / کارت / کیف پول) */} setSettleTarget(null)}>

روش تسویهٔ این مراجعه را انتخاب کنید:

{[ { method: 'cash', label: 'نقدی' }, { method: 'card', label: 'کارت به کارت' }, { method: 'wallet', label: 'کیف پول بیمار' }, ].map((m) => ( ))}
); } interface Attachment { uuid: string; name: string; url: string; mime?: string | null; size?: number | null } const formatBytes = (n?: number | null) => { if (!n) return ''; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; }; /** ضمیمه — patient attachments: upload (raw body), list, delete. */ function AttachmentsTab({ uuid }: { uuid: string }) { const qc = useQueryClient(); const fileRef = useRef(null); const [uploading, setUploading] = useState(false); const { data, isLoading } = useQuery>({ queryKey: ['patient-attachments', uuid], queryFn: () => api.get(`/api/v1/patient/${uuid}/attachments`), }); const items = data?.data ?? []; const del = useMutation({ mutationFn: (attUuid: string) => api.delete(`/api/v1/patient/attachment/${attUuid}`), onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-attachments', uuid] }); toast.success('ضمیمه حذف شد'); }, onError: (e: any) => toast.error(e.message), }); const onFile = async (file: File) => { setUploading(true); try { const token = useAuthStore.getState().token; const res = await fetch(`/api/v1/patient/${uuid}/attachment?name=${encodeURIComponent(file.name)}`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${file.name}"`, ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: file, }); if (!res.ok) throw new Error('خطا در آپلود فایل'); qc.invalidateQueries({ queryKey: ['patient-attachments', uuid] }); toast.success('فایل آپلود شد'); } catch (e: any) { toast.error(e.message); } finally { setUploading(false); if (fileRef.current) fileRef.current.value = ''; } }; return (
e.target.files?.[0] && onFile(e.target.files[0])} />
{isLoading ? (
در حال بارگذاری...
) : items.length === 0 ? (
هنوز فایلی ضمیمه نشده است.
) : (
{items.map((a) => (
{a.name} {a.size ?
{formatBytes(a.size)}
: null}
))}
)}
); } interface MedicalItem { uuid: string; title: string; body?: string | null; recorded_at: number } /** پرونده پزشکی — medical exam entries: list + add/edit modal + delete. */ function MedicalRecordsTab({ uuid }: { uuid: string }) { const qc = useQueryClient(); const [modal, setModal] = useState<'create' | MedicalItem | null>(null); const [delTarget, setDelTarget] = useState(null); const [title, setTitle] = useState(''); const [date, setDate] = useState(''); const [body, setBody] = useState(''); const { data, isLoading } = useQuery>({ queryKey: ['patient-medical', uuid], queryFn: () => api.get(`/api/v1/patient/${uuid}/medical-records`), }); const items = data?.data ?? []; const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-medical', uuid] }); const open = (m?: MedicalItem) => { setTitle(m?.title ?? ''); setBody(m?.body ?? ''); setDate(m?.recorded_at ? new Date(m.recorded_at * 1000).toISOString().slice(0, 10) : ''); setModal(m ?? 'create'); }; const save = useMutation({ mutationFn: () => { const payload = { title, body: body || null, recorded_at: date ? Math.floor(new Date(date).getTime() / 1000) : undefined }; return modal === 'create' ? api.post(`/api/v1/patient/${uuid}/medical-record`, payload) : api.patch(`/api/v1/patient/medical-record/${(modal as MedicalItem).uuid}`, payload); }, onSuccess: () => { invalidate(); setModal(null); toast.success('ذخیره شد'); }, onError: (e: any) => toast.error(e.message), }); const del = useMutation({ mutationFn: (u: string) => api.delete(`/api/v1/patient/medical-record/${u}`), onSuccess: () => { invalidate(); setDelTarget(null); toast.success('حذف شد'); }, onError: (e: any) => { toast.error(e.message); setDelTarget(null); }, }); return (
{isLoading ? (
در حال بارگذاری...
) : items.length === 0 ? (
معاینه‌ای ثبت نشده است.
) : (
{items.map((m) => (
{m.title}
{formatDate(m.recorded_at)}
{m.body &&
{m.body}
}
))}
)} setModal(null)} title={modal === 'create' ? 'ثبت معاینه جدید' : 'ویرایش معاینه'}>
setTitle(e.target.value)} placeholder="مثلاً: معاینه اولیه" autoFocus />