A patient's file had no view of their multi-session courses: which ones they have, when the remaining sessions fall, or what was recorded in the sessions already done. All of it lived on a tenant-wide page. The tab lists the patient's courses and, per course, a card for every session with its date, its status, and — for finished ones — the areas treated with the device readings a staff member logged. Estimated dates are labelled as such, so a projection is never read as a booking. Each unbooked session carries a button that opens the same NewAppointmentModal used elsewhere, seeded with that session's date, and now binds the resulting appointment to that exact session via a new treatmentSessionUuid prop — a patient can have several open courses, and without it the attachment falls back to guessing from the service. The modal opens in resource mode, not doctor mode: a course's service is booked against the device's calendar, so useDoctorBookingServices returns nothing for the supervising doctor and the picker would render 'no bookable services'. The plan response now carries the course's device for exactly this. A course with no device yet says so instead of offering a button that cannot work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1029 lines
54 KiB
TypeScript
1029 lines
54 KiB
TypeScript
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,
|
||
ArrowPathRoundedSquareIcon,
|
||
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
|
||
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
|
||
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
|
||
} 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, 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';
|
||
import PatientTreatmentTab from '../components/patient/PatientTreatmentTab';
|
||
|
||
type TabKey = 'services' | 'info' | 'appointments' | 'treatment' | 'payments' | 'wallet' | '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: 'treatment', label: 'نوبتهای بعدی', icon: (c) => <ClipboardDocumentListIcon style={{ width: 18, color: c }} /> },
|
||
{ key: 'payments', label: 'پرداختها', icon: (c) => <TabCard color={c} /> },
|
||
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet 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 === 'treatment' ? (
|
||
<PatientTreatmentTab recordUuid={uuid ?? ''} />
|
||
) : 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 === '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>
|
||
);
|
||
}
|