Files
clinicpro/assets/admin/pages/ClaimPatientDetailPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

271 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, formatNumber, formatDate, formatDateTime } from '../lib/utils';
import type { ClaimDetailRow, ClaimStatusLogEntry } from '../types';
import type { ApiResponse } from '../lib/api';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import StatusBadge from '../components/ui/StatusBadge';
import SearchableSelect from '../components/ui/SearchableSelect';
import Modal from '../components/ui/Modal';
import FeatureGate from '../components/ui/FeatureGate';
interface PatientHeader {
uuid: string;
record_uuid: string;
full_name: string | null;
mobile: string | null;
national_code: string | null;
}
interface DetailPayload {
patient: PatientHeader;
claims: ClaimDetailRow[];
}
const STATUS_OPTIONS = [
{ value: '', label: 'همه وضعیت‌ها' },
{ value: 'pending', label: 'در انتظار ارسال' },
{ value: 'submitted', label: 'ارسال شده' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'rejected', label: 'رد شده' },
{ value: 'paid', label: 'پرداخت شده' },
];
const ACTION_LABEL: Record<string, string> = {
submitted: 'ارسال به بیمه',
approved: 'تأیید',
rejected: 'رد',
paid: 'ثبت پرداخت',
};
/** نام endpoint برای هر وضعیت مقصد — وضعیت‌ها اسم فعل ندارند. */
const ACTION_PATH: Record<string, string> = {
submitted: 'submit',
approved: 'approve',
rejected: 'reject',
paid: 'pay',
};
const KIND_LABEL: Record<string, string> = { base: 'پایه', supplementary: 'تکمیلی' };
function Timeline({ logs }: { logs: ClaimStatusLogEntry[] }) {
if (logs.length === 0) return <div style={{ fontSize: 13, color: 'var(--text-3)' }}>رویدادی ثبت نشده است.</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{logs.map((log) => (
<div key={log.uuid} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{ marginTop: 6, width: 8, height: 8, borderRadius: '50%', background: 'var(--primary)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
<StatusBadge type="claim" value={log.to_status} />
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
{formatDateTime(log.at)}
{log.by ? ` · ${log.by}` : ''}
</div>
{log.note && <div style={{ fontSize: 12.5, marginTop: 4 }}>{log.note}</div>}
</div>
</div>
))}
</div>
);
}
/** داشبورد پرونده‌های بیمه — سطح دوم: همه‌ی مطالبات یک بیمار با تاریخچه‌ی کامل. */
export default function ClaimPatientDetailPage() {
const { patientUuid = '' } = useParams();
const [params, setParams] = useSearchParams();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<ClaimDetailRow | null>(null);
const [pendingAction, setPendingAction] = useState<{ claim: ClaimDetailRow; target: string } | null>(null);
const [reason, setReason] = useState('');
const [trackingNumber, setTrackingNumber] = useState('');
const status = params.get('status') ?? '';
const setParam = (patch: Record<string, string>) => {
const next = new URLSearchParams(params);
Object.entries(patch).forEach(([k, v]) => (v ? next.set(k, v) : next.delete(k)));
setParams(next, { replace: true });
};
const queryString = status ? `?status=${status}` : '';
const detailQuery = useQuery<ApiResponse<DetailPayload>>({
queryKey: ['claims-patient-detail', patientUuid, status],
queryFn: () => api.get(`/api/v1/billing/claims/by-patient/${patientUuid}${queryString}`),
enabled: !!patientUuid,
});
const payload = detailQuery.data?.data ?? null;
const patient = payload?.patient ?? null;
const claims = payload?.claims ?? [];
const transitionMut = useMutation({
mutationFn: ({ uuid, target, body }: { uuid: string; target: string; body: Record<string, unknown> }) =>
api.post(`/api/v1/billing/claims/${uuid}/${ACTION_PATH[target]}`, body),
onSuccess: () => {
toast.success('وضعیت مطالبه به‌روز شد');
queryClient.invalidateQueries({ queryKey: ['claims-patient-detail'] });
queryClient.invalidateQueries({ queryKey: ['claims-by-patient'] });
queryClient.invalidateQueries({ queryKey: ['insurance-debt'] });
closeAction();
},
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : 'ثبت تغییر وضعیت ناموفق بود'),
});
const closeAction = () => {
setPendingAction(null);
setReason('');
setTrackingNumber('');
};
const confirmAction = () => {
if (!pendingAction) return;
const { claim, target } = pendingAction;
if (target === 'rejected' && reason.trim() === '') {
toast.error('دلیل رد الزامی است');
return;
}
transitionMut.mutate({
uuid: claim.uuid,
target,
body: target === 'rejected' ? { reason: reason.trim() } : { tracking_number: trackingNumber.trim() || undefined },
});
};
const columns: Column<ClaimDetailRow>[] = [
{ key: 'visit_date', header: 'تاریخ مراجعه', render: (r) => (r.visit_date ? formatDate(r.visit_date) : '—') },
{ key: 'doctor_name', header: 'پزشک', render: (r) => r.doctor_name ?? '—' },
{ key: 'insurance_name', header: 'بیمه', render: (r) => `${r.insurance_name ?? '—'} (${KIND_LABEL[r.insurance_kind] ?? r.insurance_kind})` },
{ key: 'service_base_rials', header: 'مبلغ اصلی', render: (r) => formatRial(r.service_base_rials) },
{ key: 'coverage_percent', header: 'پوشش بیمه', render: (r) => `${formatNumber(r.coverage_percent)}٪` },
{ key: 'insurance_share_rials', header: 'سهم بیمه', render: (r) => formatRial(r.insurance_share_rials) },
{ key: 'patient_share_rials', header: 'سهم بیمار', render: (r) => formatRial(r.patient_share_rials) },
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="claim" value={r.status} /> },
{ key: 'submitted_at', header: 'تاریخ ارسال', render: (r) => (r.submitted_at ? formatDate(r.submitted_at) : '—') },
{ key: 'settled_at', header: 'تاریخ تسویه', render: (r) => (r.settled_at ? formatDate(r.settled_at) : '—') },
{ key: 'tracking_number', header: 'شماره پیگیری', render: (r) => r.tracking_number ?? '—' },
];
return (
<FeatureGate feature="insurance">
<PageHeader
backTo="/admin/claims"
title={patient?.full_name ?? 'پرونده بیمه بیمار'}
description={[patient?.mobile, patient?.national_code].filter(Boolean).join(' · ') || undefined}
breadcrumbs={[
{ label: 'داشبورد', to: '/admin' },
{ label: 'پرونده‌های بیمه', to: '/admin/claims' },
{ label: patient?.full_name ?? '—' },
]}
/>
<div className="card" style={{ padding: 18 }}>
<div style={{ minWidth: 180, marginBottom: 16 }}>
<label className="field-label">وضعیت</label>
<SearchableSelect
options={STATUS_OPTIONS}
value={status}
onChange={(v) => setParam({ status: v ? String(v) : '' })}
placeholder="همه وضعیت‌ها"
height={38}
/>
</div>
<DataTable
columns={columns}
data={claims}
loading={detailQuery.isLoading}
emptyMessage="مطالبه‌ای برای این بیمار ثبت نشده است."
actions={(row) => (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button className="btn ghost sm" onClick={() => setSelected(row)}>جزئیات</button>
{row.allowed_transitions.map((target) => (
<button
key={target}
className="btn primary sm"
onClick={() => setPendingAction({ claim: row, target })}
disabled={transitionMut.isPending}
>
{ACTION_LABEL[target] ?? target}
</button>
))}
</div>
)}
/>
</div>
<Modal open={!!selected} title="جزئیات مطالبه" size="lg" onClose={() => setSelected(null)}>
{selected && (
<div dir="rtl" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, fontSize: 13 }}>
<div><span style={{ color: 'var(--text-3)' }}>بیمه: </span>{selected.insurance_name ?? '—'}</div>
<div><span style={{ color: 'var(--text-3)' }}>نوع: </span>{KIND_LABEL[selected.insurance_kind] ?? selected.insurance_kind}</div>
<div><span style={{ color: 'var(--text-3)' }}>مبلغ اصلی: </span>{formatRial(selected.service_base_rials)}</div>
<div><span style={{ color: 'var(--text-3)' }}>سهم بیمه: </span>{formatRial(selected.insurance_share_rials)}</div>
<div><span style={{ color: 'var(--text-3)' }}>سهم بیمار: </span>{formatRial(selected.patient_share_rials)}</div>
<div><span style={{ color: 'var(--text-3)' }}>تأییدشده: </span>{selected.total_approved_rials !== null ? formatRial(selected.total_approved_rials) : '—'}</div>
<div><span style={{ color: 'var(--text-3)' }}>پرداخت‌شده: </span>{selected.total_paid_rials !== null ? formatRial(selected.total_paid_rials) : '—'}</div>
<div><span style={{ color: 'var(--text-3)' }}>شماره پیگیری: </span>{selected.tracking_number ?? '—'}</div>
</div>
{selected.reject_reason && (
<div style={{ fontSize: 13, color: 'var(--danger)' }}>دلیل رد: {selected.reject_reason}</div>
)}
<div>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>تاریخچه تغییرات</div>
<Timeline logs={selected.logs} />
</div>
</div>
)}
</Modal>
<Modal
open={!!pendingAction}
title={pendingAction ? ACTION_LABEL[pendingAction.target] ?? 'تغییر وضعیت' : ''}
size="sm"
onClose={closeAction}
footer={
<>
<button className="btn ghost sm" onClick={closeAction}>انصراف</button>
<button className="btn primary sm" onClick={confirmAction} disabled={transitionMut.isPending}>تأیید</button>
</>
}
>
{pendingAction?.target === 'rejected' ? (
<div>
<label className="field-label">دلیل رد (الزامی)</label>
<textarea
className="input"
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="دلیل رد مطالبه توسط بیمه"
/>
</div>
) : pendingAction?.target === 'submitted' ? (
<div>
<label className="field-label">شماره پیگیری بیمه (اختیاری)</label>
<input
className="input"
value={trackingNumber}
onChange={(e) => setTrackingNumber(e.target.value)}
placeholder="مثلاً TM-4419-88"
/>
</div>
) : (
<div style={{ fontSize: 13 }}>آیا از ثبت این تغییر وضعیت مطمئن هستید؟</div>
)}
</Modal>
</FeatureGate>
);
}