Files
clinicpro/assets/admin/pages/PatientDetailPage.tsx
T
hamedandClaude Fable 5 165ececab4 feat(appointments): close the three remaining design gaps
1. شارژ کیف پول is now functional end-to-end. New owner-gated
   POST /api/v1/patient/{uuid}/wallet/charge creates a manual credit
   WalletTransaction (computed balance_after); the patient detail's wallet tab
   gains a top-up modal (PriceInput + description) and supports ?tab= deep
   links. The deposit sections of the create drawer, the edit page and the
   replace modal link to it via WalletChargeLink (record resolved by mobile).
2. جایگزینی نوبت now matches appointments-replace.pdf: patient search-or-new,
   بخش/سرویس/پرسنل selects prefilled from the appointment, deposit toggle +
   amount + charge link, read-only original date/time, status pick and notes —
   all through the general PATCH.
3. The confirmed-appointments table is paginated (20/page, client-side so the
   schedule view and doctor-tab derivation keep the whole day), resetting on
   date/doctor/filter changes. The page-local STATUS_META also adopts the
   design labels plus following_up/salon for the schedule cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:26:47 +03:30

628 lines
34 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 { 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 PriceInput from '../components/ui/PriceInput';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
const TABS: { key: TabKey; label: string; icon: React.ElementType }[] = [
{ key: 'services', label: 'سرویس‌ها', icon: ClipboardDocumentCheckIcon },
{ key: 'info', label: 'اطلاعات پرونده', icon: DocumentTextIcon },
{ key: 'appointments', label: 'نوبت‌ها', icon: CalendarDaysIcon },
{ key: 'payments', label: 'پرداخت‌ها', icon: CreditCardIcon },
{ key: 'wallet', label: 'کیف پول', icon: BanknotesIcon },
{ key: 'messages', label: 'پیام‌ها', icon: ChatBubbleLeftRightIcon },
{ key: 'callcenter', label: 'کال سنتر', icon: PhoneArrowUpRightIcon },
{ key: 'attach', label: 'ضمیمه', icon: PaperClipIcon },
{ key: 'records', label: 'پرونده پزشکی', icon: ClipboardDocumentListIcon },
];
const GENDER_LABEL: Record<string, string> = { male: 'مرد', female: 'زن' };
const PAYMENT_STATUS: Record<string, string> = {
pending: 'در انتظار', success: 'موفق', failed: 'ناموفق', canceled: 'لغو شده', refunded: 'بازگشت',
};
function Placeholder({ label }: { label: string }) {
return (
<div style={{ padding: '48px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
محتوای «{label}» به‌زودی تکمیل می‌شود.
</div>
);
}
function InfoRow({ label, value }: { label: string; value?: string | null }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '10px 0', borderTop: '1px solid var(--border)', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{label}</span>
<span style={{ fontSize: 13.5, color: 'var(--text)', fontWeight: 600, direction: 'ltr' }}>{value || '—'}</span>
</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 profile: any = record?.profile ?? {};
const sessionsQ = useQuery<ApiResponse<any[]>>({
queryKey: ['patient-sessions', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions`),
enabled: !!uuid && tab === 'services',
});
const appointmentsQ = useQuery<ApiResponse<any[]>>({
queryKey: ['patient-appointments', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/appointments`),
enabled: !!uuid && tab === 'appointments',
});
const paymentsQ = useQuery<ApiResponse<any[]>>({
queryKey: ['patient-payments', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/payments`),
enabled: !!uuid && tab === 'payments',
});
return (
<div className="fade-in" style={{ maxWidth: 1060, margin: '0 auto' }}>
{/* header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Link to="/admin/patients" className="btn sm ghost" style={{ color: 'var(--text-2)' }}><ChevronRightIcon style={{ width: 16 }} /> بازگشت</Link>
<div style={{ fontSize: 15, fontWeight: 700 }}>{record?.user_name || 'پرونده'}</div>
{record?.record_number && <span className="badge gray" style={{ fontSize: 11 }}>#{record.record_number}</span>}
</div>
<Link to={`/admin/patients/${uuid}/edit`} className="btn sm ghost" style={{ color: 'var(--accent)' }}><PencilIcon style={{ width: 15 }} /> ویرایش</Link>
</div>
{/* 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;
const Icon = t.icon;
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.5, fontWeight: on ? 700 : 500,
color: on ? 'var(--primary)' : 'var(--text-2)',
borderBottom: `2px solid ${on ? 'var(--primary)' : 'transparent'}`,
}}>
<Icon style={{ width: 16 }} /> {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, maxWidth: 620 }}>
<InfoRow label="شماره پرونده" value={record?.record_number} />
<InfoRow label="نام مراجعه‌کننده" value={record?.user_name} />
<InfoRow label="کد ملی" value={record?.user_national_code} />
<InfoRow label="شماره تماس" value={record?.user_mobile} />
<InfoRow label="جنسیت" value={profile.gender ? GENDER_LABEL[profile.gender] : null} />
<InfoRow label="تاریخ تولد" value={profile.date_of_birth ? formatDate(profile.date_of_birth) : null} />
<InfoRow label="نحوه آشنایی" value={profile.referral_source} />
<InfoRow label="آدرس" value={profile.address} />
<InfoRow label="توضیحات" value={profile.description} />
</div>
) : tab === 'services' ? (
<div>
<div style={{ marginBottom: 14 }}>
<Link to={`/admin/patients/${uuid}/session/new`} className="btn primary"><PlusIcon style={{ width: 16 }} /> سرویس جدید</Link>
</div>
<TabList q={sessionsQ} emptyLabel="سرویسی ثبت نشده است"
row={(s) => ({ title: s.section_name || s.name || 'سرویس', meta: s.created_at ? formatDate(s.created_at) : '', badge: s.status })} />
</div>
) : tab === 'appointments' ? (
<TabList q={appointmentsQ} emptyLabel="نوبتی ثبت نشده است"
row={(a) => ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} />
) : tab === 'payments' ? (
<TabList q={paymentsQ} emptyLabel="پرداختی ثبت نشده است"
row={(p) => ({ title: formatRial(p.amount_rials), meta: [p.created_at ? formatDate(p.created_at) : '', p.gateway].filter(Boolean).join(' · '), badge: PAYMENT_STATUS[p.status] || p.status })} />
) : 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} />
)}
</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>
);
}
interface WalletTxn { uuid: string; amount_rials: number; type: string; description?: string | null; balance_after: number; created_at: number }
/** کیف پول — patient wallet balance card + manual top-up + recent-transaction ledger. */
function WalletTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const [chargeOpen, setChargeOpen] = useState(false);
const [amountRials, setAmountRials] = useState(0);
const [description, setDescription] = useState('');
const { data, isLoading } = useQuery<ApiResponse<{ balance_rials: number; recent_transactions: WalletTxn[] }>>({
queryKey: ['patient-wallet', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/wallet`),
enabled: !!uuid,
});
const charge = useMutation({
mutationFn: () => api.post(`/api/v1/patient/${uuid}/wallet/charge`, {
amount_rials: amountRials,
...(description.trim() ? { description: description.trim() } : {}),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] });
toast.success('کیف پول شارژ شد');
setChargeOpen(false); setAmountRials(0); setDescription('');
},
onError: (e: any) => toast.error(e.message || 'خطا در شارژ کیف پول'),
});
if (isLoading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
const balance = data?.data?.balance_rials ?? 0;
const txns = data?.data?.recent_transactions ?? [];
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(balance)}</div>
<button className="btn sm" style={{ marginTop: 12, color: 'var(--accent)', border: '1px solid var(--accent)', background: 'var(--accent-bg)' }}
onClick={() => setChargeOpen(true)}>
<PlusIcon style={{ width: 14 }} /> شارژ کیف پول
</button>
</div>
<Modal open={chargeOpen} title="شارژ کیف پول" onClose={() => setChargeOpen(false)}>
<div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>مبلغ شارژ (تومان)</label>
<div style={{ margin: '6px 0 12px' }}><PriceInput value={amountRials} onChange={setAmountRials} /></div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>توضیحات</label>
<div className="field" style={{ margin: '6px 0 16px' }}>
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="مثلاً: بیعانه نوبت" />
</div>
<button className="btn primary" style={{ width: '100%' }} disabled={amountRials <= 0 || charge.isPending} onClick={() => charge.mutate()}>
ثبت شارژ
</button>
</div>
</Modal>
{txns.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تراکنشی ثبت نشده است</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{txns.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>
);
}
function TabList({ q, emptyLabel, row }: {
q: { data?: ApiResponse<any[]>; isLoading: boolean };
emptyLabel: string;
row: (item: any) => { title: string; meta?: string; badge?: string };
}) {
if (q.isLoading) return <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
const items = q.data?.data ?? [];
if (items.length === 0) return <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>{emptyLabel}</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((item, i) => {
const r = row(item);
return (
<div key={item.uuid ?? i} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '14px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{r.title}</div>
{r.meta && <div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 2 }}>{r.meta}</div>}
</div>
{r.badge && <span className="badge gray" style={{ fontSize: 11 }}>{r.badge}</span>}
</div>
);
})}
</div>
);
}