- 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.
271 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|