Files
clinicpro/assets/admin/pages/PatientDetailPage.tsx
T
hamedandClaude Opus 5 ca9648732d feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

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

1111 lines
57 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, useNavigate } from 'react-router-dom';
import {
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, RectangleStackIcon,
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
} from '@heroicons/react/24/outline';
import { PlusIcon } from '@heroicons/react/24/outline';
import { usePackages, usePatientPackages } from '../hooks/usePackages';
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, formatDateTime, formatRial, formatTime, formatNumber, unixToIso } 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 { useIssueInvoice } from '../hooks/useIssueInvoice';
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
import SessionPaymentAccordion, { type SessionPaymentData } from '../components/SessionPaymentAccordion';
import InvoiceSummaryModal from '../components/InvoiceSummaryModal';
import SessionAuditModal from '../components/session/SessionAuditModal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons';
import {
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, 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';
import { usePermissions } from '../hooks/usePermissions';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'notes' | '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: 'packages', label: 'پکیج‌ها', icon: (c) => <RectangleStackIcon style={{ width: 18, color: c }} /> },
{ key: 'notes', label: 'یادداشت‌ها', icon: (c) => <DocumentTextIcon style={{ width: 18, 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 }>();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canUpdate = can('patients', 'update');
// ?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 nav = useNavigate();
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
const [auditSessionUuid, setAuditSessionUuid] = useState<string | null>(null);
const [sessionFilter, setSessionFilter] = useState<'active' | 'all' | 'archived'>('active');
// «مشاهده فاکتور»: اگر session هنوز فاکتور ندارد، اول صادر می‌شود (idempotent).
const issueInvoiceMut = useIssueInvoice(uuid);
const viewInvoice = (s: SessionCardData) => {
if (s.invoice_uuid) { setInvoiceUuid(s.invoice_uuid); return; }
issueInvoiceMut.mutate(s.uuid, {
onSuccess: (iv) => setInvoiceUuid(iv),
onError: () => toast.error('صدور فاکتور ناموفق بود'),
});
};
const archiveMut = useMutation({
mutationFn: ({ sessionUuid, archived }: { sessionUuid: string; archived: boolean }) =>
api.patch(`/api/v1/session/${sessionUuid}`, { archived }),
onSuccess: (_r, v) => {
qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] });
toast.success(v.archived ? 'مراجعه آرشیو شد' : 'مراجعه از آرشیو خارج شد');
},
onError: (e: any) => toast.error(e?.message || 'خطا در آرشیو'),
});
// ── فرم «اطلاعات پرونده» (اینلاین، معادل 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, sessionFilter],
queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions?filter=${sessionFilter}`),
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;
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('notes')}
/>
{/* 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 ? 'var(--primary)' : 'var(--text-2)',
borderBottom: `2px solid ${on ? 'var(--primary)' : 'transparent'}`,
}}>
{t.icon(on ? 'var(--primary)' : 'var(--text-2)')} {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 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<div className="seg">
<button className={sessionFilter === 'active' ? 'on' : ''} onClick={() => setSessionFilter('active')}>فعال‌ها</button>
<button className={sessionFilter === 'all' ? 'on' : ''} onClick={() => setSessionFilter('all')}>همه</button>
<button className={sessionFilter === 'archived' ? 'on' : ''} onClick={() => setSessionFilter('archived')}>آرشیو</button>
</div>
{canUpdate && (
<Link to={`/admin/patients/${uuid}/session/new`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: 'var(--primary)', color: 'var(--on-primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="var(--on-primary)" /> سرویس جدید
</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} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} onEdit={(sess) => nav(`/admin/patients/${uuid}/session/${sess.uuid}/edit`)} onViewAudit={(sess) => setAuditSessionUuid(sess.uuid)} onArchive={(sess, archived) => archiveMut.mutate({ sessionUuid: sess.uuid, archived })} issuing={issueInvoiceMut.isPending} />
))}
</div>
)}
</div>
) : tab === 'appointments' ? (
<AppointmentsTab uuid={uuid!} q={appointmentsQ} />
) : tab === 'payments' ? (
<PaymentsTab q={sessionsQ} />
) : tab === 'wallet' ? (
<WalletTab uuid={uuid!} />
) : tab === 'packages' ? (
<PackagesTab uuid={uuid!} />
) : tab === 'callcenter' ? (
<CallCenterTab uuid={uuid!} />
) : tab === 'attach' ? (
<AttachmentsTab uuid={uuid!} />
) : tab === 'records' ? (
<MedicalRecordsTab uuid={uuid!} />
) : tab === 'notes' ? (
<NotesTab uuid={uuid!} />
) : (
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
)}
<InvoiceSummaryModal invoiceUuid={invoiceUuid} onClose={() => setInvoiceUuid(null)} />
<SessionAuditModal sessionUuid={auditSessionUuid} onClose={() => setAuditSessionUuid(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 { can } = usePermissions();
const canUpdate = can('patients', 'update');
const canDelete = can('patients', 'delete');
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>
{canUpdate && (
<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>
{canDelete && (
<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 { can } = usePermissions();
const canUpdate = can('patients', 'update');
const canDelete = can('patients', 'delete');
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(unixToIso(m?.recorded_at));
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>
{canUpdate && (
<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 }}>
{canUpdate && (
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => open(m)}><PencilIcon style={{ width: 15 }} /></button>
)}
{canDelete && (
<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 Note { uuid: string; body: string; pinned: boolean; author: string | null; created_at: number; updated_at: number | null }
/** آیکن سنجاق (پین) — پرشده وقتی یادداشت پین است. Heroicons سنجاق ندارد. */
function PinIcon({ filled, color, size = 15 }: { filled?: boolean; color: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill={filled ? color : 'none'} stroke="currentColor" strokeWidth={filled ? 0 : 1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ color }}>
<path d="M9 4h6l-1 5 3 3v2H7v-2l3-3-1-5Z" />
<line x1="12" y1="14" x2="12" y2="21" stroke="currentColor" strokeWidth={1.8} />
</svg>
);
}
type NoteSort = 'newest' | 'oldest';
/**
* یادداشت‌ها — personal, pinnable staff notes on the record (replaces the old
* پیام‌ها tab). Compose box on top, then the list: pinned notes float first
* (accent rail + filled pin), each with author + Jalali date and pin/edit/delete.
*/
function NotesTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const { can } = usePermissions();
const canUpdate = can('patients', 'update');
const canDelete = can('patients', 'delete');
const [body, setBody] = useState('');
const [sort, setSort] = useState<NoteSort>('newest');
const [editTarget, setEditTarget] = useState<Note | null>(null);
const [editBody, setEditBody] = useState('');
const [delTarget, setDelTarget] = useState<Note | null>(null);
const { data, isLoading } = useQuery<ApiResponse<Note[]>>({
queryKey: ['patient-notes', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/notes`),
});
const items = data?.data ?? [];
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-notes', uuid] });
// سرور «پین‌شده‌ها اول، سپس جدیدترین» می‌دهد؛ برای «قدیمی‌ترین» ترتیبِ غیرپین را
// معکوس می‌کنیم ولی پین‌شده‌ها همیشه بالا می‌مانند.
const shown = useMemo(() => {
const pinned = items.filter((n) => n.pinned);
const rest = items.filter((n) => !n.pinned);
const dir = (a: Note, b: Note) => (sort === 'oldest' ? a.created_at - b.created_at : b.created_at - a.created_at);
return [...pinned].sort(dir).concat([...rest].sort(dir));
}, [items, sort]);
const create = useMutation({
mutationFn: () => api.post(`/api/v1/patient/${uuid}/note`, { body: body.trim() }),
onSuccess: () => { invalidate(); setBody(''); toast.success('یادداشت ثبت شد'); },
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت یادداشت'),
});
const update = useMutation({
mutationFn: ({ u, patch }: { u: string; patch: { body?: string; pinned?: boolean } }) =>
api.patch(`/api/v1/patient/note/${u}`, patch),
onSuccess: () => { invalidate(); setEditTarget(null); },
onError: (e: any) => toast.error(e?.message || 'خطا در ویرایش یادداشت'),
});
const del = useMutation({
mutationFn: (u: string) => api.delete(`/api/v1/patient/note/${u}`),
onSuccess: () => { invalidate(); setDelTarget(null); toast.success('یادداشت حذف شد'); },
onError: (e: any) => { toast.error(e?.message || 'خطا در حذف یادداشت'); setDelTarget(null); },
});
const openEdit = (n: Note) => { setEditTarget(n); setEditBody(n.body); };
return (
<div>
{/* افزودن یادداشت جدید */}
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 18, marginBottom: 18 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)', marginBottom: 12 }}>افزودن یادداشت جدید</div>
<div className="field" style={{ height: 'auto', marginBottom: 12 }}>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
placeholder="یادداشت خود را بنویسید..."
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }}
/>
</div>
{canUpdate && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn primary" disabled={!body.trim() || create.isPending} onClick={() => create.mutate()}>
<PlusIcon style={{ width: 16 }} /> ذخیره یادداشت
</button>
</div>
)}
</div>
{/* سرآیند + مرتب‌سازی */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, flexWrap: 'wrap', gap: 10 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-2)' }}>
یادداشت‌های قبلی ({formatNumber(items.length)})
</div>
{items.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>مرتب‌سازی:</span>
{([['newest', 'جدیدترین'], ['oldest', 'قدیمی‌ترین']] as [NoteSort, string][]).map(([key, label]) => {
const on = sort === key;
return (
<button key={key} onClick={() => setSort(key)} style={{
background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
padding: '2px 2px 4px', fontWeight: on ? 700 : 500,
color: on ? 'var(--accent)' : 'var(--text-3)',
borderBottom: `2px solid ${on ? 'var(--accent)' : 'transparent'}`,
}}>{label}</button>
);
})}
</div>
)}
</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: 12 }}>
{shown.map((n) => (
<div key={n.uuid} style={{
background: n.pinned ? 'var(--accent-bg)' : 'var(--surface)',
border: '1px solid var(--border)',
borderInlineStart: `4px solid ${n.pinned ? 'var(--accent)' : 'transparent'}`,
borderRadius: 'var(--r-lg)', padding: '14px 16px',
}}>
<div style={{ fontSize: 13.5, lineHeight: 1.9, color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{n.body}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: 12, color: 'var(--text-3)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<UserIcon style={{ width: 15 }} /> {n.author || 'نامشخص'}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<CalendarDaysIcon style={{ width: 15 }} /> {formatDate(n.created_at)}
</span>
{n.updated_at && <span style={{ fontSize: 11 }}>(ویرایش‌شده)</span>}
</div>
<div style={{ display: 'flex', gap: 6 }}>
{canUpdate && (
<button
className="btn sm ghost"
aria-label={n.pinned ? 'برداشتن پین' : 'پین کردن'}
title={n.pinned ? 'برداشتن پین' : 'پین کردن'}
style={{ color: n.pinned ? 'var(--accent)' : 'var(--text-3)' }}
disabled={update.isPending}
onClick={() => update.mutate({ u: n.uuid, patch: { pinned: !n.pinned } })}
>
<PinIcon filled={n.pinned} color="currentColor" />
</button>
)}
{canUpdate && (
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => openEdit(n)}><PencilIcon style={{ width: 15 }} /></button>
)}
{canDelete && (
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(n)}><TrashIcon style={{ width: 15 }} /></button>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* ویرایش */}
<Modal open={editTarget !== null} onClose={() => setEditTarget(null)} title="ویرایش یادداشت">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field" style={{ height: 'auto' }}>
<textarea value={editBody} onChange={(e) => setEditBody(e.target.value)} rows={4} autoFocus placeholder="متن یادداشت" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={!editBody.trim() || update.isPending} onClick={() => editTarget && update.mutate({ u: editTarget.uuid, patch: { body: editBody.trim() } })}>ذخیره</button>
<button className="btn" onClick={() => setEditTarget(null)}>انصراف</button>
</div>
</div>
</Modal>
<ConfirmDialog
open={!!delTarget}
title="حذف یادداشت"
message="آیا از حذف این یادداشت مطمئن هستید؟"
confirmLabel="حذف"
onConfirm={() => delTarget && del.mutate(delTarget.uuid)}
onCancel={() => setDelTarget(null)}
loading={del.isPending}
/>
</div>
);
}
interface Call { uuid: string; subject: string; summary?: string | null; outcome: string; called_at: number; personnel?: string | null }
const pad2 = (n: number) => String(n).padStart(2, '0');
/** تاریخ امروز میلادی به فرمت YYYY-MM-DD (ورودی PersianDateInput). */
const nowDate = () => { const d = new Date(); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; };
/** ساعت الان به فرمت HH:MM. */
const nowTime = () => { const d = new Date(); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
/** کال سنتر — patient call log: register a call + filterable history (all / success / missed). */
function CallCenterTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const { can } = usePermissions();
const canUpdate = can('patients', 'update');
const canDelete = can('patients', 'delete');
const userName = useAuthStore((s) => s.userName);
const [filter, setFilter] = useState<'all' | 'success' | 'missed'>('all');
const [date, setDate] = useState(nowDate);
const [time, setTime] = useState(nowTime);
const [subject, setSubject] = useState('');
const [summary, setSummary] = useState('');
const [outcome, setOutcome] = useState<'success' | 'missed'>('success');
// Keep تاریخ/ساعت تماس live while the user hasn't manually edited them, so a
// long-open tab doesn't submit a stale time without a page refresh.
const dateTouched = useRef(false);
const timeTouched = useRef(false);
useEffect(() => {
const sync = () => {
if (!dateTouched.current) setDate(nowDate());
if (!timeTouched.current) setTime(nowTime());
};
const id = window.setInterval(sync, 30_000);
window.addEventListener('focus', sync);
return () => { window.clearInterval(id); window.removeEventListener('focus', sync); };
}, []);
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(); dateTouched.current = false; timeTouched.current = false; setDate(nowDate()); setTime(nowTime()); 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={(v) => { dateTouched.current = true; setDate(v); }} /></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) => { timeTouched.current = true; 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>
{canUpdate && (
<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)' }}>{formatDateTime(c.called_at)}</div>
{c.personnel && <div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 2 }}>{c.personnel}</div>}
{canDelete && (
<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 { can } = usePermissions();
const canUpdate = can('patients', 'update');
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 style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
موجودی کل بیمار؛ تراکنش‌های زیر فقط مربوط به همین محل کار است
</div>
</div>
{canUpdate && (
<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 var(--primary)', background: 'transparent' }}>
<TurnsFilter color="var(--primary)" />
</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 var(--primary)', color: 'var(--primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="var(--primary)" /> نوبت رزرو
</Link>
{/* `record` یعنی مراجعه‌کننده از همین پرونده می‌آید؛ صفحهٔ ثبت نوبت دیگر جستجو نمی‌خواهد. */}
<Link to={`/admin/appointments/new?record=${uuid}`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: 'var(--primary)', color: 'var(--on-primary)', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
<AddTurn color="var(--on-primary)" /> نوبت جدید
</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>
);
}
/**
* پکیج‌های بیمار.
*
* مانده از دفتر می‌آید نه از شمارنده؛ لینک «دفتر» همان تاریخچه را نشان می‌دهد تا
* معلوم باشد عدد از کجا آمده.
*/
function PackagesTab({ uuid }: { uuid: string }) {
const { patientPackages, loading, sell } = usePatientPackages(uuid);
const { packages } = usePackages();
const [selected, setSelected] = useState('');
const active = packages.filter((p) => p.active);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 240, margin: 0 }}>
<label>فروش پکیج تازه</label>
<SearchableSelect
value={selected}
onChange={(v) => setSelected(String(v ?? ''))}
options={active.map((p) => ({
value: p.uuid,
label: `${p.name}${p.session_count} جلسه`,
}))}
placeholder="انتخاب پکیج"
/>
</div>
<button
type="button"
className="btn primary sm"
disabled={selected === '' || sell.isPending}
onClick={async () => {
await sell.mutateAsync({ package_uuid: selected });
setSelected('');
}}
>
ثبت خرید
</button>
</div>
{loading ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری…</div>
) : patientPackages.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
این بیمار هنوز پکیجی نخریده است
</div>
) : (
patientPackages.map((p) => (
<div key={p.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontWeight: 600 }}>{p.package_name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
خرید {formatDate(p.purchased_at)}
{p.valid_to !== null && ` · اعتبار تا ${formatDate(p.valid_to)}`}
</span>
</div>
<span style={{ fontSize: 14 }}>
مانده: <strong>{p.balance}</strong> از {p.session_count}
</span>
{p.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
{!p.expired && p.balance === 0 && (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
اعتبار پکیج تمام شده؛ نوبت بعدی نقدی محاسبه می‌شود.
</span>
)}
<Link
className="btn secondary sm"
style={{ marginRight: 'auto' }}
to={`/admin/patient-package/${p.uuid}/ledger`}
>
دفتر اعتبار
</Link>
</div>
))
)}
</div>
);
}