ownsRecord guards the patient record, not the rows underneath it, so
GET /api/v1/patient/{uuid}/wallet/transactions — and the recent_transactions
in the balance summary — returned the patient's entire history. Clinic A
could read what the patient paid at clinic B, down to the name of the staff
member who entered it.
The wallet stays the person's: the balance is still the sum of that user's
credits minus debits across every environment. Scoping it would show a
patient part of their own money and would make the running balance_after
meaningless. So this is attribution per row, not ownership per wallet.
The columns are deliberately named recorded_entity_type / recorded_entity_id
rather than entity_type / entity_id. TenantFilter keys on the latter and
would then scope the balance query too — the exact bug this avoids. The
naming is load-bearing, and both the entity and the architecture doc say so.
Rows that cannot be attributed — entered before this split, or outside any
environment such as a representation's commission — stay NULL and remain
visible everywhere; hiding them would make an existing patient's history
look deleted. The migration reports how many there are (0 in dev, all
attributable from payments and session references).
Consequence, documented in both docs/api/patient.md and the wallet tab: the
listed rows no longer sum to the displayed balance.
Removing the fix turns 3 of the 6 new tests red.
Tests: 902 backend (+6), 570 frontend. PHPStan unchanged at 17.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1023 lines
53 KiB
TypeScript
1023 lines
53 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,
|
||
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';
|
||
|
||
type TabKey = 'services' | 'info' | 'appointments' | '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: '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 === '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>
|
||
<Link to="/admin/appointments/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>
|
||
</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>
|
||
);
|
||
}
|
||
|