Files
clinicpro/assets/admin/pages/PatientDetailPage.tsx
T
hamedandClaude Opus 4.8 ef97b2b249 feat: unify wallet with real payment methods, full transaction transparency, pay-session-from-wallet
Address wallet feedback: use the clinic's real payment infrastructure,
redesign the tab to match the admin panel, and make every wallet movement
fully auditable.

Backend:
- WalletTransaction: add createdBy (acting user) + createdByName, payment_method,
  reference, status; toArray exposes them (migration Version20260716083939).
- WalletService (Settlement): balance/charge/withdraw + settleSessionFromWallet,
  records actor/method/reason; insufficient balance throws ERR_WALLET_INSUFFICIENT.
- PatientController: charge/withdraw delegate to WalletService and accept
  payment_method/reference; PATCH /session/{uuid} with payment_method=wallet
  debits the patient's final share from the wallet (reference=session:{uuid}).
- docs/api/patient.md updated.

Frontend:
- Wallet modal redesigned to panel style (no gradient); payment method now uses
  the clinic's real bank accounts + POS devices (usePaymentMethods) plus cash.
- Wallet tab: panel balance card + DataTable ledger with columns مبلغ/نوع/روش/
  دلیل/ثبت‌کننده/تاریخ/ساعت/وضعیت + همه/واریزی/برداشت filters.
- Session card «تکمیل پرداخت» opens a payment-method chooser incl. کیف پول.

Tests: backend transparency + session-from-wallet (success/insufficient/cash);
frontend modal (real methods, toman→rials) + wallet tab + settle chooser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:22:37 +03:30

842 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => <TabServices color={c} /> },
{ key: 'info', label: 'اطلاعات پرونده', icon: (c) => <TabInfo color={c} /> },
{ key: 'appointments', label: 'نوبت‌ها', icon: (c) => <TabCalendar color={c} /> },
{ key: 'payments', label: 'پرداخت‌ها', icon: (c) => <TabCard color={c} /> },
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
{ key: 'messages', label: 'پیام‌ها', icon: (c) => <TabSMS color={c} /> },
{ key: 'callcenter', label: 'کال سنتر', icon: (c) => <TabCall color={c} /> },
{ key: 'attach', label: 'ضمیمه', icon: (c) => <TabAttach color={c} /> },
{ key: 'records', label: 'پرونده پزشکی', icon: (c) => <TabBody color={c} /> },
];
function Placeholder({ label }: { label: string }) {
return (
<div style={{ padding: '48px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
محتوای «{label}» به‌زودی تکمیل می‌شود.
</div>
);
}
/** پرونده — 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<TabKey>(
requested && TABS.some((t) => t.key === requested) ? requested : 'services',
);
const { data, isLoading } = useQuery<ApiResponse<PatientRecord>>({
queryKey: ['patient', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}`),
enabled: !!uuid,
});
const record = data?.data;
const qc = useQueryClient();
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
const [settleTarget, setSettleTarget] = useState<string | null>(null);
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینه‌های فرم.
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
const seededProvince = useRef(false);
useEffect(() => {
if (!seededProvince.current && record?.profile) {
setEditProvinceId(record.profile.province_id ?? null);
seededProvince.current = true;
}
}, [record]);
const provincesQ = useQuery<any>({
queryKey: ['provinces'],
queryFn: () => api.get('/api/v1/provinces'),
staleTime: 600_000,
});
const citiesQ = useQuery<any>({
queryKey: ['cities', editProvinceId],
queryFn: () => api.get(`/api/v1/cities${editProvinceId ? `?province_id=${editProvinceId}` : ''}`),
staleTime: 300_000,
});
const pricingQ = useQuery<ApiResponse<any>>({
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<ApiResponse<SessionCardData[]>>({
queryKey: ['patient-sessions', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions`),
enabled: !!uuid,
});
const appointmentsQ = useQuery<ApiResponse<any[]>>({
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 (
<div className="fade-in">
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */}
<Breadcrumb name={record?.user_name || 'پرونده'} backTo="/admin/patients" />
<PatientCaseBanner
name={record?.user_name || 'پرونده'}
recordNumber={record?.record_number}
mobile={record?.user_mobile}
createdAt={(record as any)?.created_at}
tags={(record as any)?.tags}
nextAppointment={nextAppointment}
hasDebt={hasDebt}
onAddNote={() => setTab('messages')}
/>
{/* tab bar */}
<div style={{ display: 'flex', gap: 4, overflowX: 'auto', borderBottom: '1px solid var(--border)', marginBottom: 18 }}>
{TABS.map((t) => {
const on = t.key === tab;
return (
<button key={t.key} onClick={() => setTab(t.key)} style={{
display: 'inline-flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap',
padding: '10px 14px', border: 'none', background: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: on ? 700 : 500,
color: on ? '#5559ce' : '#616161',
borderBottom: `2px solid ${on ? '#5559ce' : 'transparent'}`,
}}>
{t.icon(on ? '#5559ce' : '#616161')} {t.label}
</button>
);
})}
</div>
{/* tab content */}
{isLoading ? (
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : tab === 'info' ? (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}>
<PatientRecordInfoForm
defaultValues={profileToFormValues(record?.profile ?? null)}
recordNumber={record?.record_number}
options={{
gender: GENDER_OPTS,
marital: MARITAL_OPTS,
education: EDUCATION_OPTS,
referral: REFERRAL_OPTS,
insurance: baseInsuranceOptions,
supplementary: suppInsuranceOptions,
province: locOpts(provincesQ.data),
city: locOpts(citiesQ.data),
}}
onSubmit={(v) => updateProfileMut.mutate(formValuesToPayload(v))}
isSubmitting={updateProfileMut.isPending}
onProvinceChange={setEditProvinceId}
/>
</div>
) : tab === 'services' ? (
<div>
{/* filter + new-service (tauri ServiceCardsSection header) */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginBottom: 16 }}>
<button type="button" aria-label="فیلتر" className="flex items-center justify-center rounded-[4px] cursor-pointer" style={{ width: 48, height: 48, border: '1px solid var(--border)', background: 'transparent' }}>
<TurnsFilter color="#5559ce" />
</button>
<Link to={`/admin/patients/${uuid}/session/new`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: '#5559ce', color: '#fff', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="#fff" /> سرویس جدید
</Link>
</div>
{sessionsQ.isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : sessions.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>سرویسی ثبت نشده است</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
{sessions.map((s) => (
<SessionServiceCard key={s.uuid} session={s} settling={settle.isPending} onSettle={(u) => setSettleTarget(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
))}
</div>
)}
</div>
) : tab === 'appointments' ? (
<AppointmentsTab uuid={uuid!} q={appointmentsQ} />
) : tab === 'payments' ? (
<PaymentsTab q={sessionsQ} />
) : tab === 'wallet' ? (
<WalletTab uuid={uuid!} />
) : tab === 'callcenter' ? (
<CallCenterTab uuid={uuid!} />
) : tab === 'attach' ? (
<AttachmentsTab uuid={uuid!} />
) : tab === 'records' ? (
<MedicalRecordsTab uuid={uuid!} />
) : tab === 'messages' ? (
<MessagesTab uuid={uuid!} />
) : (
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
)}
<InvoiceSummaryModal invoiceUuid={invoiceUuid} onClose={() => setInvoiceUuid(null)} />
{/* انتخاب روش پرداختِ مراجعه (نقدی / کارت / کیف پول) */}
<Modal open={settleTarget !== null} title="روش پرداخت مراجعه" onClose={() => setSettleTarget(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>روش تسویهٔ این مراجعه را انتخاب کنید:</p>
{[
{ method: 'cash', label: 'نقدی' },
{ method: 'card', label: 'کارت به کارت' },
{ method: 'wallet', label: 'کیف پول بیمار' },
].map((m) => (
<button
key={m.method}
className="btn"
style={{ justifyContent: 'flex-start' }}
disabled={settle.isPending}
onClick={() => settleTarget && settle.mutate({ sessionUuid: settleTarget, method: m.method })}
>
{m.label}
</button>
))}
</div>
</Modal>
</div>
);
}
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<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const { data, isLoading } = useQuery<ApiResponse<Attachment[]>>({
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 (
<div>
<div style={{ marginBottom: 14 }}>
<input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
<button className="btn primary" disabled={uploading} onClick={() => fileRef.current?.click()}>
<ArrowUpTrayIcon style={{ width: 16 }} /> {uploading ? 'در حال آپلود...' : 'آپلود فایل جدید'}
</button>
</div>
{isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>هنوز فایلی ضمیمه نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((a) => (
<div key={a.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<DocumentIcon style={{ width: 22, color: 'var(--primary)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<a href={a.url} target="_blank" rel="noreferrer" style={{ fontWeight: 600, fontSize: 14, color: 'var(--text)', textDecoration: 'none', wordBreak: 'break-all' }}>{a.name}</a>
{a.size ? <div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatBytes(a.size)}</div> : null}
</div>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(a.uuid)}><TrashIcon style={{ width: 16 }} /></button>
</div>
))}
</div>
)}
</div>
);
}
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<MedicalItem | null>(null);
const [title, setTitle] = useState('');
const [date, setDate] = useState('');
const [body, setBody] = useState('');
const { data, isLoading } = useQuery<ApiResponse<MedicalItem[]>>({
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 (
<div>
<div style={{ marginBottom: 14 }}>
<button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</button>
</div>
{isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>معاینه‌ای ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((m) => (
<div key={m.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '14px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>{m.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(m.recorded_at)}</div>
{m.body && <div style={{ fontSize: 13, color: 'var(--text-2)', marginTop: 8, whiteSpace: 'pre-wrap' }}>{m.body}</div>}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => open(m)}><PencilIcon style={{ width: 15 }} /></button>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(m)}><TrashIcon style={{ width: 15 }} /></button>
</div>
</div>
</div>
))}
</div>
)}
<Modal open={modal !== null} onClose={() => setModal(null)} title={modal === 'create' ? 'ثبت معاینه جدید' : 'ویرایش معاینه'}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className="field-label">عنوان *</label>
<div className="field"><input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="مثلاً: معاینه اولیه" autoFocus /></div>
</div>
<div>
<label className="field-label">تاریخ</label>
<PersianDateInput value={date} onChange={setDate} />
</div>
<div>
<label className="field-label">شرح</label>
<div className="field" style={{ height: 'auto' }}><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} placeholder="شرح معاینه" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={!title.trim() || save.isPending} onClick={() => save.mutate()}>ذخیره</button>
<button className="btn" onClick={() => setModal(null)}>انصراف</button>
</div>
</div>
</Modal>
<ConfirmDialog
open={!!delTarget}
title="حذف معاینه"
message={`آیا از حذف «${delTarget?.title}» مطمئن هستید؟`}
confirmLabel="حذف"
onConfirm={() => delTarget && del.mutate(delTarget.uuid)}
onCancel={() => setDelTarget(null)}
loading={del.isPending}
/>
</div>
);
}
interface Message { uuid: string; body: string; channel: string; created_at: number }
const CHANNEL_LABEL: Record<string, string> = { sms: 'پیامک', note: 'یادداشت', call: 'تماس', email: 'ایمیل' };
/** پیام‌ها — patient message/communication log: send + list + delete. */
function MessagesTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const [body, setBody] = useState('');
const { data, isLoading } = useQuery<ApiResponse<Message[]>>({
queryKey: ['patient-messages', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/messages`),
});
const items = data?.data ?? [];
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-messages', uuid] });
const send = useMutation({
mutationFn: () => api.post(`/api/v1/patient/${uuid}/message`, { body, channel: 'sms' }),
onSuccess: () => { invalidate(); setBody(''); toast.success('پیام ثبت شد'); },
onError: (e: any) => toast.error(e.message),
});
const del = useMutation({
mutationFn: (u: string) => api.delete(`/api/v1/patient/message/${u}`),
onSuccess: () => { invalidate(); toast.success('پیام حذف شد'); },
onError: (e: any) => toast.error(e.message),
});
return (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div className="field" style={{ flex: 1 }}><input value={body} onChange={(e) => setBody(e.target.value)} placeholder="متن پیام..." /></div>
<button className="btn primary" disabled={!body.trim() || send.isPending} onClick={() => send.mutate()}>ارسال</button>
</div>
{isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>پیامی ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((m) => (
<div key={m.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13.5, color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{m.body}</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 4, display: 'flex', gap: 8 }}>
<span className="badge gray" style={{ fontSize: 10.5 }}>{CHANNEL_LABEL[m.channel] ?? m.channel}</span>
<span>{formatDate(m.created_at)}</span>
</div>
</div>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(m.uuid)}><TrashIcon style={{ width: 15 }} /></button>
</div>
))}
</div>
)}
</div>
);
}
interface Call { uuid: string; subject: string; summary?: string | null; outcome: string; called_at: number; personnel?: string | null }
/** کال سنتر — patient call log: register a call + filterable history (all / success / missed). */
function CallCenterTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const userName = useAuthStore((s) => s.userName);
const [filter, setFilter] = useState<'all' | 'success' | 'missed'>('all');
const [date, setDate] = useState('');
const [time, setTime] = useState('');
const [subject, setSubject] = useState('');
const [summary, setSummary] = useState('');
const [outcome, setOutcome] = useState<'success' | 'missed'>('success');
const { data, isLoading } = useQuery<ApiResponse<Call[]>>({
queryKey: ['patient-calls', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/calls`),
});
const calls = data?.data ?? [];
const shown = filter === 'all' ? calls : calls.filter((c) => c.outcome === filter);
const successCount = calls.filter((c) => c.outcome === 'success').length;
const missedCount = calls.filter((c) => c.outcome === 'missed').length;
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-calls', uuid] });
const create = useMutation({
mutationFn: () => {
const iso = date ? `${date}T${time || '00:00'}` : null;
const calledAt = iso ? Math.floor(new Date(iso).getTime() / 1000) : Math.floor(Date.now() / 1000);
return api.post(`/api/v1/patient/${uuid}/call`, { subject, summary, outcome, called_at: calledAt, personnel: userName });
},
onSuccess: () => { invalidate(); setDate(''); setTime(''); setSubject(''); setSummary(''); setOutcome('success'); toast.success('تماس ثبت شد'); },
onError: (e: any) => toast.error(e.message),
});
const del = useMutation({
mutationFn: (u: string) => api.delete(`/api/v1/patient/call/${u}`),
onSuccess: () => { invalidate(); toast.success('تماس حذف شد'); },
onError: (e: any) => toast.error(e.message),
});
const chip = (key: 'all' | 'success' | 'missed', label: string) => (
<button onClick={() => setFilter(key)} style={{
padding: '6px 14px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: filter === key ? 700 : 500,
background: filter === key ? 'var(--primary-soft)' : 'var(--surface)',
color: filter === key ? 'var(--primary)' : 'var(--text-2)',
}}>{label}</button>
);
return (
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
{/* register form */}
<div style={{ width: 320, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 18 }}>
<div style={{ fontSize: 14, fontWeight: 700, textAlign: 'center', marginBottom: 16 }}>ثبت تماس جدید</div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>تاریخ تماس</label>
<div style={{ margin: '6px 0 12px' }}><PersianDateInput value={date} onChange={setDate} /></div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ساعت تماس</label>
<div className="field" style={{ margin: '6px 0 12px' }}><input type="time" value={time} onChange={(e) => setTime(e.target.value)} dir="ltr" /></div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>موضوع تماس</label>
<div className="field" style={{ margin: '6px 0 12px' }}><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="موضوع تماس" /></div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>خلاصه تماس</label>
<div className="field" style={{ height: 'auto', margin: '6px 0 12px' }}><textarea value={summary} onChange={(e) => setSummary(e.target.value)} rows={3} placeholder="خلاصه تماس" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<button onClick={() => setOutcome('success')} style={{ flex: 1, padding: '8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, background: outcome === 'success' ? 'var(--success-bg)' : 'var(--surface)', color: outcome === 'success' ? 'var(--success)' : 'var(--text-2)', fontWeight: outcome === 'success' ? 700 : 500 }}>موفق</button>
<button onClick={() => setOutcome('missed')} style={{ flex: 1, padding: '8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, background: outcome === 'missed' ? 'var(--danger-bg)' : 'var(--surface)', color: outcome === 'missed' ? 'var(--danger)' : 'var(--text-2)', fontWeight: outcome === 'missed' ? 700 : 500 }}>بی‌پاسخ</button>
</div>
<button className="btn primary" style={{ width: '100%' }} disabled={!subject.trim() || create.isPending} onClick={() => create.mutate()}><PlusIcon style={{ width: 16 }} /> ثبت تماس</button>
</div>
{/* history */}
<div style={{ flex: 1, minWidth: 320 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', gap: 6 }}>
{chip('all', 'همه')}
{chip('success', `تماس‌های موفق (${successCount})`)}
{chip('missed', `بی‌پاسخ (${missedCount})`)}
</div>
<div style={{ fontSize: 14, fontWeight: 700 }}>تاریخچه تماس‌ها</div>
</div>
{isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : shown.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تماسی ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{shown.map((c) => {
const ok = c.outcome === 'success';
return (
<div key={c.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderInlineStart: `4px solid ${ok ? 'var(--success)' : 'var(--danger)'}`, borderRadius: 'var(--r-lg)', padding: '14px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<PhoneArrowUpRightIcon style={{ width: 16, color: ok ? 'var(--success)' : 'var(--danger)' }} />
<span style={{ fontSize: 13.5, fontWeight: 700 }}>{c.subject}</span>
</div>
{c.summary && <div style={{ fontSize: 12.5, color: 'var(--text-2)', marginTop: 4 }}>{c.summary}</div>}
</div>
<div style={{ textAlign: 'end', minWidth: 120 }}>
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDate(c.called_at)}</div>
{c.personnel && <div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 2 }}>{c.personnel}</div>}
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)', marginTop: 4 }} onClick={() => del.mutate(c.uuid)}><TrashIcon style={{ width: 14 }} /></button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
type WalletFilter = 'all' | 'credit' | 'debit';
const WALLET_FILTERS: { key: WalletFilter; label: string }[] = [
{ key: 'all', label: 'همه' },
{ key: 'credit', label: 'واریزی' },
{ key: 'debit', label: 'برداشت' },
];
const METHOD_LABEL: Record<string, string> = {
card: 'کارت به کارت', pos: 'کارت‌خوان', cash: 'نقدی', wallet: 'کیف پول', gateway: 'درگاه اینترنتی',
};
const STATUS_LABEL: Record<string, string> = { confirmed: 'تأیید شده' };
type WalletRow = WalletTxn & { row_no: number };
/**
* کیف پول — کارت موجودی + مودال شارژ/برداشت (روش پرداخت واقعی) + فیلتر
* همه/واریزی/برداشت روی دفترِ کاملِ تراکنش‌ها (DataTable با ستون ثبت‌کننده،
* روش پرداخت، دلیل و وضعیت). طراحی مطابق پنل.
*/
function WalletTab({ uuid }: { uuid: string }) {
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
const [modalOpen, setModalOpen] = useState(false);
const [filter, setFilter] = useState<WalletFilter>('all');
const submitting = charge.isPending || withdraw.isPending;
const handleSubmit = ({ mode, ...body }: WalletModalSubmit) => {
const mut = mode === 'charge' ? charge : withdraw;
mut.mutate(body, {
onSuccess: () => {
toast.success(mode === 'charge' ? 'کیف پول شارژ شد' : 'برداشت از کیف پول انجام شد');
setModalOpen(false);
},
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت تراکنش'),
});
};
const shown = filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
const rows: WalletRow[] = shown.map((t, i) => ({ ...t, row_no: i + 1 }));
const columns: Column<WalletRow>[] = [
{ key: 'row_no', header: 'ردیف', className: 'w-[60px]', render: (r) => formatNumber(r.row_no) },
{
key: 'amount', header: 'مبلغ', render: (r) => (
<span style={{ fontWeight: 700, direction: 'ltr', color: r.type === 'credit' ? 'var(--success)' : 'var(--danger)' }}>
{r.type === 'credit' ? '+' : ''}{formatRial(r.amount_rials)}
</span>
),
},
{
key: 'type', header: 'نوع تراکنش', render: (r) => (
<span className={`badge ${r.type === 'credit' ? 'green' : 'red'}`}>{r.type === 'credit' ? 'واریز' : 'برداشت'}</span>
),
},
{ key: 'method', header: 'روش پرداخت', render: (r) => (r.payment_method ? METHOD_LABEL[r.payment_method] ?? r.payment_method : '—') },
{ key: 'reason', header: 'دلیل', render: (r) => r.description || '—' },
{ key: 'by', header: 'ثبت‌کننده', render: (r) => r.created_by_name || '—' },
{ key: 'date', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
{ key: 'time', header: 'ساعت', render: (r) => formatTime(r.created_at) },
{ key: 'status', header: 'وضعیت', render: (r) => <span className="badge gray">{STATUS_LABEL[r.status ?? ''] ?? 'تأیید شده'}</span> },
];
return (
<div>
{/* موجودی + دکمه تراکنش جدید */}
<div className="card card-pad" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
<div style={{ fontSize: 24, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</div>
</div>
<button className="btn primary" onClick={() => setModalOpen(true)}>
<PlusIcon style={{ width: 16 }} /> شارژ کیف پول
</button>
</div>
{/* فیلتر تراکنش‌ها */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>تراکنش‌ها:</span>
{WALLET_FILTERS.map((f) => {
const on = filter === f.key;
return (
<button key={f.key} onClick={() => setFilter(f.key)} style={{
padding: '6px 14px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: on ? 700 : 500,
background: on ? 'var(--primary-soft)' : 'var(--surface)',
color: on ? 'var(--primary)' : 'var(--text-2)',
}}>{f.label}</button>
);
})}
</div>
<WalletTransactionModal
open={modalOpen}
balanceRials={balanceRials}
submitting={submitting}
onClose={() => setModalOpen(false)}
onSubmit={handleSubmit}
/>
<DataTable<WalletRow>
columns={columns}
data={rows}
loading={isLoading}
emptyMessage="تراکنشی ثبت نشده است"
/>
</div>
);
}
/**
* پرداخت‌ها — settlement history grouped by مراجعه (session), ported from tauri
* PaymentsSection. Reuses the already-fetched sessions; each session is an
* accordion showing its settlement line or the empty message.
*/
function PaymentsTab({ q }: { q: { data?: ApiResponse<any[]>; isLoading: boolean } }) {
const items = (q.data?.data ?? []) as SessionPaymentData[];
// undefined = default (first panel open, matching tauri); null = user closed all.
const [expanded, setExpanded] = useState<string | null | undefined>(undefined);
const openUuid = expanded === undefined ? (items[0]?.uuid ?? null) : expanded;
if (q.isLoading) return <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
if (items.length === 0) return <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>پرداختی ثبت نشده است</div>;
return (
<div>
{items.map((s) => (
<SessionPaymentAccordion
key={s.uuid}
session={s}
expanded={openUuid === s.uuid}
onToggle={() => setExpanded(openUuid === s.uuid ? null : s.uuid)}
/>
))}
</div>
);
}
const APPT_SORT_OPTS = [
{ value: 'newest', label: 'جدیدترین' },
{ value: 'oldest', label: 'قدیمی‌ترین' },
{ value: 'reserved', label: 'رزرو شده' },
{ value: 'done', label: 'انجام شده' },
{ value: 'cancelled', label: 'لغو شده' },
];
const CANCELLED_STATUSES = ['cancelled_by_doctor', 'cancelled_by_user', 'no_show', 'expired'];
/**
* نوبت‌ها — the appointments tab, ported from tauri TurnsSection: a sort/filter
* toolbar + reserve/new buttons, over a grid of AppointmentTurnCard. Sorting and
* filtering are client-side over the already-fetched list (tauri leaves them inert).
*/
function AppointmentsTab({ uuid, q }: {
uuid: string;
q: { data?: ApiResponse<AppointmentCardData[]>; isLoading: boolean };
}) {
const [sort, setSort] = useState('newest');
const items = q.data?.data ?? [];
const queryKey = ['patient-appointments', uuid];
const shown = useMemo(() => {
let list = [...items];
if (sort === 'reserved') list = list.filter((a) => a.status !== 'completed' && !CANCELLED_STATUSES.includes(a.status));
else if (sort === 'done') list = list.filter((a) => a.status === 'completed');
else if (sort === 'cancelled') list = list.filter((a) => CANCELLED_STATUSES.includes(a.status));
list.sort((a, b) => (sort === 'oldest' ? a.starts_at - b.starts_at : b.starts_at - a.starts_at));
return list;
}, [items, sort]);
return (
<div>
{/* toolbar: sort + filter (right of RTL) · reserve/new buttons (left) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 320, maxWidth: '100%' }}>
<SearchableSelect options={APPT_SORT_OPTS} value={sort} onChange={(v) => setSort(String(v ?? 'newest'))} height={48} />
</div>
<button type="button" aria-label="فیلتر" className="flex items-center justify-center rounded-[4px] cursor-pointer" style={{ width: 62, height: 48, border: '1px solid #5559ce', background: 'transparent' }}>
<TurnsFilter color="#5559ce" />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Link to="/admin/appointments/reserve" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, border: '1px solid #5559ce', color: '#5559ce', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="#5559ce" /> نوبت رزرو
</Link>
<Link to="/admin/appointments/new" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: '#5559ce', color: '#fff', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="#fff" /> نوبت جدید
</Link>
</div>
</div>
{q.isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : shown.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>نوبتی ثبت نشده است</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
{shown.map((a) => <AppointmentTurnCard key={a.uuid} appointment={a} queryKey={queryKey} />)}
</div>
)}
</div>
);
}