Fix: remove some and add some

This commit is contained in:
Friday6661
2025-08-13 13:34:37 +07:00
parent 2e396f32b9
commit 5d9195d903
4 changed files with 917 additions and 461 deletions

View File

@@ -61,7 +61,7 @@ interface CostStats {
approvalRate: number;
}
export default function CostRecommendation() {
export function LegacyCostRecommendation() {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [riskFilter, setRiskFilter] = useState("all");
@@ -679,3 +679,484 @@ export default function CostRecommendation() {
</div>
);
}
interface ICDRecommendation {
code: string;
description: string;
confidence: number;
reasoning: string;
}
async function mockRecommendICD(diagnosis: string, procedure: string): Promise<ICDRecommendation[]> {
const lowerDx = diagnosis.toLowerCase();
const lowerProc = procedure.toLowerCase();
const suggestions: ICDRecommendation[] = [];
if (lowerDx.includes("hipertensi") || lowerDx.includes("hypertension")) {
suggestions.push({
code: "I10",
description: "Essential (primary) hypertension",
confidence: 92,
reasoning: "Kata kunci 'hipertensi' terdeteksi pada diagnosis klinis.",
});
}
if (lowerDx.includes("diabetes") || lowerDx.includes("dm")) {
suggestions.push({
code: "E11",
description: "Type 2 diabetes mellitus",
confidence: 88,
reasoning: "Terdapat indikasi DM tipe 2 pada gambaran diagnosis.",
});
}
if (lowerDx.includes("pneumonia") || lowerDx.includes("infeksi paru")) {
suggestions.push({
code: "J18.9",
description: "Pneumonia, unspecified organism",
confidence: 82,
reasoning: "Gejala dan istilah yang mengarah ke pneumonia terdeteksi.",
});
}
if (lowerDx.includes("gastritis") || lowerDx.includes("dispepsia")) {
suggestions.push({
code: "K29.1",
description: "Acute gastritis",
confidence: 80,
reasoning: "Istilah gastritis muncul pada diagnosis.",
});
}
if (lowerDx.includes("osteoarthritis") || lowerDx.includes("nyeri sendi")) {
suggestions.push({
code: "M15.9",
description: "Polyosteoarthritis, unspecified",
confidence: 76,
reasoning: "Keluhan terkait osteoartritis/nyeri sendi teridentifikasi.",
});
}
if (lowerProc.includes("insulin") && !suggestions.find((s) => s.code.startsWith("E1"))) {
suggestions.push({
code: "E10-E14",
description: "Diabetes mellitus (range, sesuaikan subkategori)",
confidence: 70,
reasoning: "Penggunaan insulin mengarah pada kategori diabetes mellitus.",
});
}
if (lowerProc.includes("antibiotik") && !suggestions.find((s) => s.code.startsWith("J"))) {
suggestions.push({
code: "J15-J18",
description: "Bacterial/unspecified pneumonia (range)",
confidence: 68,
reasoning: "Terapi antibiotik berkolerasi dengan infeksi respiratori.",
});
}
if (suggestions.length === 0) {
suggestions.push(
{
code: "R69",
description: "Illness, unspecified",
confidence: 55,
reasoning: "Tidak ada kata kunci spesifik terdeteksi. Perlu klarifikasi klinis.",
},
{
code: "Z00.0",
description: "General medical examination",
confidence: 48,
reasoning: "Pertimbangkan evaluasi umum jika kasus bersifat screening.",
}
);
}
suggestions.sort((a, b) => b.confidence - a.confidence);
await new Promise((r) => setTimeout(r, 600));
return suggestions.slice(0, 6);
}
export default function CostRecommendation() {
const [diagnosis, setDiagnosis] = useState("");
const [procedure, setProcedure] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<ICDRecommendation[]>([]);
const [visitType, setVisitType] = useState("Kontrol Rutin");
const [lastVisitDate, setLastVisitDate] = useState("");
const [currentVisitDate, setCurrentVisitDate] = useState(() => new Date().toISOString().slice(0, 10));
type BpjsMapping = {
icdCode: string;
bpjsCode: string;
description: string;
estTariff: number;
};
const BPJS_CODE_MAPPINGS: BpjsMapping[] = [
{ icdCode: "I10", bpjsCode: "BPJS-I10-01", description: "Hipertensi Esensial", estTariff: 850000 },
{ icdCode: "E11", bpjsCode: "BPJS-E11-02", description: "Diabetes Mellitus Tipe 2", estTariff: 1200000 },
{ icdCode: "J18.9", bpjsCode: "BPJS-J18-03", description: "Pneumonia", estTariff: 2500000 },
{ icdCode: "K29.1", bpjsCode: "BPJS-K29-04", description: "Gastritis Akut", estTariff: 650000 },
{ icdCode: "M15.9", bpjsCode: "BPJS-M15-09", description: "Osteoartritis", estTariff: 900000 },
];
const findBpjsMappingForICD = (code: string): BpjsMapping | null => {
if (!code) return null;
const normalized = code === "E10-E14" ? "E11" : code;
const exact = BPJS_CODE_MAPPINGS.find((m) => m.icdCode === normalized);
if (exact) return exact;
return BPJS_CODE_MAPPINGS.find((m) => normalized.startsWith(m.icdCode)) || null;
};
const bpjsRecommendations = results
.map((r) => ({ icdCode: r.code, mapping: findBpjsMappingForICD(r.code) }))
.filter((x) => x.mapping !== null) as { icdCode: string; mapping: BpjsMapping }[];
const getDaysBetween = (a: string, b: string) => {
try {
const da = new Date(a + "T00:00:00");
const db = new Date(b + "T00:00:00");
const diff = Math.floor((db.getTime() - da.getTime()) / (1000 * 60 * 60 * 24));
return isNaN(diff) ? null : Math.max(diff, 0);
} catch {
return null;
}
};
const checkReasonConsistency = (type: string, dx: string, proc: string) => {
const t = type.toLowerCase();
const text = `${dx} ${proc}`.toLowerCase();
const followUpKeywords = ["kontrol", "follow up", "lanjutan", "kontrol ulang", "monitoring"];
const acuteKeywords = ["akut", "baru", "nyeri hebat", "mendadak", "demam tinggi", "perburukan"];
const foundFollow = followUpKeywords.some((k) => text.includes(k));
const foundAcute = acuteKeywords.some((k) => text.includes(k));
const reasons: string[] = [];
let inconsistent = false;
if (t.includes("kontrol") && foundAcute) {
inconsistent = true;
reasons.push("Terdapat indikasi kondisi akut pada keterangan, namun tipe kunjungan adalah kontrol.");
}
if (t.includes("keluhan baru") && foundFollow) {
inconsistent = true;
reasons.push("Terdapat indikasi tindak lanjut/kontrol pada keterangan, namun dipilih 'Keluhan Baru'.");
}
return { inconsistent, reasons };
};
const ICD_INTERVAL_RULES: Record<string, { minDays: number; note: string }> = {
"I10": { minDays: 14, note: "Kontrol hipertensi" },
"E11": { minDays: 30, note: "Kontrol DM tipe 2" },
"J18.9": { minDays: 14, note: "Pneumonia" },
"K29.1": { minDays: 14, note: "Gastritis akut" },
"M15.9": { minDays: 30, note: "Osteoartritis" },
};
const getIntervalRuleForCode = (code: string) => {
if (ICD_INTERVAL_RULES[code as keyof typeof ICD_INTERVAL_RULES]) return ICD_INTERVAL_RULES[code as keyof typeof ICD_INTERVAL_RULES];
if (code.startsWith("E10") || code.startsWith("E11") || code.startsWith("E12") || code.startsWith("E13") || code.startsWith("E14") || code === "E10-E14") {
return ICD_INTERVAL_RULES.E11;
}
return undefined;
};
const daysSinceLast = lastVisitDate && currentVisitDate ? getDaysBetween(lastVisitDate, currentVisitDate) : null;
const consistency = checkReasonConsistency(visitType, diagnosis, procedure);
const showInconsistentControlWarning = visitType.toLowerCase().includes("kontrol") && daysSinceLast !== null && daysSinceLast < 30 && consistency.inconsistent;
const overclaimItems = results
.map((r) => ({ rec: r, rule: getIntervalRuleForCode(r.code) }))
.filter((x) => x.rule && daysSinceLast !== null && (daysSinceLast as number) < (x.rule as { minDays: number }).minDays);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(amount);
};
const handleGenerate = async () => {
setIsLoading(true);
setError(null);
try {
const recs = await mockRecommendICD(diagnosis, procedure);
setResults(recs);
} catch (e) {
setError("Gagal membuat rekomendasi. Coba lagi.");
} finally {
setIsLoading(false);
}
};
const exportJSON = () => {
const blob = new Blob([JSON.stringify(results, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "icd_recommendations.json";
a.click();
URL.revokeObjectURL(url);
};
const copyCode = async (code: string) => {
try {
await navigator.clipboard.writeText(code);
} catch {}
};
return (
<div className="p-6">
<div className="max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Asisten Rekomendasi ICD untuk Dokter</h1>
<p className="text-gray-600 mt-1">
Masukkan ringkasan diagnosa dan prosedur. Sistem akan mengusulkan kode ICD yang relevan beserta tingkat keyakinan.
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border mb-6">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center">
<FileText className="h-4 w-4 mr-2 text-blue-600" /> Diagnosa Klinis
</label>
<textarea
value={diagnosis}
onChange={(e) => setDiagnosis(e.target.value)}
rows={4}
placeholder="Contoh: Pasien dengan hipertensi esensial, keluhan pusing dan tekanan darah 150/95 mmHg."
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tipe Kunjungan</label>
<select
value={visitType}
onChange={(e) => setVisitType(e.target.value)}
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3"
>
<option>Kontrol Rutin</option>
<option>Keluhan Baru</option>
<option>Tindak Lanjut Prosedur</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tanggal Kunjungan Terakhir</label>
<input
type="date"
value={lastVisitDate}
onChange={(e) => setLastVisitDate(e.target.value)}
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tanggal Kunjungan Saat Ini</label>
<input
type="date"
value={currentVisitDate}
onChange={(e) => setCurrentVisitDate(e.target.value)}
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center">
<Stethoscope className="h-4 w-4 mr-2 text-green-600" /> Prosedur/Tatalaksana Utama
</label>
<input
type="text"
value={procedure}
onChange={(e) => setProcedure(e.target.value)}
placeholder="Contoh: Terapi antibiotik IV, insulin basal, fisioterapi, dsb."
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3"
/>
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleGenerate}
disabled={isLoading || (!diagnosis && !procedure)}
className="btn-primary flex items-center space-x-2 disabled:opacity-60"
>
<Search className="h-4 w-4" />
<span>{isLoading ? "Menghasilkan..." : "Buat Rekomendasi"}</span>
</button>
<button
onClick={exportJSON}
disabled={results.length === 0}
className="btn-secondary flex items-center space-x-2 disabled:opacity-60"
>
<Download className="h-4 w-4" />
<span>Export JSON</span>
</button>
</div>
{error && (
<div className="text-sm text-red-600">{error}</div>
)}
{/* Global warnings */}
{showInconsistentControlWarning && (
<div className="mt-2 rounded-md border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800">
Kunjungan kontrol &lt; 30 hari dan alasan terdeteksi tidak konsisten dengan tipe kunjungan.
{consistency.reasons.length > 0 && (
<ul className="list-disc pl-5 mt-1">
{consistency.reasons.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">Rekomendasi Kode ICD</h3>
<div className="text-xs text-gray-500">AI Based</div>
</div>
{/* Overclaim/interval banner */}
{overclaimItems.length > 0 && (
<div className="mx-6 my-4 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800">
Peringatan interval: Ditemukan potensi klaim dini pada {overclaimItems.length} kode.
{daysSinceLast !== null && (
<div className="mt-1 text-xs text-red-700">Jarak kunjungan: {daysSinceLast} hari.</div>
)}
</div>
)}
{isLoading ? (
<div className="p-10 text-center text-gray-600">Sedang menganalisis...</div>
) : results.length > 0 ? (
<div className="overflow-x-auto">
<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">Kode ICD</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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aksi</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{results.map((r) => (
<tr key={r.code} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm font-medium text-gray-900">
<Code className="h-4 w-4 mr-2 text-blue-600" /> {r.code}
</div>
{/* Per-item overclaim label */}
{(() => {
const rule = getIntervalRuleForCode(r.code);
const isOverclaim = rule && daysSinceLast !== null && (daysSinceLast as number) < (rule as { minDays: number }).minDays;
if (!isOverclaim) return null;
return (
<div className="mt-1 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
Interval &lt; {String((rule as { minDays: number }).minDays)} hari ({String((rule as { note: string }).note)})
</div>
);
})()}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">{r.description}</div>
</td>
<td className="px-6 py-4 w-48">
<div className="text-sm text-gray-700 mb-1">{r.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, r.confidence))}%` }}
/>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-600">{r.reasoning}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button
onClick={() => copyCode(r.code)}
className="text-blue-600 hover:text-blue-900"
>
Salin Kode
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<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 rekomendasi</h3>
<p className="mt-1 text-sm text-gray-500">Isi diagnosa/prosedur lalu klik "Buat Rekomendasi".</p>
</div>
)}
</div>
{/* BPJS mapping and cost section */}
<div className="bg-white rounded-lg shadow-sm border overflow-hidden mt-6">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">Rekomendasi Kode BPJS & Estimasi Biaya</h3>
<div className="text-xs text-gray-500">Mapping dari ICD</div>
</div>
{isLoading ? (
<div className="p-10 text-center text-gray-600">Menyiapkan mapping BPJS...</div>
) : results.length > 0 ? (
bpjsRecommendations.length > 0 ? (
<div className="overflow-x-auto">
<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">Kode ICD</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kode BPJS</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">Estimasi Tarif</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{bpjsRecommendations.map(({ icdCode, mapping }) => (
<tr key={`${icdCode}-${mapping.bpjsCode}`} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm font-medium text-gray-900">
<Code className="h-4 w-4 mr-2 text-blue-600" /> {icdCode}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{mapping.bpjsCode}</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900">{mapping.description}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center">
<DollarSign className="h-4 w-4 mr-1 text-green-600" />
{formatCurrency(mapping.estTariff)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<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 mapping BPJS untuk rekomendasi ICD saat ini</h3>
<p className="mt-1 text-sm text-gray-500">Coba perbarui diagnosa/prosedur untuk melihat rekomendasi BPJS.</p>
</div>
)
) : (
<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 rekomendasi</h3>
<p className="mt-1 text-sm text-gray-500">Buat rekomendasi ICD terlebih dahulu.</p>
</div>
)}
</div>
</div>
</div>
);
}