Files
clinicpro/assets/admin/pages/PatientDetailPage.tsx
T
hamedandClaude Opus 4.8 f028d6841a feat: port wallet charge/withdraw modal from tauri to patient admin page
Add manual wallet withdrawal (debit) endpoint mirroring the offline app's
balance guard, and rebuild the patient کیف پول tab around a single
charge/withdraw toggle modal (quick amounts, تومان→ریال conversion,
transaction filters).

Backend:
- POST /api/v1/patient/{uuid}/wallet/withdraw — creates a debit
  WalletTransaction; 422 ERR_WALLET_INSUFFICIENT when amount exceeds balance.
- ErrorCodes: ERR_WALLET_INSUFFICIENT ('موجودی کیف پول کافی نیست').
- docs/api/patient.md updated.

Frontend:
- usePatientWallet hook (balance + charge/withdraw mutations).
- WalletTransactionModal (toggle, quick amounts, UI-only payment fields).
- WalletTab: charge button, همه/واریزی/برداشت filters.

Tests: backend withdraw success/insufficient/non-positive/ownership;
frontend modal + wallet tab interactions.

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

795 lines
41 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 } from '../lib/utils';
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);
// ── فرم «اطلاعات پرونده» (اینلاین، معادل 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: string) => api.patch(`/api/v1/session/${sessionUuid}`, { payment_method: 'cash' }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] }); toast.success('پرداخت ثبت شد'); },
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) => settle.mutate(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)} />
</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: 'برداشت' },
];
/**
* کیف پول — کارت موجودی + مودال شارژ/برداشت (toggle) + فیلتر همه/واریزی/برداشت
* روی دفتر تراکنش‌های اخیر. پورت‌شده از tauri WalletSection + AddTransactionModal.
*/
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, amount_rials, description }: { mode: 'charge' | 'withdraw'; amount_rials: number; description?: string }) => {
const mut = mode === 'charge' ? charge : withdraw;
mut.mutate(
{ amount_rials, ...(description ? { description } : {}) },
{
onSuccess: () => {
toast.success(mode === 'charge' ? 'کیف پول شارژ شد' : 'برداشت از کیف پول انجام شد');
setModalOpen(false);
},
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت تراکنش'),
},
);
};
if (isLoading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
const shown = filter === 'all' ? transactions : transactions.filter((t) => t.type === filter);
return (
<div>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20, marginBottom: 16, maxWidth: 320 }}>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(balanceRials)}</div>
<button className="btn sm" style={{ marginTop: 12, color: '#fff', border: 'none', background: '#5559ce' }}
onClick={() => setModalOpen(true)}>
<PlusIcon style={{ width: 14 }} /> شارژ کیف پول
</button>
</div>
{/* فیلتر تراکنش‌ها: همه / واریزی / برداشت (معادل tauri filters) */}
<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}
/>
{shown.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تراکنشی ثبت نشده است</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{shown.map((t) => {
const credit = t.type === 'credit';
return (
<div key={t.uuid} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '12px 14px' }}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 600 }}>{t.description || (credit ? 'واریز' : 'برداشت')}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(t.created_at)}</div>
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, direction: 'ltr', color: credit ? 'var(--success)' : 'var(--danger)' }}>
{credit ? '+' : ''}{formatRial(t.amount_rials)}
</div>
</div>
);
})}
</div>
)}
</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>
);
}