474 lines
18 KiB
TypeScript
474 lines
18 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Code,
|
|
AlertCircle,
|
|
Wand2,
|
|
ShieldCheck,
|
|
CheckCircle2,
|
|
ClipboardList,
|
|
Download,
|
|
} from "lucide-react";
|
|
|
|
// Halaman Assist Coding (tanpa tab ICD dan tanpa kolom departemen)
|
|
|
|
// removed unused CodeUsageStats summary interface
|
|
|
|
interface AssistInput {
|
|
clinicalNotes: string;
|
|
labResults: string;
|
|
procedures: string;
|
|
}
|
|
|
|
interface RecommendedCode {
|
|
type: "ICD10" | "ICD9CM";
|
|
code: string;
|
|
description: string;
|
|
confidence: number;
|
|
rationale: string;
|
|
}
|
|
|
|
interface InaCbgsMapping {
|
|
group: string;
|
|
code: string;
|
|
description: string;
|
|
estTariff: number;
|
|
}
|
|
|
|
interface AssistResult {
|
|
preprocessed: AssistInput;
|
|
recommendedCodes: RecommendedCode[];
|
|
inaCbgs: InaCbgsMapping | null;
|
|
references: string[];
|
|
}
|
|
|
|
export default function BPJSCode() {
|
|
const [assistInput, setAssistInput] = useState<AssistInput>({
|
|
clinicalNotes: "",
|
|
labResults: "",
|
|
procedures: "",
|
|
});
|
|
const [assistLoading, setAssistLoading] = useState(false);
|
|
const [assistError, setAssistError] = useState<string | null>(null);
|
|
const [assistResult, setAssistResult] = useState<AssistResult | null>(null);
|
|
|
|
const formatCurrency = (amount: number) =>
|
|
new Intl.NumberFormat("id-ID", {
|
|
style: "currency",
|
|
currency: "IDR",
|
|
minimumFractionDigits: 0,
|
|
}).format(amount);
|
|
|
|
// (format tanggal untuk tabel dihapus karena halaman hanya Assist Coding)
|
|
|
|
// Mock pipeline: preprocess -> LLM -> RAG -> INA-CBGs mapping
|
|
const preprocessInput = (input: AssistInput): AssistInput => {
|
|
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
return {
|
|
clinicalNotes: normalize(input.clinicalNotes),
|
|
labResults: normalize(input.labResults),
|
|
procedures: normalize(input.procedures),
|
|
};
|
|
};
|
|
|
|
const mockLLMRecommend = async (
|
|
input: AssistInput
|
|
): Promise<RecommendedCode[]> => {
|
|
const text =
|
|
`${input.clinicalNotes} ${input.labResults} ${input.procedures}`.toLowerCase();
|
|
const recs: RecommendedCode[] = [];
|
|
if (
|
|
text.includes("hipertensi") ||
|
|
text.includes("bp 150/95") ||
|
|
text.includes("tekanan darah")
|
|
) {
|
|
recs.push({
|
|
type: "ICD10",
|
|
code: "I10",
|
|
description: "Essential (primary) hypertension",
|
|
confidence: 90,
|
|
rationale:
|
|
"Temuan tekanan darah tinggi/hipertensi pada catatan klinis.",
|
|
});
|
|
}
|
|
if (
|
|
text.includes("hba1c") ||
|
|
text.includes("diabetes") ||
|
|
text.includes("glukosa puasa")
|
|
) {
|
|
recs.push({
|
|
type: "ICD10",
|
|
code: "E11",
|
|
description: "Type 2 diabetes mellitus",
|
|
confidence: 86,
|
|
rationale:
|
|
"Hasil lab HbA1c/glukosa dan kata kunci diabetes terdeteksi.",
|
|
});
|
|
}
|
|
if (
|
|
text.includes("pneumonia") ||
|
|
text.includes("infiltrat") ||
|
|
text.includes("nyeri dada batuk demam")
|
|
) {
|
|
recs.push({
|
|
type: "ICD10",
|
|
code: "J18.9",
|
|
description: "Pneumonia, unspecified organism",
|
|
confidence: 80,
|
|
rationale: "Gambaran klinis/temuan imaging mendukung pneumonia.",
|
|
});
|
|
}
|
|
if (
|
|
text.includes("endoskopi") ||
|
|
text.includes("kateterisasi") ||
|
|
text.includes("fiksasi")
|
|
) {
|
|
recs.push({
|
|
type: "ICD9CM",
|
|
code: "45.13",
|
|
description: "Endoskopi lambung (contoh)",
|
|
confidence: 72,
|
|
rationale: "Prosedur terekstrak dari tindakan/operasi.",
|
|
});
|
|
}
|
|
if (recs.length === 0) {
|
|
recs.push({
|
|
type: "ICD10",
|
|
code: "R69",
|
|
description: "Illness, unspecified",
|
|
confidence: 55,
|
|
rationale: "Tidak ada sinyal kuat; butuh klarifikasi klinis.",
|
|
});
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
return recs.sort((a, b) => b.confidence - a.confidence).slice(0, 6);
|
|
};
|
|
|
|
const mockRAGValidate = async (
|
|
recs: RecommendedCode[]
|
|
): Promise<{ validated: RecommendedCode[]; references: string[] }> => {
|
|
// Anggap melakukan pencarian ke referensi nasional (Kemenkes/BPJS)
|
|
const refs: string[] = recs.map((r) => `Ref:${r.type}:${r.code}`);
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
return { validated: recs, references: refs };
|
|
};
|
|
|
|
const mockMapInaCbgs = async (
|
|
recs: RecommendedCode[]
|
|
): Promise<InaCbgsMapping | null> => {
|
|
// Mapping sederhana contoh saja
|
|
const hasPneumonia = recs.some((r) => r.code.startsWith("J18"));
|
|
const hasDM = recs.some((r) => r.code.startsWith("E11"));
|
|
const mapping: InaCbgsMapping | null = hasPneumonia
|
|
? {
|
|
group: "Respiratory",
|
|
code: "E-4-13-II",
|
|
description: "Pneumonia",
|
|
estTariff: 3500000,
|
|
}
|
|
: hasDM
|
|
? {
|
|
group: "Endocrine",
|
|
code: "E-1-10-I",
|
|
description: "Diabetes Mellitus",
|
|
estTariff: 2100000,
|
|
}
|
|
: null;
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
return mapping;
|
|
};
|
|
|
|
const runAssistPipeline = async () => {
|
|
setAssistLoading(true);
|
|
setAssistError(null);
|
|
setAssistResult(null);
|
|
try {
|
|
const pre = preprocessInput(assistInput);
|
|
const llm = await mockLLMRecommend(pre);
|
|
const rag = await mockRAGValidate(llm);
|
|
const ina = await mockMapInaCbgs(rag.validated);
|
|
setAssistResult({
|
|
preprocessed: pre,
|
|
recommendedCodes: rag.validated,
|
|
inaCbgs: ina,
|
|
references: rag.references,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
setAssistError("Gagal menjalankan Assist Coding. Coba lagi.");
|
|
} finally {
|
|
setAssistLoading(false);
|
|
}
|
|
};
|
|
|
|
// Halaman fokus pada Assist Coding
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
|
<Code className="h-8 w-8 text-purple-600 mr-3" />
|
|
BPJS Assist Coding
|
|
</h1>
|
|
<p className="text-gray-600 mt-1">
|
|
Bantuan penentuan kode ICD dan mapping INA-CBGs berbasis input
|
|
klinis.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Assist Coding */}
|
|
<div className="bg-white rounded-lg shadow-sm border mb-6">
|
|
<div className="p-6 border-b border-gray-200">
|
|
<div className="grid grid-cols-1 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700 mb-1 flex items-center">
|
|
<ClipboardList className="h-4 w-4 mr-2 text-purple-600" />{" "}
|
|
Catatan Medis
|
|
</label>
|
|
<textarea
|
|
rows={4}
|
|
value={assistInput.clinicalNotes}
|
|
onChange={(e) =>
|
|
setAssistInput((s) => ({
|
|
...s,
|
|
clinicalNotes: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="Riwayat penyakit, gejala, temuan fisik..."
|
|
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent p-3"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Hasil Lab
|
|
</label>
|
|
<textarea
|
|
rows={3}
|
|
value={assistInput.labResults}
|
|
onChange={(e) =>
|
|
setAssistInput((s) => ({
|
|
...s,
|
|
labResults: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="HbA1c 8.2%, CRP 15 mg/L, Leukosit 12.000/µL, ..."
|
|
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent p-3"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Tindakan/Prosedur
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={assistInput.procedures}
|
|
onChange={(e) =>
|
|
setAssistInput((s) => ({
|
|
...s,
|
|
procedures: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="Endoskopi lambung, kateterisasi jantung, transfusi, ..."
|
|
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent p-3"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
<button
|
|
onClick={runAssistPipeline}
|
|
disabled={
|
|
assistLoading ||
|
|
(!assistInput.clinicalNotes &&
|
|
!assistInput.labResults &&
|
|
!assistInput.procedures)
|
|
}
|
|
className="btn-primary flex items-center space-x-2 disabled:opacity-60"
|
|
>
|
|
<Wand2 className="h-4 w-4" />
|
|
<span>
|
|
{assistLoading ? "Memproses..." : "Jalankan Assist Coding"}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{assistError && (
|
|
<div className="text-sm text-red-600">{assistError}</div>
|
|
)}
|
|
</div>
|
|
{/* Assist Results */}
|
|
<div className="overflow-x-auto">
|
|
<div>
|
|
{!assistResult ? (
|
|
<div className="text-center py-12">
|
|
<AlertCircle className="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
|
Belum ada hasil
|
|
</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Isi input lalu klik "Jalankan Assist Coding".
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="p-6 space-y-6">
|
|
<div className="bg-white border rounded-lg p-4">
|
|
<div className="text-sm font-medium text-gray-900 mb-2">
|
|
Hasil Preprocess
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-700">
|
|
<div>
|
|
<div className="font-medium">Catatan Medis</div>
|
|
<div className="text-gray-600 whitespace-pre-wrap">
|
|
{assistResult.preprocessed.clinicalNotes || "-"}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-medium">Hasil Lab</div>
|
|
<div className="text-gray-600 whitespace-pre-wrap">
|
|
{assistResult.preprocessed.labResults || "-"}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-medium">Tindakan</div>
|
|
<div className="text-gray-600 whitespace-pre-wrap">
|
|
{assistResult.preprocessed.procedures || "-"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white border rounded-lg p-4">
|
|
<div className="text-sm font-medium text-gray-900 mb-3 flex items-center">
|
|
<ShieldCheck className="h-4 w-4 mr-2 text-green-600" />{" "}
|
|
Rekomendasi Kode & Alasan (LLM + RAG)
|
|
</div>
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Jenis
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Kode
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Deskripsi
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Confidence
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Alasan
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{assistResult.recommendedCodes.map((c) => (
|
|
<tr
|
|
key={`${c.type}-${c.code}`}
|
|
className="hover:bg-gray-50"
|
|
>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
|
{c.type}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
{c.code}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-900">
|
|
{c.description}
|
|
</td>
|
|
<td className="px-6 py-4 w-48">
|
|
<div className="text-sm text-gray-700 mb-1">
|
|
{c.confidence}%
|
|
</div>
|
|
<div className="w-full bg-gray-100 rounded-full h-2">
|
|
<div
|
|
className="bg-green-500 h-2 rounded-full"
|
|
style={{
|
|
width: `${Math.max(
|
|
0,
|
|
Math.min(100, c.confidence)
|
|
)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600">
|
|
{c.rationale}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
<div className="text-xs text-gray-500 mt-2">
|
|
Referensi: {assistResult.references.join(", ") || "-"}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white border rounded-lg p-4">
|
|
<div className="text-sm font-medium text-gray-900 mb-2">
|
|
Mapping INA-CBGs
|
|
</div>
|
|
{assistResult.inaCbgs ? (
|
|
<div className="text-sm text-gray-700">
|
|
<div>
|
|
Kode:{" "}
|
|
<span className="font-medium">
|
|
{assistResult.inaCbgs.code}
|
|
</span>{" "}
|
|
({assistResult.inaCbgs.group})
|
|
</div>
|
|
<div>
|
|
Deskripsi: {assistResult.inaCbgs.description}
|
|
</div>
|
|
<div>
|
|
Estimasi Tarif:{" "}
|
|
<span className="font-medium">
|
|
{formatCurrency(assistResult.inaCbgs.estTariff)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-gray-500">
|
|
Belum ada mapping yang relevan.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
<button className="btn-primary flex items-center space-x-2">
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
<span>Verifikasi & Approve</span>
|
|
</button>
|
|
<button
|
|
className="btn-secondary flex items-center space-x-2"
|
|
onClick={() => {
|
|
const blob = new Blob(
|
|
[JSON.stringify(assistResult, null, 2)],
|
|
{ type: "application/json" }
|
|
);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "klaim_siap_kirim.json";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}}
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
<span>Export Klaim</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|