first commit

This commit is contained in:
ryan
2026-02-06 14:22:35 +07:00
commit bd70440c71
185 changed files with 69518 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
"use client";
import { useState, useEffect } from "react";
import { FaSearch, FaEdit, FaTrash, FaPlus } from 'react-icons/fa';
import { getRuangan } from "@/lib/api-helper";
interface Ruangan {
id?: number;
ID_Ruangan?: number;
nama?: string;
Nama_Ruangan?: string;
[key: string]: any;
}
interface AdminRuanganProps {
onLogout?: () => void;
}
const AdminRuangan = ({ onLogout }: AdminRuanganProps) => {
const [ruanganList, setRuanganList] = useState<Ruangan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");
// Buat fetch ruangan list yak
useEffect(() => {
const fetchRuangan = async () => {
try {
setLoading(true);
setError("");
const response = await getRuangan();
if (response.error) {
setError(response.error);
setRuanganList([]);
return;
}
// Extract ruangan data dari response
const ruanganArray = (response.data as any)?.data || response.data;
if (ruanganArray && Array.isArray(ruanganArray)) {
setRuanganList(ruanganArray);
} else {
setRuanganList([]);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Error loading ruangan");
setRuanganList([]);
} finally {
setLoading(false);
}
};
fetchRuangan();
}, []);
// Filter ruangan by search term
const filteredRuangan = ruanganList.filter((ruangan) => {
const nama = (ruangan.nama || ruangan.Nama_Ruangan || "").toString().toLowerCase();
return nama.includes(searchTerm.toLowerCase());
});
return (
<div className="w-full bg-gray-50 min-h-screen p-4 sm:p-6 md:p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-800 mb-2">Manajemen Ruangan</h1>
<p className="text-gray-600">Kelola data ruangan rumah sakit</p>
</div>
{/* Search dan Add Button */}
<div className="mb-6 flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<FaSearch className="absolute left-3 top-3 text-gray-400" />
<input
type="text"
placeholder="Cari ruangan..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500"
/>
</div>
<button className="flex items-center gap-2 bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors">
<FaPlus />
Tambah Ruangan
</button>
</div>
{/* Loading State */}
{loading && (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
)}
{/* Error State */}
{error && !loading && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg mb-4">
<p className="font-semibold">Error</p>
<p>{error}</p>
</div>
)}
{/* Ruangan Table */}
{!loading && !error && (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
{filteredRuangan.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 border-b">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">No</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Nama Ruangan</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Aksi</th>
</tr>
</thead>
<tbody>
{filteredRuangan.map((ruangan, idx) => (
<tr key={idx} className="border-b hover:bg-gray-50 transition-colors">
<td className="px-4 py-3 text-sm text-gray-600">{idx + 1}</td>
<td className="px-4 py-3 text-sm text-gray-800 font-medium">
{ruangan.nama || ruangan.Nama_Ruangan || "-"}
</td>
<td className="px-4 py-3 text-sm flex gap-2">
<button className="flex items-center gap-1 text-blue-500 hover:text-blue-700 transition-colors">
<FaEdit size={16} />
<span>Edit</span>
</button>
<button className="flex items-center gap-1 text-red-500 hover:text-red-700 transition-colors">
<FaTrash size={16} />
<span>Hapus</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="p-8 text-center text-gray-500">
<p>Tidak ada ruangan ditemukan</p>
</div>
)}
</div>
)}
</div>
);
};
export default AdminRuangan;
@@ -0,0 +1,80 @@
"use client";
import React, { useState, useEffect } from "react";
import { checkBackendHealth } from "@/lib/api";
interface BackendStatusProps {
className?: string;
}
const BackendStatus: React.FC<BackendStatusProps> = ({ className = "" }) => {
const [isOnline, setIsOnline] = useState<boolean | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [lastChecked, setLastChecked] = useState<Date | null>(null);
const checkStatus = async () => {
setIsChecking(true);
try {
const response = await checkBackendHealth();
setIsOnline(response.status === 200 && !response.error);
setLastChecked(new Date());
} catch (error) {
setIsOnline(false);
setLastChecked(new Date());
} finally {
setIsChecking(false);
}
};
useEffect(() => {
// Cek status di awal abis delay singkat buat hindari conflict
const initialTimeout = setTimeout(() => {
checkStatus();
}, 1000);
// Cek status setiap 60 detik (frekuensi dikurangin)
const interval = setInterval(checkStatus, 60000);
return () => {
clearTimeout(initialTimeout);
clearInterval(interval);
};
}, []);
const getStatusColor = () => {
if (isChecking) return "bg-yellow-500";
if (isOnline === null) return "bg-gray-500";
return isOnline ? "bg-green-500" : "bg-red-500";
};
const getStatusText = () => {
if (isChecking) return "Checking...";
if (isOnline === null) return "Unknown";
return isOnline ? "Online" : "Offline";
};
return (
<div className={`flex items-center space-x-2 ${className}`}>
<div
className={`w-3 h-3 rounded-full ${getStatusColor()} ${
isChecking ? "animate-pulse" : ""
}`}
/>
<span className="text-sm text-gray-600">Backend: {getStatusText()}</span>
{lastChecked && (
<span className="text-xs text-gray-400">
({lastChecked.toLocaleTimeString()})
</span>
)}
<button
onClick={checkStatus}
disabled={isChecking}
className="text-xs text-blue-500 hover:text-blue-700 disabled:opacity-50"
>
Refresh
</button>
</div>
);
};
export default BackendStatus;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,524 @@
"use client";
import { useState, useEffect } from 'react';
import { FaSearch, FaEdit } from 'react-icons/fa';
import { getallbilingaktif} from '@/lib/api-helper';
interface RiwayatBillingPasienProps {
onLogout?: () => void;
userRole?: "dokter" | "admin";
onEdit?: (billingId: number, pasienName?: string) => void;
selectedRuangan?: string | null;
}
interface BillingData {
ID_Billing?: number;
ID_Pasien?: number;
Nama_Pasien?: string;
Billing_Sign?: string;
// Backend sends lowercase field names
id_billing?: number;
id_pasien?: number;
nama_pasien?: string;
billing_sign?: string;
// Additional fields from backend
Kelas?: string;
ruangan?: string;
total_tarif_rs?: number;
total_klaim?: number;
// Doctor fields
ID_Dokter?: number;
id_dokter?: number;
nama_dokter?: string;
Nama_Dokter?: string;
[key: string]: any;
}
const RiwayatBillingPasien = ({ onLogout, userRole, onEdit, selectedRuangan }: RiwayatBillingPasienProps) => {
const [billingData, setBillingData] = useState<BillingData[]>([]);
const [filteredData, setFilteredData] = useState<BillingData[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [loggedInDokterId, setLoggedInDokterId] = useState<number | null>(null);
const [loggedInDokterName, setLoggedInDokterName] = useState<string>('');
const [ruangan, setRuangan] = useState<string>('');
const [ruanganSearch, setRuanganSearch] = useState<string>('');
const [ruanganDropdownOpen, setRuanganDropdownOpen] = useState<boolean>(false);
// Ambil info dokter yang login
useEffect(() => {
const dokterData = localStorage.getItem("dokter");
if (dokterData) {
try {
const dokter = JSON.parse(dokterData);
if (dokter.id) {
setLoggedInDokterId(dokter.id);
}
if (dokter.nama) {
setLoggedInDokterName(dokter.nama);
}
} catch (err) {
console.error('Error parsing dokter data:', err);
}
}
}, []);
// Fetch billing data
useEffect(() => {
const fetchBillingData = async () => {
try {
setLoading(true);
setError('');
const response = await getallbilingaktif();
if (response.error) {
setError(response.error);
return;
}
// Handle berbagai struktur response
if (response.data) {
let dataArray: BillingData[] = [];
// Cek kalo response.data udah array langsung
if (Array.isArray(response.data)) {
dataArray = response.data;
}
// Cek kalo response.data punya property data
else if ((response.data as any).data && Array.isArray((response.data as any).data)) {
dataArray = (response.data as any).data;
}
// Cek kalo response.data punya status sama property data
else if ((response.data as any).status && (response.data as any).data && Array.isArray((response.data as any).data)) {
dataArray = (response.data as any).data;
} else {
console.error('Unexpected response structure:', response.data);
setError('Format data tidak dikenali');
return;
}
// Log untuk debugging
console.log('Billing data loaded:', dataArray.length, 'items');
if (dataArray.length > 0) {
console.log('Sample item:', dataArray[0]);
}
// Tidak filter berdasarkan dokter - ambil semua data dari database
setBillingData(dataArray);
setFilteredData(dataArray);
} else {
console.error('No data in response:', response);
setError('Tidak ada data yang diterima dari server');
}
} catch (err) {
setError('Gagal memuat data billing. Pastikan backend server berjalan.');
console.error(err);
} finally {
setLoading(false);
}
};
// Always fetch data regardless of login status
fetchBillingData();
}, []);
// Filter data based on search term and ruangan
useEffect(() => {
let filtered = billingData;
// Filter 1: By ruangan (if selectedRuangan provided)
if (selectedRuangan) {
console.log(`[DEBUG] Filtering for ruangan: "${selectedRuangan}"`);
console.log(`[DEBUG] Total items before filter: ${billingData.length}`);
console.log(`[DEBUG] Available ruangan in data:`, [...new Set(billingData.map(item => item.ruangan || item.Ruangan))]);
filtered = filtered.filter((item) => {
const itemRuangan = item.ruangan || item.Ruangan || '';
const matches = itemRuangan.trim() === selectedRuangan.trim();
if (!matches && item.nama_pasien) {
console.log(`[DEBUG] Item "${item.nama_pasien}" ruangan="${itemRuangan}" doesn't match "${selectedRuangan}"`);
}
return matches;
});
console.log(`[DEBUG] Items after filter: ${filtered.length}`);
}
// Filter 2: By search term
if (searchTerm.trim()) {
filtered = filtered.filter(
(item) => {
const namaPasien = item.Nama_Pasien || item.nama_pasien || '';
const idPasien = item.ID_Pasien || item.id_pasien || 0;
const idBilling = item.ID_Billing || item.id_billing || 0;
return (
namaPasien.toString().toLowerCase().includes(searchTerm.toLowerCase()) ||
idPasien.toString().includes(searchTerm) ||
idBilling.toString().includes(searchTerm)
);
}
);
}
setFilteredData(filtered);
}, [searchTerm, billingData, selectedRuangan]);
const getStatusColor = (billingSign: string) => {
// Map billing sign to color
if (!billingSign) return "bg-gray-400";
const sign = billingSign.toLowerCase();
// Map Indonesian enum values from database
if (sign === "hijau" || sign === "green") {
return "bg-green-500"; // Tarif RS <=25% dari BPJS
} else if (sign === "kuning" || sign === "yellow") {
return "bg-yellow-500"; // 26%-50%
} else if (sign === "merah" || sign === "red" || sign === "orange") {
return "bg-red-500"; // >50%
}
// Legacy mappings (for backward compatibility)
if (sign === "selesai" || sign === "completed" || sign === "1") {
return "bg-green-500";
} else if (sign === "pending" || sign === "proses" || sign === "0") {
return "bg-yellow-500";
} else {
return "bg-gray-400";
}
};
// Hitung warning sign secara dinamis berdasarkan current tarif RS vs existing klaim
// This ensures warning updates even if INACBG hasn't been input yet
const calculateDynamicWarningSign = (totalTarifRS: number | undefined, totalKlaim: number | undefined): string => {
if (!totalTarifRS || !totalKlaim || totalTarifRS <= 0 || totalKlaim <= 0) {
return ""; // No data to calculate
}
const percentage = (totalTarifRS / totalKlaim) * 100;
if (percentage <= 25) {
return "Hijau"; // Safe
} else if (percentage <= 50) {
return "Kuning"; // Warning
} else {
return "Merah"; // Alert
}
};
const handleSelectRuangan = (idRuangan: string, namaRuangan: string) => {
setRuangan(namaRuangan); // ✅ SIMPAN NAMA RUANGAN!
setRuanganSearch(namaRuangan);
setRuanganDropdownOpen(false);
};
return (
<div className="bg-white flex flex-col h-screen">
{/* Fixed Header */}
<div className="flex-shrink-0 bg-white border-b border-gray-100">
{/* Tanggal */}
<div className="p-3 sm:p-4 md:p-6 pb-2 sm:pb-3">
<div className="text-xs sm:text-sm text-[#2591D0]">
{new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
{/* Search Bar */}
<div className="px-3 sm:px-4 md:px-6 pb-3 sm:pb-4 md:pb-6">
<div className="relative">
<input
type="text"
placeholder="Cari billing pasien disini"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-10 sm:pr-12 text-[#2591D0] placeholder-blue-400 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 focus:outline-0"
/>
<FaSearch className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-[#2591D0] cursor-pointer text-sm sm:text-base" />
</div>
</div>
{/* Table Header - Desktop Only */}
<div className="hidden md:block border-t border-blue-200">
<div className="w-full overflow-x-auto">
<table className="w-full">
<thead className="bg-[#87CEEB]">
<tr className="bg-[#87CEEB]">
<th className="px-4 md:px-6 lg:px-15 py-3 md:py-4 text-left text-sm md:text-base font-bold text-white w-24">
ID Pasien
</th>
<th className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-left text-sm md:text-base font-bold text-white flex-1">
Nama
</th>
<th className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-left text-sm md:text-base font-bold text-white flex-1">
Dokter
</th>
<th className="px-4 md:px-6 lg:px-30 py-3 md:py-4 text-right text-sm md:text-base font-bold text-white w-28">
Billing Sign
</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
{/* Fixed Logout Button - Top Right */}
{onLogout && (
<button
onClick={onLogout}
className="fixed top-4 right-4 z-50 flex items-center space-x-1 sm:space-x-2 bg-white sm:bg-transparent px-2 sm:px-0 py-1.5 sm:py-0 rounded-lg sm:rounded-none shadow-md sm:shadow-none text-blue-500 hover:text-red-500 transition text-xs sm:text-sm font-medium"
title="Logout"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 sm:h-5 sm:w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H6a2 2 0 01-2-2V7a2 2 0 012-2h5a2 2 0 012 2v1"
/>
</svg>
<span className="hidden sm:inline text-xs sm:text-sm font-medium">Logout</span>
</button>
)}
{/* Error Message */}
{error && (
<div className="mx-3 sm:mx-4 md:mx-6 mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg">
{error}
</div>
)}
{/* Scrollable Content Container */}
<div className="flex-1 overflow-y-auto">
{/* Table - Desktop View */}
<div className="hidden md:block border border-blue-200 border-t-0 m-3 sm:m-4 md:m-6 mt-0 overflow-x-auto">
<table className="w-full">
<thead style={{display: 'none'}}>
<tr>
<th className="w-24">ID Pasien</th>
<th className="flex-1">Nama</th>
<th className="flex-1">Dokter</th>
<th className="w-28">Billing Sign</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="px-4 md:px-6 lg:px-8 py-12 text-center text-[#2591D0] text-base md:text-lg">
Memuat data...
</td>
</tr>
) : filteredData.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 md:px-6 lg:px-8 py-12 text-center text-[#2591D0] text-base md:text-lg">
{searchTerm ? 'Tidak ada data yang sesuai dengan pencarian' : 'Tidak ada data billing'}
</td>
</tr>
) : (
filteredData.map((item, index) => {
// Support both PascalCase and lowercase field names from backend
const idBilling = item.ID_Billing || item.id_billing || 0;
const idPasien = item.ID_Pasien || item.id_pasien || 0;
const namaPasien = item.Nama_Pasien || item.nama_pasien || 'N/A';
const billingSign = item.Billing_Sign || item.billing_sign || '';
// Ambil nama dokter dari backend response - bisa kosong kalo belum ada di database
const namaDokter = item.Nama_Dokter || item.nama_dokter || '';
return (
<tr
key={idBilling || index}
className={`${
index % 2 === 0 ? "bg-white" : "bg-gray-50"
} hover:bg-blue-50 transition-colors`}
>
<td className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-sm md:text-base text-[#2591D0] w-24">
P.{idPasien.toString().padStart(4, '0')}
</td>
<td className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-sm md:text-base text-[#2591D0] break-words flex-1">
{namaPasien}
</td>
<td className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-sm md:text-base text-[#2591D0] break-words flex-1">
{namaDokter || '-'}
</td>
<td className="px-4 md:px-6 lg:px-8 py-3 md:py-4 w-28">
<div className="flex items-center gap-3 md:gap-4 justify-between group relative">
{(() => {
// Hitung dynamic warning sign kalo data tarif ada
const dynamicSign = calculateDynamicWarningSign(item.total_tarif_rs, item.total_klaim);
const displaySign = dynamicSign || billingSign; // Use dynamic if available, fallback to DB sign
return (
<>
<span
className={`${getStatusColor(
displaySign
)} w-16 md:w-20 lg:w-24 h-5 md:h-6 lg:h-7 rounded-full flex-shrink-0 cursor-help`}
title={item.total_tarif_rs && item.total_klaim ?
`Tarif RS: Rp ${item.total_tarif_rs?.toLocaleString('id-ID')} | BPJS: Rp ${item.total_klaim?.toLocaleString('id-ID')}`
: ''}
></span>
{/* Hover Tooltip */}
{item.total_tarif_rs && item.total_klaim && (
<div className="hidden group-hover:block absolute left-0 bottom-full mb-2 bg-gray-900 text-white text-xs rounded-lg px-2 py-1 whitespace-nowrap z-10">
Tarif RS: Rp {item.total_tarif_rs?.toLocaleString('id-ID')} | BPJS: Rp {item.total_klaim?.toLocaleString('id-ID')}
</div>
)}
</>
);
})()}
{userRole === "admin" && (
<FaEdit
onClick={() => {
console.log("🖱️ FaEdit clicked! onEdit exists?", !!onEdit, "idBilling:", idBilling, "namaPasien:", namaPasien);
if (onEdit && idBilling) {
console.log("✅ Calling onEdit with:", idBilling, namaPasien);
onEdit(idBilling, namaPasien);
} else {
console.error("❌ Cannot call onEdit - onEdit exists?", !!onEdit, "idBilling exists?", !!idBilling);
}
}}
className="text-[#2591D0] cursor-pointer hover:text-[#1e7ba8] text-base md:text-lg flex-shrink-0 ml-auto"
/>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Mobile/Tablet Card View */}
<div className="md:hidden space-y-3 p-3 sm:p-4 md:p-6">
{loading ? (
<div className="py-12 text-center text-[#2591D0] text-base">
Memuat data...
</div>
) : filteredData.length === 0 ? (
<div className="py-12 text-center text-[#2591D0] text-base bg-white border border-blue-200 rounded-lg">
{searchTerm ? 'Tidak ada data yang sesuai dengan pencarian' : 'Tidak ada data billing'}
</div>
) : (
filteredData.map((item, index) => {
const idBilling = item.ID_Billing || item.id_billing || 0;
const idPasien = item.ID_Pasien || item.id_pasien || 0;
const namaPasien = item.Nama_Pasien || item.nama_pasien || 'N/A';
const billingSign = item.Billing_Sign || item.billing_sign || '';
const namaDokter = item.Nama_Dokter || item.nama_dokter || '';
return (
<div
key={idBilling || index}
className="bg-white border border-blue-200 rounded-lg shadow-sm p-4 hover:shadow-md transition-shadow"
>
<div className="space-y-3">
{/* ID Pasien */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
ID Pasien
</span>
<span className="text-sm font-semibold text-[#2591D0]">
P.{idPasien.toString().padStart(4, '0')}
</span>
</div>
{/* Nama */}
<div className="flex flex-col space-y-1">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Nama
</span>
<span className="text-sm text-[#2591D0] break-words">
{namaPasien}
</span>
</div>
{/* Dokter */}
<div className="flex flex-col space-y-1">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dokter
</span>
<span className="text-sm text-[#2591D0] break-words">
{namaDokter || '-'}
</span>
</div>
{/* Billing Sign */}
<div className="flex items-center justify-between pt-2 border-t border-blue-100">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Billing Sign
</span>
<div className="flex items-center gap-2">
{(() => {
// Calculate dynamic warning sign if tarif data exists
const dynamicSign = calculateDynamicWarningSign(item.total_tarif_rs, item.total_klaim);
const displaySign = dynamicSign || billingSign; // Use dynamic if available, fallback to DB sign
return (
<span
className={`${getStatusColor(displaySign)} w-16 h-5 rounded-full flex-shrink-0`}
></span>
);
})()}
{userRole === "admin" && (
<FaEdit
onClick={() => {
console.log("🖱️ FaEdit (mobile) clicked! onEdit exists?", !!onEdit, "idBilling:", idBilling, "namaPasien:", namaPasien);
if (onEdit && idBilling) {
console.log("✅ Calling onEdit (mobile) with:", idBilling, namaPasien);
onEdit(idBilling, namaPasien);
} else {
console.error("❌ Cannot call onEdit (mobile) - onEdit exists?", !!onEdit, "idBilling exists?", !!idBilling);
}
}}
className="text-[#2591D0] cursor-pointer hover:text-[#1e7ba8] text-lg flex-shrink-0"
/>
)}
</div>
</div>
{/* Warning Info */}
{item.total_tarif_rs && item.total_klaim && (() => {
// Calculate dynamic warning sign
const dynamicSign = calculateDynamicWarningSign(item.total_tarif_rs, item.total_klaim);
const displaySign = dynamicSign || billingSign;
return (
<div className="mt-3 p-2 rounded-lg" style={{
backgroundColor: displaySign === 'Merah' ? '#fee2e2' : displaySign === 'Kuning' ? '#fef3c7' : '#ecfdf5',
borderLeft: `4px solid ${displaySign === 'Merah' ? '#dc2626' : displaySign === 'Kuning' ? '#f59e0b' : '#10b981'}`
}}>
<p className="text-xs font-semibold" style={{
color: displaySign === 'Merah' ? '#7f1d1d' : displaySign === 'Kuning' ? '#92400e' : '#065f46'
}}>
{displaySign === 'Merah' ? '⚠️ Tarif RS Melebihi' : displaySign === 'Kuning' ? '⚠️ Mendekati Batas' : '✅ Aman'}
</p>
<p className="text-xs mt-1" style={{
color: displaySign === 'Merah' ? '#991b1b' : displaySign === 'Kuning' ? '#b45309' : '#047857'
}}>
RS: Rp {item.total_tarif_rs?.toLocaleString('id-ID')} | BPJS: Rp {item.total_klaim?.toLocaleString('id-ID')}
</p>
</div>
);
})()}
</div>
</div>
);
})
)}
</div>
</div>
</div>
);
};
export default RiwayatBillingPasien;
@@ -0,0 +1,21 @@
"use client";
const BukuSaku = () => {
return (
<div className="p-3 sm:p-4 md:p-6 bg-white min-h-screen">
<h1 className="text-xl sm:text-2xl font-semibold text-[#2591D0] mb-3 sm:mb-4">
Buku Saku
</h1>
<div className="w-full h-[calc(100vh-100px)] sm:h-[calc(100vh-120px)] md:h-[calc(100vh-140px)] rounded-lg overflow-hidden border border-blue-200">
<iframe
src="./assets/PDF/BUKU SAKU PENGISIAN RESUME MEDIS IKPK REVISI (1).pdf"
className="w-full h-full"
title="Buku Saku PDF"
/>
</div>
</div>
);
};
export default BukuSaku;
@@ -0,0 +1,240 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { FaSearch, FaExternalLinkAlt, FaEdit } from 'react-icons/fa';
import BillingAktif from "./billing_aktif";
import { getAllBilling } from "@/lib/api-helper";
// Static logo import
import logoImage from "../../../public/assets/LOGO_CAREIT.svg";
interface DashboardAdminProps {
onLogout?: () => void;
onEditBilling?: (billingId: number) => void;
}
interface BillingData {
ID_Billing?: number;
ID_Pasien?: number;
Nama_pasien?: string;
ruangan?: string;
Ruangan?: string;
[key: string]: any;
}
const DashboardAdmin = ({ onLogout, onEditBilling }: DashboardAdminProps) => {
const [activeRuangan, setActiveRuangan] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
interface RuanganItem {
name: string;
unsignedCount: number;
}
const [ruanganItems, setRuanganItems] = useState<RuanganItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Fetch ruangan dari billing/pasien data
useEffect(() => {
const fetchRuangan = async () => {
try {
setLoading(true);
setError("");
const response = await getAllBilling();
if (response.error) {
setError(response.error);
setRuanganItems([]);
return;
}
// Extract data dari response - bisa response.data atau response.data.data
const billingArray = (response.data as any)?.data || (response.data as any);
if (billingArray && Array.isArray(billingArray)) {
// Build a map of ruangan -> count of unsigned billing_sign
const ruanganMap = new Map<string, { total: number; unsigned: number }>();
billingArray.forEach((item: BillingData) => {
// Backend now returns ruangan with name (not ID), so use directly
const ruangan = (item.ruangan || item.Ruangan || "").toString().trim();
if (!ruangan) return;
const prev = ruanganMap.get(ruangan) || { total: 0, unsigned: 0 };
prev.total += 1;
// Determine whether this billing is unsigned. Treat falsy/empty/"0"/"null" as unsigned.
const sign = item.billing_sign as any;
const isUnsigned = !sign || sign === "0" || sign === "null" || (typeof sign === "string" && sign.trim() === "");
if (isUnsigned) prev.unsigned += 1;
ruanganMap.set(ruangan, prev);
});
// Convert map to array of RuanganItem sorted by name
const ruanganArray: RuanganItem[] = Array.from(ruanganMap.entries())
.map(([name, counts]) => ({ name, unsignedCount: counts.unsigned }))
.sort((a, b) => a.name.localeCompare(b.name));
setRuanganItems(ruanganArray);
// Set active ruangan ke yang pertama kalo ada
if (ruanganArray.length > 0) {
setActiveRuangan(ruanganArray[0].name);
}
} else {
setRuanganItems([]);
setActiveRuangan(null);
}
} catch (err) {
console.error("Error fetching ruangan:", err);
setError(
err instanceof Error
? err.message
: "Gagal mengambil data ruangan"
);
setRuanganItems([]);
} finally {
setLoading(false);
}
};
fetchRuangan();
}, []);
const handleLogout = () => {
localStorage.removeItem("isAuthenticated");
localStorage.removeItem("userRole");
if (onLogout) {
onLogout();
}
};
return (
<div className="flex min-h-screen bg-[#F5FAFD]">
{/* Hamburger Menu Button - Mobile Only */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="fixed top-4 left-4 z-50 lg:hidden bg-[#2591D0] text-white p-2 rounded-lg shadow-lg hover:bg-[#1e7ba8] transition-colors"
aria-label="Toggle sidebar"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isSidebarOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</button>
{/* Mobile Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Left Sidebar */}
<div
className={`
fixed top-0 left-0
w-56 sm:w-60 md:w-64 h-screen
bg-[#ECF6FB] rounded-r-2xl sm:rounded-r-3xl shadow-lg
transition-transform duration-300 z-50
overflow-y-auto
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0 lg:relative lg:rounded-r-none lg:rounded-l-3xl
`}
>
{/* Logo */}
<div className="p-3 sm:p-5 flex justify-center border-b border-blue-100">
<Image
src={logoImage}
alt="CARE-IT Logo"
width={140}
height={70}
className="object-contain w-24 sm:w-32 md:w-36"
/>
</div>
{/* Navigation Menu */}
<nav className="mt-4 sm:mt-6 space-y-1 px-2 sm:px-4">
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#2591D0]"></div>
<span className="ml-2 text-xs text-gray-500">Loading...</span>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-xs text-red-600">
{error}
</div>
) : ruanganItems.length === 0 ? (
<div className="text-center py-6">
<p className="text-xs text-gray-500">Tidak ada data ruangan</p>
</div>
) : (
ruanganItems.map((ruanganItem, index) => {
const ruanganName = ruanganItem.name;
const isActive = activeRuangan === ruanganName;
return (
<button
key={index}
onClick={() => {
setActiveRuangan(ruanganName);
setIsSidebarOpen(false);
}}
className={`
w-full flex items-center justify-between py-2 sm:py-3 px-2 sm:px-4
rounded-lg text-left transition-all
${isActive
? "bg-white text-[#2591D0] border-l-4 border-[#2591D0] font-medium"
: "text-gray-400 hover:bg-white hover:text-gray-600"
}
`}
>
<span className="text-xs sm:text-sm">{ruanganName}</span>
{ruanganItem.unsignedCount > 0 && (
<span className="ml-2 inline-flex items-center justify-center bg-red-500 text-white text-[10px] font-semibold h-5 px-2 rounded-full">
{ruanganItem.unsignedCount}
</span>
)}
</button>
);
})
)}
</nav>
</div>
{/* Main Content Area */}
<div className="flex-1 w-full lg:w-auto lg:ml-0">
<BillingAktif
onLogout={handleLogout}
userRole="admin"
onEdit={onEditBilling}
selectedRuangan={activeRuangan}
/>
</div>
</div>
);
};
export default DashboardAdmin;
@@ -0,0 +1,486 @@
"use client";
import React, { useState, useEffect } from "react";
import TarifRumahSakit from "./tarif-rumah-sakit";
import Image from "next/image";
import Sidebar from "./sidebar";
import TarifBPJS from "./tarif-bpjs";
import BillingPasien from "./billing-pasien";
import RiwayatBillingPasien from "./riwayat-billing-pasien";
import BukuSaku from "./buku-saku";
import Fornas from "./fornas";
import AdminRuangan from "./admin-ruangan";
import DashboardAdmin from "./dashboard_Admin_Ruangan";
import { getAllBilling } from "@/lib/api-helper";
// Static imports
import header1Image from "../../../public/assets/dashboard_dokter/header1.svg";
import header2Image from "../../../public/assets/dashboard_dokter/header2.svg";
import warningGreen from "../../../public/assets/dashboard_dokter/warning-green.svg";
import warningYellow from "../../../public/assets/dashboard_dokter/warning-yellow.svg";
import warningRed from "../../../public/assets/dashboard_dokter/warning-red.svg";
interface DashboardProps {
onLogout?: () => void;
onEditBilling?: (billingId: number) => void;
onActiveMenuChange?: (menu: string) => void;
initialActiveMenu?: string;
}
const Dashboard = ({ onLogout, onEditBilling, onActiveMenuChange, initialActiveMenu }: DashboardProps) => {
const [activeMenu, setActiveMenu] = useState(initialActiveMenu || "Home");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [namaDokter, setNamaDokter] = useState("Dokter");
const [userRole, setUserRole] = useState<"dokter" | "admin" | "">("");
const [namaUser, setNamaUser] = useState("Pengguna");
const [totalBilling, setTotalBilling] = useState(0);
const [exceededBilling, setExceededBilling] = useState(0);
const [warningBilling, setWarningBilling] = useState(0);
const [normalBilling, setNormalBilling] = useState(0);
// Ambil nama dokter/admin dari localStorage sama user role
useEffect(() => {
const role = localStorage.getItem("userRole") as "dokter" | "admin" | "";
setUserRole(role || "");
// Try to get dokter/admin data from localStorage
const dokterData = localStorage.getItem("dokter");
const adminData = localStorage.getItem("admin");
if (role === "admin" && adminData) {
try {
const admin = JSON.parse(adminData);
// Backend returns nama_admin field for admin
if (admin.nama_admin) {
setNamaUser(admin.nama_admin);
setNamaDokter(admin.nama_admin); // Keep for backward compatibility
}
} catch (e) {
console.error("Error parsing admin data:", e);
}
} else if (dokterData) {
try {
const dokter = JSON.parse(dokterData);
if (dokter.nama) {
setNamaUser(dokter.nama);
setNamaDokter(dokter.nama);
}
} catch (e) {
console.error("Error parsing dokter data:", e);
}
}
}, []);
// Track activeMenu changes and notify parent
useEffect(() => {
if (onActiveMenuChange) {
onActiveMenuChange(activeMenu);
localStorage.setItem("activeMenu", activeMenu);
}
}, [activeMenu, onActiveMenuChange]);
// Fetch billing data buat pie chart
useEffect(() => {
const fetchBillingStats = async () => {
try {
const response = await getAllBilling();
if (response.data) {
let billingArray: any[] = [];
// Handle berbagai struktur response
if (Array.isArray(response.data)) {
billingArray = response.data;
} else if ((response.data as any).data && Array.isArray((response.data as any).data)) {
billingArray = (response.data as any).data;
} else if ((response.data as any).status && (response.data as any).data && Array.isArray((response.data as any).data)) {
billingArray = (response.data as any).data;
}
// Count by billing sign: merah (exceeded), kuning (warning), hijau (normal)
const total = billingArray.length;
let exceeded = 0;
let warning = 0;
let normal = 0;
console.log('📊 Total billing items:', total);
console.log('📋 Billing data sample:', billingArray.slice(0, 3));
billingArray.forEach((item, index) => {
const sign = (item.Billing_Sign || item.billing_sign || "").toLowerCase().trim();
console.log(`Item ${index} - Billing_Sign: "${item.Billing_Sign}", billing_sign: "${item.billing_sign}", normalized: "${sign}"`);
if (sign === "merah" || sign === "red" || sign === "orange") {
exceeded++;
console.log(` ✓ Counted as MERAH (exceeded)`);
} else if (sign === "kuning" || sign === "yellow") {
warning++;
console.log(` ✓ Counted as KUNING (warning)`);
} else if (sign === "hijau" || sign === "green") {
normal++;
console.log(` ✓ Counted as HIJAU (normal)`);
} else {
console.log(` ⚠️ Not counted - sign: "${sign}"`);
}
});
console.log('📈 Final counts - Normal:', normal, 'Warning:', warning, 'Exceeded:', exceeded);
setTotalBilling(total);
setExceededBilling(exceeded);
setWarningBilling(warning);
setNormalBilling(normal);
}
} catch (err) {
console.error("Error fetching billing stats:", err);
// Use fallback values
setTotalBilling(10);
setExceededBilling(2);
setWarningBilling(3);
setNormalBilling(5);
}
};
fetchBillingStats();
}, []);
const handleLogout = () => {
// Clear authentication
localStorage.removeItem("isAuthenticated");
// Call parent logout handler
if (onLogout) {
onLogout();
}
};
const menuItems = [
{ name: "Home", icon: "🏠" },
{ name: "Tarif Rumah Sakit", icon: "🏥" },
{ name: "Tarif BPJS", icon: "💳" },
{ name: "Billing Pasien", icon: "📊" },
{ name: "Riwayat Billing Pasien", icon: "🕒" },
{ name: "Buku Saku", icon: "📚" },
{ name: "Fornas", icon: "⚙️" },
];
const warningItems = [
{
message: "Billing mencapai 50% dari Tarif INA-CBG",
icon: warningGreen,
},
{
message: "Billing mencapai 80% dari Tarif INA-CBG",
icon: warningYellow,
},
{
message: "Billing melebihi dari Tarif INA-CBG",
icon: warningRed,
},
];
return (
<div className="flex min-h-screen bg-[#F5FAFD] overflow-hidden w-full">
{/* Hamburger Menu Button - Mobile Only */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="fixed top-safe left-4 z-50 lg:hidden bg-[#2591D0] text-white p-2.5 sm:p-3 rounded-lg shadow-lg hover:bg-[#1e7ba8] transition-colors"
aria-label="Toggle sidebar"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isSidebarOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</button>
{/* Sidebar */}
<Sidebar
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
activeMenu={activeMenu}
setActiveMenu={setActiveMenu}
userRole={userRole}
/>
{/* Main Content - Offset by sidebar width on lg+ */}
<div className="flex-1 w-full max-w-full px-3 sm:px-4 md:px-6 py-4 sm:py-6 overflow-y-auto ml-0 lg:ml-72">
{/* Render content based on active menu */}
{activeMenu === "Tarif Rumah Sakit" && <TarifRumahSakit />}
{activeMenu === "Tarif BPJS" && <TarifBPJS />}
{activeMenu === "Billing Pasien" && <BillingPasien onEditBilling={onEditBilling} />}
{activeMenu === "Riwayat Billing Pasien" && (
<RiwayatBillingPasien
userRole={localStorage.getItem("userRole") === "admin" ? "admin" : "dokter"}
onEdit={onEditBilling}
/>
)}
{activeMenu === "Buku Saku" && <BukuSaku />}
{activeMenu === "Fornas" && <Fornas />}
{activeMenu === "Ruangan" && (
<DashboardAdmin onLogout={onLogout} onEditBilling={onEditBilling} />
)}
{activeMenu === "Home" && (
<>
{/* Top Header */}
{/* Content Area */}
<div className="flex-1 p-2 sm:p-4 md:p-6 overflow-y-auto">
{/* Greeting Card */}
<div className="mb-4 sm:mb-6">
{/* DATE + LOGOUT */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-2 gap-2 sm:gap-0">
<p className="text-blue-500 text-xs sm:text-sm">
{new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</p>
</div>
{/* Fixed Logout Button - Top Right - Only show on Home page */}
{activeMenu === "Home" && (
<button
onClick={handleLogout}
className="fixed top-4 right-4 z-50 flex items-center space-x-1 sm:space-x-2 bg-white sm:bg-transparent px-2 sm:px-0 py-1.5 sm:py-0 rounded-lg sm:rounded-none shadow-md sm:shadow-none text-blue-500 hover:text-red-500 transition text-xs sm:text-sm font-medium"
title="Logout"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 sm:h-5 sm:w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H6a2 2 0 01-2-2V7a2 2 0 012-2h5a2 2 0 012 2v1"
/>
</svg>
<span className="hidden sm:inline text-xs sm:text-sm font-medium">Logout</span>
</button>
)}
{/* MAIN CARD */}
<div className="relative bg-gradient-to-r from-[#7CC3EA] to-[#5BAFE2] rounded-xl sm:rounded-2xl p-4 sm:p-6 md:p-10 text-white overflow-hidden">
{/* ORNAMENT CIRCLES */}
<span className="absolute top-4 sm:top-6 left-1/3 w-3 h-3 sm:w-4 sm:h-4 border-2 sm:border-4 border-yellow-400 rounded-full opacity-70"></span>
<span className="absolute bottom-6 sm:bottom-10 right-1/3 w-3 h-3 sm:w-4 sm:h-4 border-2 sm:border-4 border-yellow-400 rounded-full opacity-70"></span>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 sm:gap-6">
{/* LEFT IMAGE */}
<div className="w-24 sm:w-32 md:w-36 flex-shrink-0">
<Image
src={header2Image}
alt="Medicine"
width={144}
height={144}
className="w-full object-contain"
/>
</div>
{/* TEXT CENTER */}
<div className="text-center sm:text-left max-w-lg flex-1">
<h2 className="text-xl sm:text-2xl md:text-3xl font-semibold mb-2 sm:mb-3">
Halo, {namaUser}
</h2>
<p className="text-xs sm:text-sm md:text-base text-blue-50 leading-relaxed">
Care It memudahkan dokter memverifikasi tarif tindakan agar sesuai standar BPJS, dengan fitur warning billing sign yang memberi peringatan otomatis saat tarif melebihi batas, sehingga rumah sakit tetap patuh, aman, dan bebas overbilling.
</p>
</div>
{/* RIGHT IMAGE */}
<div className="w-20 sm:w-28 md:w-32 flex-shrink-0">
<Image
src={header1Image}
alt="Clipboard"
width={128}
height={128}
className="w-full object-contain"
/>
</div>
</div>
</div>
</div>
{/* Warning Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 sm:gap-3 md:gap-4 lg:gap-6">
<div>
<h4 className="text-sm sm:text-base md:text-lg font-semibold text-gray-800 mb-3 sm:mb-4">
Warning Billing Sign
</h4>
<div className="space-y-2 sm:space-y-3 md:space-y-4">
{warningItems.map((warning, index) => (
<div
key={index}
className="bg-[#EAF6FF] rounded-lg sm:rounded-xl p-3 sm:p-4 md:p-6 flex items-center space-x-3 sm:space-x-4"
>
{/* ICON IMAGE */}
<div className="w-10 h-10 sm:w-12 sm:h-12 md:w-14 md:h-14 flex items-center justify-center flex-shrink-0">
<Image
src={warning.icon}
alt="warning icon"
width={64}
height={64}
className="w-full h-full object-contain"
/>
</div>
{/* TEXT */}
<div className="min-w-0 flex-1">
<p className="text-xs sm:text-sm md:text-lg font-medium text-[#1E88E5] break-words">
{warning.message}
</p>
</div>
</div>
))}
</div>
</div>
{/* Right Side - Additional Content */}
<div className="bg-[#D8EEF9] rounded-xl p-4 sm:p-6 flex flex-col items-center justify-center min-h-[200px] sm:min-h-[280px]">
{/* Pie Chart */}
<div className="flex flex-col items-center gap-4 sm:gap-6 w-full">
{/* SVG Pie Chart */}
<div className="w-40 h-40 sm:w-48 sm:h-48 md:w-56 md:h-56">
<svg viewBox="0 0 120 120" className="w-full h-full drop-shadow-lg">
{/* Background circle */}
<circle cx="60" cy="60" r="50" fill="none" stroke="#E0E0E0" strokeWidth="2" />
{/* Pie segments - calculate angles based on data */}
{totalBilling > 0 && (
<>
{/* Exceeded segment (red) - starts at top (0°) */}
{exceededBilling > 0 && (
<path
d={(() => {
const angle1 = (exceededBilling / totalBilling) * 360;
const rad1 = (angle1 * Math.PI) / 180;
const x1 = 60 + 50 * Math.sin(rad1);
const y1 = 60 - 50 * Math.cos(rad1);
const largeArc = angle1 > 180 ? 1 : 0;
return `M 60 10 A 50 50 0 ${largeArc} 1 ${x1} ${y1} L 60 60 Z`;
})()}
fill="#FF5252"
stroke="white"
strokeWidth="2"
/>
)}
{/* Warning segment (yellow) */}
{warningBilling > 0 && (
<path
d={(() => {
const angle1 = (exceededBilling / totalBilling) * 360;
const angle2 = ((exceededBilling + warningBilling) / totalBilling) * 360;
const rad1 = (angle1 * Math.PI) / 180;
const rad2 = (angle2 * Math.PI) / 180;
const x1 = 60 + 50 * Math.sin(rad1);
const y1 = 60 - 50 * Math.cos(rad1);
const x2 = 60 + 50 * Math.sin(rad2);
const y2 = 60 - 50 * Math.cos(rad2);
const largeArc = (angle2 - angle1) > 180 ? 1 : 0;
return `M ${x1} ${y1} A 50 50 0 ${largeArc} 1 ${x2} ${y2} L 60 60 Z`;
})()}
fill="#FFC107"
stroke="white"
strokeWidth="2"
/>
)}
{/* Normal segment (green) */}
{normalBilling > 0 && (
<path
d={(() => {
const angle2 = ((exceededBilling + warningBilling) / totalBilling) * 360;
const angle3 = 360;
const rad2 = (angle2 * Math.PI) / 180;
const x2 = 60 + 50 * Math.sin(rad2);
const y2 = 60 - 50 * Math.cos(rad2);
const largeArc = (angle3 - angle2) > 180 ? 1 : 0;
return `M ${x2} ${y2} A 50 50 0 ${largeArc} 1 60 10 L 60 60 Z`;
})()}
fill="#4CAF50"
stroke="white"
strokeWidth="2"
/>
)}
</>
)}
</svg>
</div>
{/* Legend */}
<div className="flex gap-4 sm:gap-6 justify-center flex-wrap">
{exceededBilling > 0 && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 sm:w-5 sm:h-5 bg-[#FF5252] rounded-sm"></div>
<span className="text-xs sm:text-sm font-medium text-gray-700">Melebihi ({exceededBilling})</span>
</div>
)}
{warningBilling > 0 && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 sm:w-5 sm:h-5 bg-[#FFC107] rounded-sm"></div>
<span className="text-xs sm:text-sm font-medium text-gray-700">Peringatan ({warningBilling})</span>
</div>
)}
{normalBilling > 0 && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 sm:w-5 sm:h-5 bg-[#4CAF50] rounded-sm"></div>
<span className="text-xs sm:text-sm font-medium text-gray-700">Normal ({normalBilling})</span>
</div>
)}
</div>
{/* Text */}
<div className="text-center">
<p className="text-center text-lg sm:text-xl md:text-2xl font-bold">
<span className="text-[#FF5252]">{exceededBilling}</span>
<span className="text-gray-700"> dari </span>
<span className="text-[#2591D0]">{totalBilling}</span>
<span className="text-gray-700"> Pasien</span>
</p>
<p className="text-center text-sm sm:text-base text-[#2591D0] font-semibold mt-2">
melebihi klaim BPJS
</p>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
</div>
);
};
export default Dashboard;
@@ -0,0 +1,787 @@
"use client";
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { FaTrash, FaPlus, FaChevronDown } from 'react-icons/fa';
import { apiFetch } from '@/lib/api-helper';
interface TarifBPJSRawatInap {
KodeINA: string;
Deskripsi: string;
Kelas1: number;
Kelas2: number;
Kelas3: number;
}
interface TarifBPJSRawatJalan {
KodeINA: string;
Deskripsi: string;
TarifINACBG?: number;
tarif_inacbg?: number;
}
interface EditINACBGModalProps {
isOpen: boolean;
billingId: number;
currentData: {
kode_inacbg?: string[];
tipe_inacbg?: "RI" | "RJ";
kelas?: string;
total_klaim?: number;
};
onClose: () => void;
onSuccess: () => void;
}
const EditINACBGModal: React.FC<EditINACBGModalProps> = ({
isOpen,
billingId,
currentData,
onClose,
onSuccess,
}) => {
const [tipeInacbg, setTipeInacbg] = useState<"RI" | "RJ">("RI");
const [selectedInacbgCodes, setSelectedInacbgCodes] = useState<string[]>([]);
const [existingInacbgCodes, setExistingInacbgCodes] = useState<string[]>([]);
const [deletedInacbgCodes, setDeletedInacbgCodes] = useState<string[]>([]);
const [inacbgSearch, setInacbgSearch] = useState('');
const [inacbgRIData, setInacbgRIData] = useState<TarifBPJSRawatInap[]>([]);
const [inacbgRJData, setInacbgRJData] = useState<TarifBPJSRawatJalan[]>([]);
const [inacbgDropdownOpen, setInacbgDropdownOpen] = useState(false);
const inacbgInputRef = useRef<HTMLInputElement>(null);
const inacbgDropdownRef = useRef<HTMLDivElement>(null);
const [kelas, setKelas] = useState('');
const [totalKlaimOriginal, setTotalKlaimOriginal] = useState<number>(0);
const [totalTarifRS, setTotalTarifRS] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [billingSign, setBillingSign] = useState<string>('');
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
// ✅ Track previous billing ID dan codes untuk prevent multiple re-syncs
const prevBillingIdRef = useRef<number | null>(null);
const prevCodesJsonRef = useRef<string>('');
const prevTotalClaimRef = useRef<number>(0);
// ✅ DERIVED STATE: Calculate total klaim dengan real-time delta
// Formula: baseKlaim + adjustment (dari deleted codes - dan added codes +)
// baseKlaim = totalKlaimOriginal (dari DB, yang sudah ada)
// adjustment = -tarif(deletedCodes) + tarif(newCodes)
const totalKlaimBPJS = useMemo(() => {
if (!kelas || (inacbgRIData.length === 0 && inacbgRJData.length === 0)) {
return totalKlaimOriginal;
}
const kelasMatch = kelas.match(/(\d+)/);
const kelasNumber = kelasMatch ? parseInt(kelasMatch[1]) : 1;
// ✅ Base klaim = yang sudah ada di DB
const baseKlaim = totalKlaimOriginal || 0;
// ✅ Kode yang DIHAPUS dari selection
const deletedCodes = existingInacbgCodes.filter(c => !selectedInacbgCodes.includes(c));
// ✅ Kode yang DITAMBAH (baru)
const newCodes = selectedInacbgCodes.filter(c => !existingInacbgCodes.includes(c));
// Helper function to get tarif for a code
const getTarifForCode = (code: string): number => {
let tarif = 0;
if (tipeInacbg === 'RI') {
const riItem = inacbgRIData.find(i => i.KodeINA === code);
if (riItem) {
if (kelasNumber === 1) tarif = riItem.Kelas1 || 0;
else if (kelasNumber === 2) tarif = riItem.Kelas2 || 0;
else if (kelasNumber === 3) tarif = riItem.Kelas3 || 0;
}
} else {
const rjItem = inacbgRJData.find(i => i.KodeINA === code);
tarif = rjItem?.TarifINACBG || rjItem?.tarif_inacbg || 0;
}
return tarif;
};
let adjustment = 0;
// Kurangi tarif kode yang dihapus
deletedCodes.forEach(code => {
const tarif = getTarifForCode(code);
adjustment -= tarif * 0.75; // potong 25%
});
// Tambah tarif kode baru
newCodes.forEach(code => {
const tarif = getTarifForCode(code);
adjustment += tarif * 0.75; // potong 25%
});
const finalTotal = baseKlaim + adjustment;
console.log(`💰 Total Klaim Calculation (Real-time Delta):
- Base (from DB): ${baseKlaim}
- Deleted codes: ${deletedCodes.length}
- New codes added: ${newCodes.length}
- Adjustment: ${adjustment}
- Final Total: ${finalTotal}`);
return Math.max(0, finalTotal);
}, [
selectedInacbgCodes,
existingInacbgCodes,
inacbgRIData,
inacbgRJData,
tipeInacbg,
kelas,
]);
// Debug: log whenever selectedInacbgCodes changes
useEffect(() => {
if (isOpen) {
console.log('🎯 selectedInacbgCodes updated:', selectedInacbgCodes);
console.log('🎯 selectedInacbgCodes length:', selectedInacbgCodes.length);
console.log('💰 totalKlaimBPJS (computed):', totalKlaimBPJS);
}
}, [selectedInacbgCodes, isOpen, totalKlaimBPJS]);
// ✅ DERIVED STATE: Calculate billing sign as pure function
const billingSignComputed = useMemo(() => {
if (!totalTarifRS || totalTarifRS <= 0 || !totalKlaimBPJS || totalKlaimBPJS <= 0) {
return '';
}
const percentage = (totalTarifRS / totalKlaimBPJS) * 100;
let sign = '';
if (percentage <= 25) {
sign = 'Hijau';
} else if (percentage <= 50) {
sign = 'Kuning';
} else {
sign = 'Merah';
}
console.log(`🎨 Edit INACBG Billing Sign: ${sign} (${percentage.toFixed(2)}%)`);
return sign;
}, [totalTarifRS, totalKlaimBPJS]);
// ✅ Update billingSign state only when computed value changes
useEffect(() => {
setBillingSign(billingSignComputed);
}, [billingSignComputed]);
// Initialize modal data
// ✅ PENTING: Initialize jika modal dibuka (isOpen true) untuk billing ID apapun
// Ini memastikan data reset ketika user membuka modal kembali setelah batal
useEffect(() => {
if (isOpen && currentData) {
// Jika billing ID berubah ATAU modal baru dibuka, reset state
const isNewBilling = billingId !== prevBillingIdRef.current;
const shouldInitialize = isNewBilling || (prevBillingIdRef.current !== null && isOpen);
if (shouldInitialize) {
prevBillingIdRef.current = billingId;
console.log('📋 Edit INACBG Modal opened');
console.log('📋 currentData received:', JSON.stringify(currentData, null, 2));
let codes = Array.isArray(currentData.kode_inacbg) ? currentData.kode_inacbg : [];
// ✅ PENTING: Remove duplicates dari codes yang diterima dari parent (sorted untuk consistent comparison)
codes = Array.from(new Set(codes)).sort();
console.log('📋 De-duplicated codes at init:', codes);
// ✅ Track di ref untuk strict comparison saat sync
prevCodesJsonRef.current = JSON.stringify(codes);
prevTotalClaimRef.current = currentData.total_klaim || 0;
// existingInacbgCodes = baseline dari API
// selectedInacbgCodes = copy dari existing (sama baseline)
setExistingInacbgCodes(codes);
setSelectedInacbgCodes(codes);
setTipeInacbg(currentData.tipe_inacbg || 'RI');
setKelas(currentData.kelas || '');
setTotalKlaimOriginal(currentData.total_klaim || 0);
// ✅ REMOVED: setTotalKlaimBPJS - now computed via useMemo
setDeletedInacbgCodes([]);
setError('');
setSuccess('');
// Fetch INACBG data
const fetchInacbgData = async () => {
try {
const [riResponse, rjResponse] = await Promise.all([
apiFetch<TarifBPJSRawatInap[]>("/tarifBPJSRawatInap"),
apiFetch<TarifBPJSRawatJalan[]>("/tarifBPJSRawatJalan"),
]);
if (riResponse.data) setInacbgRIData(riResponse.data);
if (rjResponse.data) setInacbgRJData(rjResponse.data);
} catch (err) {
console.error('Error fetching INACBG data:', err);
}
};
fetchInacbgData();
// Fetch tarif RS dari API pake async/await yang proper
const fetchTarifRS = async () => {
try {
console.log('🔄 Fetching Tarif RS for billingId:', billingId);
const response = await apiFetch<any>(`/admin/billing/${billingId}`);
console.log('📊 Full response:', response);
console.log('📊 response.data:', response.data);
console.log('📊 response.data.data:', response.data?.data);
// Cek semua kemungkinan struktur
let tarifRS = 0;
// Cek apakah di response.data.total_tarif_rs
if (response.data?.total_tarif_rs !== undefined) {
tarifRS = response.data.total_tarif_rs;
console.log('✅ Found at response.data.total_tarif_rs:', tarifRS);
}
// Cek apakah di response.data.data.total_tarif_rs
else if (response.data?.data?.total_tarif_rs !== undefined) {
tarifRS = response.data.data.total_tarif_rs;
console.log('✅ Found at response.data.data.total_tarif_rs:', tarifRS);
}
// Cek apakah field punya nama berbeda
else if (response.data?.data) {
console.log('📋 Available fields in response.data.data:', Object.keys(response.data.data));
// Coba cari field yang mengandung "tarif"
const tarifField = Object.keys(response.data.data).find(key =>
key.toLowerCase().includes('tarif') && key.toLowerCase().includes('rs')
);
if (tarifField) {
tarifRS = response.data.data[tarifField];
console.log(`✅ Found at response.data.data.${tarifField}:`, tarifRS);
}
}
if (tarifRS > 0 || tarifRS === 0) {
setTotalTarifRS(tarifRS);
console.log('✅ TotalTarifRS state set to:', tarifRS);
}
} catch (err) {
console.error('❌ Error fetching tarif RS:', err);
}
};
fetchTarifRS();
}
}
}, [isOpen, billingId, currentData]);
// ✅ UPDATE: Handle parent update dengan currentData baru (setelah save)
// Detect parent refresh dan sync dengan fresh baseline
useEffect(() => {
if (isOpen && currentData && billingId === prevBillingIdRef.current) {
// Modal sudah terbuka untuk billing yang sama
const incomingCodes = Array.isArray(currentData.kode_inacbg)
? Array.from(new Set(currentData.kode_inacbg)).sort() // ✅ De-dup dan sort
: [];
const incomingCodesJson = JSON.stringify(incomingCodes);
const incomingTotalClaim = currentData.total_klaim || 0;
// ✅ Strict comparison: hanya sync jika benar2 berbeda
const codesChanged = incomingCodesJson !== prevCodesJsonRef.current;
const totalChanged = incomingTotalClaim !== prevTotalClaimRef.current;
if (codesChanged || totalChanged) {
console.log('🔄 Parent refresh detected, syncing...');
console.log(' Old codes:', prevCodesJsonRef.current);
console.log(' New codes:', incomingCodesJson);
// ✅ Update refs FIRST untuk prevent re-trigger
prevCodesJsonRef.current = incomingCodesJson;
prevTotalClaimRef.current = incomingTotalClaim;
// ✅ Reset BOTH ke baseline fresh dari API
setExistingInacbgCodes(incomingCodes);
setSelectedInacbgCodes(incomingCodes);
setTotalKlaimOriginal(incomingTotalClaim);
setDeletedInacbgCodes([]);
console.log('✅ Synced: existing + selected reset to fresh baseline');
}
}
}, [isOpen, billingId, currentData.total_klaim]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (inacbgDropdownOpen) {
const isClickInsideInput = inacbgInputRef.current?.contains(target);
const isClickInsideDropdown = inacbgDropdownRef.current?.contains(target);
if (!isClickInsideInput && !isClickInsideDropdown) {
setInacbgDropdownOpen(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [inacbgDropdownOpen]);
const filteredInacbgCodes = () => {
const kelasMatch = kelas.match(/(\d+)/);
const kelasNumber = kelasMatch ? parseInt(kelasMatch[1]) : 1;
const data =
tipeInacbg === "RI"
? inacbgRIData.map((item) => {
let tarifValue = item.Kelas1;
if (kelasNumber === 1) tarifValue = item.Kelas1;
else if (kelasNumber === 2) tarifValue = item.Kelas2;
else if (kelasNumber === 3) tarifValue = item.Kelas3;
return {
code: item.KodeINA,
description: item.Deskripsi,
tarif: tarifValue,
};
})
: inacbgRJData.map((item) => ({
code: item.KodeINA,
description: item.Deskripsi,
tarif: item.TarifINACBG || item.tarif_inacbg || 0,
}));
if (!inacbgSearch) return data;
return data.filter(
(item) =>
item.code.toLowerCase().includes(inacbgSearch.toLowerCase()) ||
item.description.toLowerCase().includes(inacbgSearch.toLowerCase())
);
};
const getInacbgTarifRaw = (code: string): number | null => {
if (!code) return null;
const kelasMatch = kelas.match(/(\d+)/);
const kelasNumber = kelasMatch ? parseInt(kelasMatch[1]) : 1;
const riItem = inacbgRIData.find((item) => item.KodeINA === code);
if (riItem) {
if (kelasNumber === 1) return riItem.Kelas1 || 0;
if (kelasNumber === 2) return riItem.Kelas2 || 0;
if (kelasNumber === 3) return riItem.Kelas3 || 0;
return riItem.Kelas1 || 0;
}
const rjItem = inacbgRJData.find((item) => item.KodeINA === code);
if (rjItem) return rjItem.TarifINACBG || rjItem.tarif_inacbg || 0;
return null;
};
const handleAddInacbg = (code?: string) => {
const filtered = filteredInacbgCodes();
const codeToAdd = code || filtered[0]?.code;
if (!codeToAdd) {
setError("Pilih kode INA CBG terlebih dahulu");
return;
}
if (selectedInacbgCodes.includes(codeToAdd)) {
setError("Kode INA CBG sudah ditambahkan");
return;
}
setSelectedInacbgCodes((prev) => {
const next = Array.from(new Set([...prev, codeToAdd]));
// Recompute deleted codes deterministically (existing - current)
const recomputedDeleted = existingInacbgCodes.filter(c => !next.includes(c));
setDeletedInacbgCodes(recomputedDeleted);
return next;
});
setInacbgSearch("");
setInacbgDropdownOpen(false);
setError("");
};
const handleRemoveInacbg = (idx: number) => {
// Hapus berdasarkan index, bukan code (agar duplikat tidak semua terhapus)
const codeToDelete = selectedInacbgCodes[idx];
const newCodes = selectedInacbgCodes.filter((_, i) => i !== idx);
const uniqueNewCodes = Array.from(new Set(newCodes));
setSelectedInacbgCodes(uniqueNewCodes);
// Recompute deleted codes deterministically as existing - current
const recomputedDeleted = existingInacbgCodes.filter(code => !uniqueNewCodes.includes(code));
setDeletedInacbgCodes(recomputedDeleted);
console.log(`✅ INACBG code removed from list: ${codeToDelete}`);
console.log(`📋 recomputed deletedInacbgCodes:`, recomputedDeleted);
};
const handleSave = async () => {
// Cek apakah ada perubahan
const hasChanges = JSON.stringify(selectedInacbgCodes.sort()) !== JSON.stringify(existingInacbgCodes.sort());
if (!hasChanges) {
setError("Tidak ada perubahan yang dilakukan");
return;
}
// Buka confirm modal terlebih dahulu
setIsConfirmModalOpen(true);
};
const confirmSubmit = async () => {
setIsConfirmModalOpen(false);
setLoading(true);
setError("");
setSuccess("");
try {
// Hapus duplikat dari selectedInacbgCodes sebelum kirim
const uniqueSelectedCodes = Array.from(new Set(selectedInacbgCodes));
// Hitung kode yang akan Ditambah: hanya yang baru (selected - existing)
const codesToAdd = uniqueSelectedCodes.filter(code => !existingInacbgCodes.includes(code));
// Hitung kode yang akan Dihapus: existing - selected
const actualDeletedCodes = existingInacbgCodes.filter(code => !uniqueSelectedCodes.includes(code));
// Jika tidak ada kode yang tersisa (kosong), total klaim baru = 0
const totalKlaimBaru = uniqueSelectedCodes.length === 0 ? 0 : totalKlaimBPJS;
const payload = {
id_billing: billingId,
tipe_inacbg: tipeInacbg,
kode_inacbg: codesToAdd, // Kode yang harus ditambahkan (prevent duplicate append)
kode_delete: actualDeletedCodes, // Kode yang dihapus
total_klaim: totalKlaimBaru,
billing_sign: billingSign,
};
console.log("📤 Sending EDIT INACBG payload:", payload);
console.log("📋 actualDeletedCodes:", actualDeletedCodes);
const response = await apiFetch<{ status: string; message: string }>(
"/admin/inacbg",
{
method: "PUT",
body: JSON.stringify(payload),
}
);
if (response.error) {
setError(response.error);
return;
}
setSuccess("Data INA CBG berhasil diperbarui");
// Dispatch event untuk notify parent component
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('billingDataUpdated', {
detail: {
billingId,
timestamp: new Date().getTime(),
selectedInacbgCodes: uniqueSelectedCodes,
totalKlaimBPJS,
billingSign,
},
})
);
}
// ✅ PENTING: Reset deletedInacbgCodes setelah save sukses
setDeletedInacbgCodes([]);
setTimeout(() => {
onSuccess();
onClose();
}, 1500);
} catch (err) {
setError(
err instanceof Error ? err.message : "Terjadi kesalahan saat menyimpan data"
);
} finally {
setLoading(false);
}
};
const getBillingSignColor = (sign: string) => {
switch (sign) {
case 'Hijau':
return { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-700', dot: 'bg-green-500' };
case 'Kuning':
return { bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-700', dot: 'bg-yellow-500' };
case 'Merah':
return { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-700', dot: 'bg-red-500' };
default:
return { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400' };
}
};
if (!isOpen) return null;
const signColor = getBillingSignColor(billingSign);
const maxWidth = 'max-w-2xl';
const fullWidth = 'w-full';
return (
<div className="fixed inset-0 bg-white/30 backdrop-blur-sm flex items-center justify-center z-[200] p-4">
<div className={`${maxWidth} ${fullWidth} bg-white rounded-2xl shadow-2xl max-h-[90vh] overflow-y-auto`}>
{/* Header */}
<div className="sticky top-0 bg-gradient-to-r from-blue-50 to-blue-100 p-4 sm:p-6 border-b border-blue-200 z-10">
<h2 className="text-lg sm:text-2xl font-bold text-[#2591D0] flex items-center gap-2">
Edit INA CBG
</h2>
<p className="text-xs sm:text-sm text-gray-600 mt-1">
Ubah kode INA CBG dan lihat perhitungan klaim secara real-time
</p>
</div>
{/* Content */}
<div className="p-4 sm:p-6 space-y-4">
{/* Error/Success Messages */}
{error && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
{success && (
<div className="p-3 bg-green-100 border border-green-400 text-green-700 rounded-lg text-sm">
{success}
</div>
)}
{/* Tipe INA CBG */}
<div>
<label className="block text-sm font-bold text-[#2591D0] mb-2">
Tipe INA CBG
</label>
<div className="relative">
<select
value={tipeInacbg}
onChange={(e) => {
setTipeInacbg(e.target.value as "RI" | "RJ");
setSelectedInacbgCodes([]);
}}
className="w-full border border-blue-200 rounded-lg py-2 px-3 text-[#2591D0] focus:ring-2 focus:ring-blue-400 focus:border-transparent appearance-none bg-white"
>
<option value="RI">Rawat Inap (RI)</option>
<option value="RJ">Rawat Jalan (RJ)</option>
</select>
<FaChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-400 pointer-events-none text-sm" />
</div>
</div>
{/* INA CBG Selection */}
<div>
<label className="block text-sm font-bold text-[#2591D0] mb-2">
Kode INA CBG
</label>
<div className="flex items-center gap-2 mb-2 relative">
<div className="flex-1 relative">
<input
ref={inacbgInputRef}
type="text"
placeholder="Cari kode INA CBG..."
value={inacbgSearch}
onChange={(e) => {
setInacbgSearch(e.target.value);
setInacbgDropdownOpen(true);
}}
onFocus={() => setInacbgDropdownOpen(true)}
className="w-full border border-blue-200 rounded-lg py-2 px-3 text-sm text-[#2591D0] placeholder-blue-400 focus:ring-2 focus:ring-blue-400 focus:border-transparent"
/>
<FaChevronDown
onClick={(e) => {
e.stopPropagation();
setInacbgDropdownOpen(!inacbgDropdownOpen);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-400 cursor-pointer hover:text-blue-600 text-sm pointer-events-auto z-10"
/>
{inacbgDropdownOpen && (
<div
ref={inacbgDropdownRef}
className="absolute top-full left-0 right-0 bg-white border border-blue-200 rounded-lg shadow-lg mt-1 max-h-48 overflow-y-auto z-20"
>
{filteredInacbgCodes().length > 0 ? (
filteredInacbgCodes().map((item) => (
<div
key={item.code}
onClick={() => {
handleAddInacbg(item.code);
}}
className="px-3 py-2 text-sm hover:bg-blue-100 cursor-pointer border-b border-blue-100 last:border-b-0"
>
<div className="font-semibold text-[#2591D0]">{item.code}</div>
<div className="text-xs text-gray-600">{item.description}</div>
<div className="text-xs text-gray-500">
Tarif: Rp {item.tarif.toLocaleString('id-ID')}
</div>
</div>
))
) : (
<div className="px-3 py-2 text-sm text-gray-500 text-center">
Tidak ada hasil
</div>
)}
</div>
)}
</div>
<button
type="button"
className="w-8 h-8 bg-[#2591D0] rounded-full flex items-center justify-center text-white hover:bg-[#1e7ba8] transition-colors flex-shrink-0"
onClick={() => {
if (filteredInacbgCodes().length > 0) {
handleAddInacbg(filteredInacbgCodes()[0].code);
}
}}
>
<FaPlus className="text-xs" />
</button>
</div>
{/* Selected Codes */}
{selectedInacbgCodes.length > 0 ? (
<div className="mt-3 space-y-2">
{selectedInacbgCodes.map((code, idx) => {
const tarif = getInacbgTarifRaw(code);
const isNew = !existingInacbgCodes.includes(code);
return (
<div
key={`${code}-${idx}`}
className={`flex items-center justify-between p-2 rounded-lg border ${
isNew
? 'bg-blue-50 border-blue-200'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex-1">
<div className="font-semibold text-sm text-[#2591D0]">{code}</div>
<div className="text-xs text-gray-600">
Tarif: Rp {(tarif || 0).toLocaleString('id-ID')}
{isNew && <span className="ml-2 text-green-600">(Baru)</span>}
</div>
</div>
<button
onClick={() => handleRemoveInacbg(idx)}
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors flex-shrink-0"
>
<FaTrash className="w-4 h-4" />
</button>
</div>
);
})}
</div>
) : (
<div className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-lg text-center text-sm text-gray-500">
Belum ada kode INA CBG yang dipilih
</div>
)}
</div>
{/* Total Klaim Display */}
<div className="bg-blue-50 p-3 sm:p-4 rounded-lg border border-blue-200">
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-gray-600">Total Klaim Original</p>
<p className="text-sm sm:text-base font-bold text-[#2591D0]">
Rp {totalKlaimOriginal.toLocaleString('id-ID')}
</p>
</div>
<div>
<p className="text-xs text-gray-600">Total Klaim (Real-time)</p>
<p className="text-sm sm:text-base font-bold text-green-600">
Rp {totalKlaimBPJS.toLocaleString('id-ID')}
</p>
</div>
<div>
<p className="text-xs text-gray-600">Total Tarif RS</p>
<p className="text-sm sm:text-base font-bold text-[#2591D0]">
Rp {totalTarifRS.toLocaleString('id-ID')}
</p>
</div>
<div>
<p className="text-xs text-gray-600">Delta Klaim</p>
<p className={`text-sm sm:text-base font-bold ${(totalKlaimBPJS - totalKlaimOriginal) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{(totalKlaimBPJS - totalKlaimOriginal) >= 0 ? '+' : ''}Rp {(totalKlaimBPJS - totalKlaimOriginal).toLocaleString('id-ID')}
</p>
</div>
</div>
</div>
{/* Billing Sign Indicator */}
{billingSign && (
<div className={`p-3 sm:p-4 rounded-lg border-2 ${signColor.bg} ${signColor.border}`}>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${signColor.dot}`}></div>
<span className={`text-sm font-semibold ${signColor.text}`}>
Status: {billingSign}
</span>
</div>
</div>
)}
</div>
{/* Confirm Submit Modal */}
{isConfirmModalOpen && (
<div className="fixed inset-0 bg-white/30 backdrop-blur-sm flex items-center justify-center z-[300] p-4">
<div className="bg-white rounded-lg shadow-xl max-w-sm w-full">
<div className="p-6">
<h3 className="text-lg font-bold text-[#2591D0] mb-4 flex items-center gap-2">
<span></span> Konfirmasi Perubahan
</h3>
<p className="text-gray-700 mb-6">
Apakah anda yakin mengubah kode inacbg?
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setIsConfirmModalOpen(false)}
className="px-4 py-2 rounded-full border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors font-medium text-sm"
>
Batal
</button>
<button
onClick={confirmSubmit}
disabled={loading}
className="px-4 py-2 rounded-full bg-[#2591D0] text-white hover:bg-[#1e7ba8] disabled:bg-gray-400 transition-colors font-medium text-sm"
>
{loading ? 'Menyimpan...' : 'Simpan'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Footer */}
<div className="sticky bottom-0 bg-gray-50 p-4 sm:p-6 border-t border-gray-200 flex gap-3 justify-end">
<button
onClick={onClose}
disabled={loading}
className="px-4 sm:px-6 py-2 rounded-full border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:bg-gray-200 disabled:cursor-not-allowed transition-colors font-medium text-sm sm:text-base"
>
Batal
</button>
<button
onClick={handleSave}
disabled={loading || JSON.stringify(selectedInacbgCodes.sort()) === JSON.stringify(existingInacbgCodes.sort())}
className="px-4 sm:px-6 py-2 rounded-full bg-[#2591D0] text-white hover:bg-[#1e7ba8] disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium text-sm sm:text-base"
>
{loading ? 'Menyimpan...' : 'Simpan'}
</button>
</div>
</div>
</div>
);
};
export default EditINACBGModal;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
"use client";
const Fornas = () => {
return (
<div className="p-3 sm:p-4 md:p-6 bg-white min-h-screen">
<h1 className="text-xl sm:text-2xl font-semibold text-[#2591D0] mb-3 sm:mb-4">
FORNAS
</h1>
<div className="w-full h-[calc(100vh-100px)] sm:h-[calc(100vh-120px)] md:h-[calc(100vh-140px)] rounded-lg overflow-hidden border border-blue-200">
<iframe
src="./assets/PDF/FORNAS_2023.pdf"
className="w-full h-full"
title="FORNAS PDF"
/>
</div>
</div>
);
};
export default Fornas;
@@ -0,0 +1,131 @@
"use client";
import React, { useState } from "react";
const Header = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
};
return (
<header className="bg-white shadow-md sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<div className="flex-shrink-0">
<h1 className="text-2xl font-bold text-blue-600">Care IT</h1>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex space-x-8">
<a
href="#home"
className="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
>
Home
</a>
<a
href="#about"
className="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
>
About
</a>
<a
href="#services"
className="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
>
Services
</a>
<a
href="#contact"
className="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
>
Contact
</a>
</nav>
{/* Mobile menu button */}
<div className="md:hidden">
<button
onClick={toggleMenu}
className="inline-flex items-center justify-center p-2 rounded-md text-gray-700 hover:text-blue-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-expanded="false"
>
<span className="sr-only">Open main menu</span>
{/* Hamburger icon */}
<svg
className={`${isMenuOpen ? "hidden" : "block"} h-6 w-6`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{/* Close icon */}
<svg
className={`${isMenuOpen ? "block" : "hidden"} h-6 w-6`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/* Mobile Navigation Menu */}
<div className={`${isMenuOpen ? "block" : "hidden"} md:hidden`}>
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-white border-t border-gray-200">
<a
href="#home"
className="text-gray-700 hover:text-blue-600 block px-3 py-2 text-base font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Home
</a>
<a
href="#about"
className="text-gray-700 hover:text-blue-600 block px-3 py-2 text-base font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
About
</a>
<a
href="#services"
className="text-gray-700 hover:text-blue-600 block px-3 py-2 text-base font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Services
</a>
<a
href="#contact"
className="text-gray-700 hover:text-blue-600 block px-3 py-2 text-base font-medium transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Contact
</a>
</div>
</div>
</div>
</header>
);
};
export default Header;
@@ -0,0 +1,192 @@
"use client";
import Image from "next/image";
// Static imports for assets to ensure they work in Electron build
import logoImage from "../../../public/assets/LOGO_CAREIT.svg";
import circleImage from "../../../public/assets/LandingPage/lingkaranLandingPage.svg";
import doctorImage from "../../../public/assets/LandingPage/dokterLucu.svg";
import stethoscopeImage from "../../../public/assets/LandingPage/stetoskop.svg";
import syringeImage from "../../../public/assets/LandingPage/suntik.svg";
interface LandingPageProps {
onStartNow?: () => void;
}
const LandingPage = ({ onStartNow }: LandingPageProps) => {
return (
<div className="min-h-screen bg-white flex flex-col lg:flex-row overflow-x-hidden">
{/* Left Section - White Background */}
<div className="flex-1 flex flex-col justify-center px-4 sm:px-6 md:px-12 lg:px-16 xl:px-24 py-8 sm:py-12 lg:py-16">
{/* Logo */}
<div className="mb-4 sm:mb-6 lg:mb-8">
<Image
src={logoImage}
alt="CARE-IT Logo"
width={180}
height={90}
className="object-contain w-28 sm:w-32 md:w-40 lg:w-48"
priority
/>
</div>
{/* Main Heading */}
<div className="mb-4 sm:mb-6 lg:mb-8">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-[#2591D0] leading-tight">
TARIF BPJS TEPAT
<br />
LAYANAN HEBAT
</h1>
</div>
{/* Descriptive Text */}
<div className="mb-6 sm:mb-8 lg:mb-10 max-w-2xl">
<p className="text-sm sm:text-base md:text-lg lg:text-xl text-[#2591D0] leading-relaxed">
Care It memudahkan dokter memverifikasi tarif tindakan agar sesuai standar BPJS,
dengan fitur warning billing sign yang memberi peringatan otomatis saat tarif melebihi batas,
sehingga rumah sakit tetap patuh, aman, dan bebas overbilling.
</p>
</div>
{/* Call-to-Action Button */}
<div>
<button
onClick={onStartNow}
className="bg-[#87CEEB] text-[#2591D0] px-8 sm:px-10 md:px-12 py-3 sm:py-4 md:py-5 rounded-full font-bold text-base sm:text-lg md:text-xl uppercase hover:bg-[#6bb8d8] hover:text-white transition-all duration-300 shadow-lg w-full sm:w-auto"
>
START NOW
</button>
</div>
</div>
{/* Right Section - White Background with Circle Decorative */}
<div className="flex-1 relative hidden md:block overflow-hidden min-h-[400px] lg:min-h-screen bg-white">
{/* Circular Decorative Element - Background */}
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
<div className="relative" style={{ transform: 'translate(20%, -20%) scale(1.3)' }}>
<Image
src={circleImage}
alt="Decorative Circle"
width={695}
height={738}
className="object-contain w-full h-full opacity-30"
priority
style={{ filter: 'brightness(0) saturate(100%) invert(40%) sepia(90%) saturate(2000%) hue-rotate(190deg) brightness(0.9) contrast(1.1)' }}
/>
</div>
</div>
{/* White Curve Overlay - Complex curved shape from right extending to left */}
<div className="absolute inset-0">
<svg className="absolute inset-0 w-full h-full" preserveAspectRatio="none" style={{ zIndex: 1 }}>
<path
d="M 100 0 L 100 100 C 95 95, 85 85, 70 75 C 55 65, 40 55, 25 45 C 15 35, 8 25, 0 15 L 0 0 Z"
fill="white"
/>
</svg>
</div>
{/* 3D Doctor Illustration - Aligned with logo top and text bottom */}
<div className="absolute inset-0 z-10" style={{ top: '8rem', bottom: '8rem' }}>
<div className="relative w-full h-full flex items-center justify-center">
<div style={{ transform: 'translate(20%, -20%)' }}>
<Image
src={doctorImage}
alt="Doctor Illustration"
width={480}
height={576}
className="object-contain w-full h-full max-w-[24rem] xl:max-w-[26rem] 2xl:max-w-[28rem]"
priority
/>
</div>
</div>
</div>
{/* Floating Stethoscope Icon - Upper Left */}
<div className="absolute top-20 left-12 xl:left-16 z-20">
<Image
src={stethoscopeImage}
alt="Stethoscope"
width={120}
height={120}
className="object-contain w-20 h-20 sm:w-24 sm:h-24 md:w-28 md:h-28 lg:w-32 lg:h-32"
/>
</div>
{/* Floating Syringe Icon - Lower Right */}
<div className="absolute bottom-20 right-12 xl:right-16 z-20">
<Image
src={syringeImage}
alt="Syringe"
width={120}
height={120}
className="object-contain w-20 h-20 sm:w-24 sm:h-24 md:w-28 md:h-28 lg:w-32 lg:h-32"
/>
</div>
</div>
{/* Mobile View - Show doctor illustration */}
<div className="lg:hidden w-full bg-white py-8 px-4 flex items-center justify-center relative min-h-[300px] sm:min-h-[350px] overflow-hidden">
{/* Circular Decorative Element - Background Mobile */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-[500px] h-[500px] sm:w-[600px] sm:h-[600px] max-w-none" style={{ transform: 'translate(15%, 10%)' }}>
<Image
src={circleImage}
alt="Decorative Circle"
width={695}
height={738}
className="object-contain w-full h-full opacity-20 sm:opacity-25"
priority
style={{ filter: 'brightness(0) saturate(100%) invert(40%) sepia(90%) saturate(2000%) hue-rotate(190deg) brightness(0.9) contrast(1.1)' }}
/>
</div>
</div>
{/* White Curve for Mobile */}
<div className="absolute inset-0 overflow-hidden">
<svg className="absolute bottom-0 w-full h-full" preserveAspectRatio="none">
<path
d="M 0 100 L 100 100 L 100 60 C 80 50, 50 40, 0 30 Z"
fill="white"
/>
</svg>
</div>
{/* Doctor Illustration - Mobile */}
<div className="relative z-10">
<Image
src={doctorImage}
alt="Doctor Illustration"
width={260}
height={347}
className="object-contain w-full max-w-[13rem]"
priority
/>
</div>
{/* Mobile Icons */}
<div className="absolute top-8 left-12 z-20">
<Image
src={stethoscopeImage}
alt="Stethoscope"
width={80}
height={80}
className="object-contain w-16 h-16 sm:w-20 sm:h-20"
/>
</div>
<div className="absolute bottom-4 right-4 z-20">
<Image
src={syringeImage}
alt="Syringe"
width={80}
height={80}
className="object-contain w-16 h-16 sm:w-20 sm:h-20"
/>
</div>
</div>
</div>
);
};
export default LandingPage;
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { loginDokter, loginAdmin } from "@/lib/api-helper";
// Static imports for Electron compatibility
import logoImage from "../../../public/assets/LOGO_CAREIT.svg";
import medicalIllustration from "../../../public/assets/Login/logindokterbanyak.svg";
import silhouetteImage from "../../../public/assets/Login/siluetLogin.svg";
interface LoginProps {
onLoginSuccess?: (role: string) => void;
onBackToLanding?: () => void;
}
const Login = ({ onLoginSuccess, onBackToLanding }: LoginProps) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
// Validasi input tidak kosong
if (!email.trim()) {
setError("Email/username tidak boleh kosong");
setLoading(false);
return;
}
if (!password.trim()) {
setError("Password tidak boleh kosong");
setLoading(false);
return;
}
try {
const trimmedInput = email.trim();
const trimmedPassword = password.trim();
// Deteksi apakah input adalah email (dokter) atau username (admin)
// Jika mengandung @, berarti dokter, jika tidak berarti admin
const isDokter = trimmedInput.includes("@");
let response;
let role: string;
if (isDokter) {
// Login sebagai dokter
response = await loginDokter({
email: trimmedInput,
password: trimmedPassword,
});
role = "dokter";
} else {
// Login sebagai admin
response = await loginAdmin({
nama_admin: trimmedInput,
password: trimmedPassword,
});
role = "admin";
}
if (response.error) {
setError(response.error);
setLoading(false);
return;
}
if (response.data) {
// Store token
if (response.data.token) {
localStorage.setItem("token", response.data.token);
// Store role-specific user info
if (isDokter && 'dokter' in response.data) {
localStorage.setItem("dokter", JSON.stringify(response.data.dokter));
} else if (!isDokter && 'admin' in response.data) {
localStorage.setItem("admin", JSON.stringify(response.data.admin));
}
}
localStorage.setItem("isAuthenticated", "true");
localStorage.setItem("userRole", role);
// Login successful
if (onLoginSuccess) {
onLoginSuccess(role);
}
}
} catch (err) {
setError(
err instanceof Error
? err.message
: "Terjadi kesalahan saat login. Pastikan backend server berjalan."
);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-white flex flex-col lg:flex-row overflow-x-hidden">
{/* Left Section - White Background */}
<div className="flex-shrink-0 flex flex-col px-3 sm:px-4 md:px-8 lg:px-24 py-2 sm:py-3 md:py-4 lg:py-12 lg:flex-1 max-h-[45vh] lg:max-h-none">
{/* Logo */}
<div className="mb-1 sm:mb-1.5 md:mb-2 lg:mb-6">
<button
onClick={onBackToLanding}
className="cursor-pointer"
>
<Image
src={logoImage}
alt="CARE-IT Logo"
width={180}
height={90}
className="object-contain w-14 sm:w-16 md:w-20 lg:w-40"
priority
/>
</button>
</div>
{/* 3D Medical Illustration - Reduced size on mobile */}
<div className="flex items-center justify-center relative h-[100px] sm:h-[120px] md:h-[140px] lg:flex-1 lg:min-h-[400px] lg:h-auto flex-1">
<Image
src={medicalIllustration}
alt="Medical Professionals Illustration"
width={800}
height={667}
className="object-contain w-full h-full max-w-[150px] sm:max-w-[200px] md:max-w-[240px] lg:max-w-lg xl:max-w-xl"
priority
/>
</div>
</div>
{/* Right Section - White Background with Silhouette */}
<div className="flex-1 relative hidden lg:flex items-center justify-center overflow-hidden bg-white">
{/* Silhouette Decorative Element - Background */}
<div className="absolute inset-0 flex items-center justify-end overflow-visible">
<Image
src={silhouetteImage}
alt="Login Silhouette"
width={965}
height={1024}
className="object-contain h-full w-auto opacity-30"
priority
style={{
minHeight: '100%',
maxHeight: '100%',
filter: 'brightness(0) saturate(100%) invert(40%) sepia(90%) saturate(2000%) hue-rotate(190deg) brightness(0.9) contrast(1.1)'
}}
/>
</div>
{/* Login Form */}
<div className="relative z-10 w-full max-w-xs sm:max-w-sm md:max-w-md px-3 sm:px-6">
{/* Welcome Title */}
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-bold text-white mb-6 sm:mb-8 md:mb-10 lg:mb-12 text-center">
Welcome
</h2>
{/* Login Form */}
<form onSubmit={handleLogin} className="space-y-4 sm:space-y-6">
{/* Email/Username Input */}
<div>
<input
type="text"
placeholder="Enter Email or Username"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-gray-100 text-gray-800 placeholder-gray-500 rounded-full py-3 sm:py-4 px-4 sm:px-6 text-base sm:text-lg focus:outline-none focus:ring-2 focus:ring-[#2591D0] focus:ring-offset-2 focus:ring-offset-transparent"
required
/>
</div>
{/* Password Input */}
<div>
<input
type="password"
placeholder="Enter Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-100 text-gray-800 placeholder-gray-500 rounded-full py-3 sm:py-4 px-4 sm:px-6 text-base sm:text-lg focus:outline-none focus:ring-2 focus:ring-[#2591D0] focus:ring-offset-2 focus:ring-offset-transparent"
required
/>
</div>
{/* Error Message */}
{error && (
<div className="text-red-600 text-xs sm:text-sm text-center bg-red-100 rounded-full py-2 px-3 sm:px-4">
{error}
</div>
)}
{/* Login Button */}
<div className="pt-2 sm:pt-4">
<button
type="submit"
disabled={loading}
className="w-full bg-[#d4a574] hover:bg-[#c49663] disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-3 sm:py-4 px-4 sm:px-6 rounded-full text-base sm:text-lg transition-colors shadow-lg"
>
{loading ? "Logging in..." : "Login"}
</button>
</div>
</form>
</div>
</div>
{/* Mobile Login Form */}
<div className="lg:hidden w-full flex items-center justify-center bg-white px-4 sm:px-6 md:px-8 py-4 sm:py-5 md:py-6 flex-shrink-0 min-h-[55vh] relative overflow-visible">
{/* Silhouette Decorative Element - Background Mobile */}
<div className="absolute inset-0 flex items-center justify-end overflow-visible">
<Image
src={silhouetteImage}
alt="Login Silhouette"
width={965}
height={1024}
className="object-contain h-full w-auto opacity-20"
priority
style={{
minHeight: '100%',
maxHeight: '100%',
filter: 'brightness(0) saturate(100%) invert(40%) sepia(90%) saturate(2000%) hue-rotate(190deg) brightness(0.9) contrast(1.1)'
}}
/>
</div>
<div className="w-full max-w-xs sm:max-w-sm relative z-10">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-white mb-2.5 sm:mb-3 md:mb-4 text-center">
Welcome
</h2>
<form onSubmit={handleLogin} className="space-y-2 sm:space-y-2.5 md:space-y-3">
<input
type="text"
placeholder="Enter Email or Username"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-gray-100 text-gray-800 placeholder-gray-500 rounded-full py-2 sm:py-2.5 md:py-3 px-3 sm:px-4 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#2591D0]"
required
/>
<input
type="password"
placeholder="Enter Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-100 text-gray-800 placeholder-gray-500 rounded-full py-2 sm:py-2.5 md:py-3 px-3 sm:px-4 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#2591D0]"
required
/>
{error && (
<div className="text-red-600 text-xs text-center bg-red-100 rounded-full py-1.5 px-2 sm:px-3">
{error}
</div>
)}
<div className="pt-0.5 sm:pt-1">
<button
type="submit"
disabled={loading}
className="w-full bg-[#d4a574] hover:bg-[#c49663] active:bg-[#b88752] disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-2.5 sm:py-3 px-4 rounded-full text-sm sm:text-base transition-colors shadow-lg"
>
{loading ? "Logging in..." : "Login"}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default Login;
@@ -0,0 +1,644 @@
"use client";
import { useState, useEffect } from 'react';
import { FaArrowLeft, FaCalendarAlt, FaUser, FaBuilding, FaChevronDown } from 'react-icons/fa';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiFetch } from '@/lib/api-helper';
interface PasienDetail {
id_billing: number;
id_pasien: number;
nama_pasien: string;
jenis_kelamin: string;
usia: number;
ruangan: string;
nama_ruangan?: string;
kelas: string; // ← Changed to lowercase to match API
tanggal_keluar: string;
tanggal_masuk: string | null;
tanggal_tindakan: string | null;
tindakan_rs: string[];
icd9: string[];
icd10: string[];
kode_inacbg: string;
total_tarif_rs?: number;
total_klaim?: number;
id_dpjp?: number;
nama_dpjp?: string;
[key: string]: any;
}
interface TindakanWithDate {
deskripsi: string;
tanggal: string;
}
interface TarifData {
KodeINA?: string;
Deskripsi?: string;
[key: string]: any;
}
interface PasienProps {
billingId?: number;
pasienName?: string;
onBack?: () => void;
}
const Pasien = ({ billingId, pasienName, onBack }: PasienProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [pasienData, setPasienData] = useState<PasienDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tindakanWithDates, setTindakanWithDates] = useState<TindakanWithDate[]>([]);
const [selectedTanggal, setSelectedTanggal] = useState<string>('');
const [availableTanggal, setAvailableTanggal] = useState<string[]>([]);
const [tarifCache, setTarifCache] = useState<{[key: string]: string}>({});
// Ambil billingId dari query params kalo gak ada di prop
const queryBillingId = searchParams.get('billingId');
const queryPasienName = searchParams.get('namaPasien');
const finalBillingId = billingId || (queryBillingId ? parseInt(queryBillingId) : undefined);
const finalPasienName = pasienName || queryPasienName || undefined;
// Fetch detail data pasien dari billing yang udah closed
useEffect(() => {
const fetchPasienData = async () => {
try {
setLoading(true);
setError('');
// Fetch riwayat pasien lengkap dari backend
const response = await apiFetch("/admin/riwayat-pasien-all", { method: "GET" });
if (response.error) {
setError('Gagal memuat data riwayat pasien');
setLoading(false);
return;
}
// Extract data array dari response
let billingDataArray: any[] = [];
const responseData = response as any;
if (Array.isArray(responseData.data)) {
billingDataArray = responseData.data;
} else if (responseData.data && Array.isArray(responseData.data.data)) {
billingDataArray = responseData.data.data;
}
console.log('📊 Full Response:', responseData);
console.log('📋 Billing Data Array:', billingDataArray);
// Cari billing dengan ID yang sesuai
const targetBillingId = parseInt(queryBillingId as string);
const foundBilling = billingDataArray.find((item) => {
return item.id_billing === targetBillingId;
});
console.log('🎯 Target Billing ID:', targetBillingId);
console.log('✅ Found Billing:', foundBilling);
if (foundBilling) {
console.log('📦 Setting PasienData:', foundBilling);
setPasienData(foundBilling);
// Initialize tindakan with dates dari response
const tindakanList = foundBilling.tindakan_rs || [];
console.log('📋 Tindakan List:', tindakanList);
const tindakanDates: TindakanWithDate[] = tindakanList.map((item: any) => {
const desc = typeof item === 'string' ? item : item.deskripsi || '-';
const tanggal = foundBilling.tanggal_tindakan ? formatDateForInput(foundBilling.tanggal_tindakan) : formatDateForInput(foundBilling.tanggal_masuk);
console.log('📅 Tindakan item:', { desc, tanggal });
return { deskripsi: desc, tanggal };
});
setTindakanWithDates(tindakanDates);
// Generate available tanggal (dari tanggal_masuk sampai tanggal_keluar)
const tanggalList = generateDateRange(foundBilling.tanggal_masuk, foundBilling.tanggal_keluar);
console.log('📅 Available tanggal:', tanggalList);
setAvailableTanggal(tanggalList);
// Set default selected tanggal ke yang pertama
if (tanggalList.length > 0) {
setSelectedTanggal(tanggalList[0]);
console.log('✅ Default selected tanggal:', tanggalList[0]);
}
// Fetch tarif data buat mapping kode -> deskripsi
console.log('🔄 Fetching tarif data...');
await fetchTarifData();
console.log('✅ Tarif data fetch complete');
} else {
setError('Data pasien dengan billing ID tersebut tidak ditemukan dalam riwayat pasien');
}
} catch (err) {
setError('Gagal memuat data pasien');
console.error(err);
} finally {
setLoading(false);
}
};
if (queryBillingId) {
fetchPasienData();
}
}, [queryBillingId]);
const handleBack = () => {
if (onBack) {
onBack();
} else {
router.back();
}
};
// Generate date range dari tanggal_masuk sampai tanggal_keluar
const generateDateRange = (startDate: string | null, endDate: string | null): string[] => {
if (!startDate || !endDate) return [];
const dates: string[] = [];
const current = new Date(startDate);
const end = new Date(endDate);
while (current <= end) {
const year = current.getFullYear();
const month = String(current.getMonth() + 1).padStart(2, '0');
const day = String(current.getDate()).padStart(2, '0');
dates.push(`${year}-${month}-${day}`);
current.setDate(current.getDate() + 1);
}
return dates;
};
// Fetch tarif data untuk mapping kode -> deskripsi
const fetchTarifData = async () => {
try {
console.log('🔄 Starting fetchTarifData...');
const [riResponse, rjResponse, tarifRSResponse, icd9Response, icd10Response] = await Promise.all([
apiFetch("/tarifBPJSRawatInap", { method: "GET" }),
apiFetch("/tarifBPJSRawatJalan", { method: "GET" }),
apiFetch("/tarifRS", { method: "GET" }),
apiFetch("/icd9", { method: "GET" }),
apiFetch("/icd10", { method: "GET" }),
]);
const cache: {[key: string]: string} = {};
// Map RI data (INACBG)
console.log('📋 riResponse:', riResponse);
if (riResponse?.data && Array.isArray(riResponse.data)) {
console.log('✅ RI Data is array, length:', riResponse.data.length);
(riResponse.data as TarifData[]).forEach((item) => {
if (item.KodeINA && item.Deskripsi) {
cache[item.KodeINA] = item.Deskripsi;
}
});
} else {
console.log('⚠️ RI Data is not array or empty:', riResponse?.data);
}
// Map RJ data (INACBG)
console.log('📋 rjResponse:', rjResponse);
if (rjResponse?.data && Array.isArray(rjResponse.data)) {
console.log('✅ RJ Data is array, length:', rjResponse.data.length);
(rjResponse.data as TarifData[]).forEach((item) => {
if (item.KodeINA && item.Deskripsi) {
cache[item.KodeINA] = item.Deskripsi;
}
});
} else {
console.log('⚠️ RJ Data is not array or empty:', rjResponse?.data);
}
// Map Tarif RS data (Tindakan)
console.log('📋 tarifRSResponse:', tarifRSResponse);
if (tarifRSResponse?.data && Array.isArray(tarifRSResponse.data)) {
console.log('✅ Tarif RS Data is array, length:', tarifRSResponse.data.length);
(tarifRSResponse.data as TarifData[]).forEach((item) => {
if (item.KodeINA && item.Deskripsi) {
cache[item.KodeINA] = item.Deskripsi;
}
});
} else {
console.log('⚠️ Tarif RS Data is not array or empty:', tarifRSResponse?.data);
console.log('📋 Checking first item structure:', (tarifRSResponse?.data as any)?.[0]);
}
// Map ICD9 data
console.log('📋 icd9Response:', icd9Response);
if (icd9Response?.data && Array.isArray(icd9Response.data)) {
console.log('✅ ICD9 Data is array, length:', icd9Response.data.length);
(icd9Response.data as TarifData[]).forEach((item) => {
if (item.KodeINA && item.Deskripsi) {
cache[item.KodeINA] = item.Deskripsi;
}
});
} else {
console.log('⚠️ ICD9 Data is not array or empty:', icd9Response?.data);
console.log('📋 Checking first item structure:', (icd9Response?.data as any)?.[0]);
}
// Map ICD10 data
console.log('📋 icd10Response:', icd10Response);
if (icd10Response?.data && Array.isArray(icd10Response.data)) {
console.log('✅ ICD10 Data is array, length:', icd10Response.data.length);
(icd10Response.data as TarifData[]).forEach((item) => {
if (item.KodeINA && item.Deskripsi) {
cache[item.KodeINA] = item.Deskripsi;
}
});
} else {
console.log('⚠️ ICD10 Data is not array or empty:', icd10Response?.data);
console.log('📋 Checking first item structure:', (icd10Response?.data as any)?.[0]);
}
console.log('✅ Final Tarif cache created with keys:', Object.keys(cache).length, 'items');
console.log('📋 Cache content:', cache);
setTarifCache(cache);
} catch (err) {
console.error('❌ Error fetching tarif data:', err);
}
};
// Ambil deskripsi dari kode
const getKodeDeskripsi = (kode: string): string => {
if (!kode || kode === '-') return '-';
const deskripsi = tarifCache[kode];
console.log(`🔍 Looking for kode: "${kode}", found:`, deskripsi, 'cache keys:', Object.keys(tarifCache).slice(0, 5));
if (deskripsi) {
return `${kode} - ${deskripsi}`;
}
// Fallback: jika tidak ada di cache, tampilkan kode saja
return kode;
};
const formatDate = (date: string | null | undefined) => {
if (!date) return '-';
try {
return new Date(date).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return date;
}
};
const formatDateForInput = (date: string | null | undefined) => {
if (!date) return '';
try {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} catch {
return '';
}
};
const handleTindakanDateChange = (idx: number, newDate: string) => {
const updated = [...tindakanWithDates];
updated[idx].tanggal = newDate;
setTindakanWithDates(updated);
};
// Ambil tindakan buat tanggal yang dipilih
const getTindakanByDate = () => {
if (!selectedTanggal) return [];
const filtered = tindakanWithDates.filter(item => item.tanggal === selectedTanggal);
console.log(`🔍 getTindakanByDate - selectedTanggal: ${selectedTanggal}, filtered:`, filtered);
return filtered;
};
// Bikin combined data buat unified table
const getCombinedTableData = () => {
const icd9Array = (pasienData?.icd9 || []) as string[];
const icd10Array = (pasienData?.icd10 || []) as string[];
const inacbgCode = pasienData?.kode_inacbg || '';
const tindakanByDate = getTindakanByDate();
console.log('📊 getCombinedTableData called:');
console.log(' - icd9Array:', icd9Array);
console.log(' - icd10Array:', icd10Array);
console.log(' - inacbgCode:', inacbgCode);
console.log(' - tindakanByDate:', tindakanByDate);
const maxRows = Math.max(
tindakanByDate.length,
icd9Array.length,
icd10Array.length,
inacbgCode ? 1 : 0
);
const data: Array<{tindakan: string; icd9: string; icd10: string; inacbg: string}> = [];
for (let i = 0; i < maxRows; i++) {
// Ambil ICD9 - BE kirim kode, tampilkan pake deskripsi
const icd9Code = icd9Array[i] || '';
const icd9Display = getKodeDeskripsi(icd9Code);
// Ambil ICD10 - BE kirim kode, tampilkan pake deskripsi
const icd10Code = icd10Array[i] || '';
const icd10Display = getKodeDeskripsi(icd10Code);
// Ambil INACBG - BE kirim kode tunggal (cuma di baris pertama), tampilkan pake deskripsi
const inacbgDisplay = i === 0 ? getKodeDeskripsi(inacbgCode) : '';
// Ambil Tindakan - BE kirim kode, tampilkan pake deskripsi
const tindakanDesc = tindakanByDate[i]?.deskripsi || '';
const tindakanDisplay = getKodeDeskripsi(tindakanDesc);
console.log(`📝 Row ${i}:`, { tindakanDisplay, icd10Display, icd9Display, inacbgDisplay });
data.push({
tindakan: tindakanDisplay,
icd9: icd9Display,
icd10: icd10Display,
inacbg: inacbgDisplay,
});
}
console.log('✅ Final combined data:', data);
return data;
};
const getNamaPasien = () => pasienData?.nama_pasien || pasienName || 'N/A';
const getUsia = () => pasienData?.usia?.toString() || '-';
const getGender = () => pasienData?.jenis_kelamin || '-';
const getRuangan = () => pasienData?.nama_ruangan || '-';
const getKelas = () => pasienData?.kelas || '-'; // ← Changed to lowercase
const getTanggalMasuk = () => formatDate(pasienData?.tanggal_masuk);
const getTanggalKeluar = () => formatDate(pasienData?.tanggal_keluar);
const getTanggalMasukForInput = () => formatDateForInput(pasienData?.tanggal_masuk);
const getTanggalKeluarForInput = () => formatDateForInput(pasienData?.tanggal_keluar);
const getDPJP = () => pasienData?.nama_dpjp ? pasienData.nama_dpjp : '-';
const getTotalTarifRS = () => {
const total = pasienData?.total_tarif_rs;
if (typeof total === 'number') {
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 }).format(total);
}
return '-';
};
const getTotalKlaim = () => {
const total = pasienData?.total_klaim;
if (typeof total === 'number') {
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 }).format(total);
}
return '-';
};
const getTindakan = () => pasienData?.tindakan_rs || [];
const getICD9 = () => pasienData?.icd9 || [];
const getICD10 = () => pasienData?.icd10 || [];
const getINACBG = () => pasienData?.kode_inacbg ? [pasienData.kode_inacbg] : [];
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-white">
<div className="text-center">
<div className="text-lg text-[#2591D0] mb-4">Memuat data pasien...</div>
<div className="animate-spin h-8 w-8 border-4 border-[#2591D0] border-t-transparent rounded-full mx-auto"></div>
</div>
</div>
);
}
return (
<div className="p-3 sm:p-4 md:p-6 bg-white w-full max-w-full min-h-screen">
{/* Header */}
<div className="flex items-center gap-2 mb-4 sm:mb-6">
<button
onClick={handleBack}
className="flex items-center gap-2 text-[#2591D0] hover:text-[#1e7ba8] transition font-semibold text-sm sm:text-base"
>
<FaArrowLeft className="text-sm sm:text-base" />
</button>
</div>
{/* Date */}
<div className="mb-2 sm:mb-3">
<div className="text-xs sm:text-sm text-[#2591D0]">
{new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
{/* Title */}
<div className="text-lg sm:text-xl text-[#2591D0] mb-3 sm:mb-6 font-bold">Detail Pasien</div>
{/* Error Messages */}
{error && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
{/* Content */}
{!pasienData ? (
<div className="text-center py-12 text-gray-500">
Tidak ada data pasien yang ditemukan
</div>
) : (
<div className="w-full max-w-full space-y-4 sm:space-y-6">
{/* Informasi Pasien - Key Details */}
<div className="w-full max-w-full">
{/* Nama Lengkap */}
<div className="ml-0 sm:ml-4 mb-3 sm:mb-4">
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">
Nama Lengkap
</label>
<input
type="text"
value={getNamaPasien()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
{/* ID Pasien, Kelas, Usia - Three Columns */}
<div className="ml-0 sm:ml-4 mt-2 grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-x-3 md:gap-x-4 lg:gap-x-6 sm:gap-y-3 md:gap-y-4 w-full max-w-full">
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">ID Pasien</label>
<input
type="text"
value={pasienData?.id_pasien || '-'}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">Kelas</label>
<input
type="text"
value={getKelas()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">Usia</label>
<input
type="text"
value={`${getUsia()} tahun`}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
</div>
{/* Ruangan, Jenis Kelamin - Two Columns */}
<div className="ml-0 sm:ml-4 mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-x-3 md:gap-x-4 lg:gap-x-6 sm:gap-y-3 md:gap-y-4 w-full max-w-full">
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">Ruangan</label>
<input
type="text"
value={getRuangan()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">Jenis Kelamin</label>
<input
type="text"
value={getGender()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
</div>
{/* Tanggal Masuk, Tanggal Keluar - Two Columns */}
<div className="ml-0 sm:ml-4 mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-x-3 md:gap-x-4 lg:gap-x-6 sm:gap-y-3 md:gap-y-4 w-full max-w-full">
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">Tanggal Masuk</label>
<input
type="text"
value={getTanggalMasuk()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">Tanggal Keluar</label>
<input
type="text"
value={getTanggalKeluar()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
</div>
{/* DPJP */}
<div className="ml-0 sm:ml-4 mt-2">
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">DPJP</label>
<input
type="text"
value={getDPJP()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
{/* Total Tarif RS dan Total Klaim - Two Columns */}
<div className="ml-0 sm:ml-4 mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-x-3 md:gap-x-4 lg:gap-x-6 sm:gap-y-3 md:gap-y-4 w-full max-w-full">
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">Total Tarif RS</label>
<input
type="text"
value={getTotalTarifRS()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm sm:text-md text-[#2591D0] mb-1 sm:mb-2 font-bold">Total Klaim</label>
<input
type="text"
value={getTotalKlaim()}
disabled
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-[#2591D0] bg-gray-50 disabled:cursor-not-allowed"
/>
</div>
</div>
</div>
{/* Detail Tindakan & Diagnosa Table */}
<div className="w-full max-w-full">
<div className="ml-0 sm:ml-4 text-sm sm:text-md text-[#2591D0] mb-2 sm:mb-3 font-bold">
<p className="mb-2 sm:mb-3">Detail Tindakan & Diagnosa</p>
</div>
{/* Date Selector Dropdown */}
{availableTanggal.length > 0 && (
<div className="ml-0 sm:ml-4 mb-4 flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4">
<label className="text-sm font-semibold text-[#2591D0]">Pilih Tanggal Tindakan:</label>
<div className="relative w-full sm:w-auto">
<select
value={selectedTanggal}
onChange={(e) => setSelectedTanggal(e.target.value)}
className="w-full sm:w-64 border border-blue-200 rounded-lg py-2 px-3 text-sm text-[#2591D0] bg-white focus:ring-2 focus:ring-blue-400 focus:border-transparent appearance-none"
>
{availableTanggal.map((tanggal) => (
<option key={tanggal} value={tanggal}>
{new Date(tanggal + 'T00:00:00').toLocaleDateString('id-ID', {
weekday: 'short',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</option>
))}
</select>
<FaChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-400 pointer-events-none text-sm" />
</div>
</div>
)}
{getCombinedTableData().length > 0 ? (
<div className="overflow-x-auto border border-blue-200 rounded-lg">
<table className="w-full border-collapse text-xs sm:text-sm md:text-base">
<thead>
<tr className="bg-blue-100 border-b border-blue-200">
<th className="border border-blue-200 p-3 md:p-4 text-left font-semibold text-[#2591D0] min-w-[200px]">Tindakan</th>
<th className="border border-blue-200 p-3 md:p-4 text-left font-semibold text-[#2591D0] min-w-[200px]">ICD 10</th>
<th className="border border-blue-200 p-3 md:p-4 text-left font-semibold text-[#2591D0] min-w-[200px]">ICD 9</th>
<th className="border border-blue-200 p-3 md:p-4 text-left font-semibold text-[#2591D0] min-w-[160px]">INACBG</th>
</tr>
</thead>
<tbody>
{getCombinedTableData().map((row, idx) => (
<tr key={idx} className={idx % 2 === 0 ? 'bg-white' : 'bg-blue-50'}>
<td className="border border-blue-200 p-3 md:p-4 text-[#2591D0] break-words font-medium">
{row.tindakan || '-'}
</td>
<td className="border border-blue-200 p-3 md:p-4 text-[#2591D0] break-words">
{row.icd10 || '-'}
</td>
<td className="border border-blue-200 p-3 md:p-4 text-[#2591D0] break-words">
{row.icd9 || '-'}
</td>
<td className="border border-blue-200 p-3 md:p-4 text-[#2591D0] break-words font-medium">
{row.inacbg || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="p-4 bg-gray-50 rounded-lg text-center text-gray-500 text-sm">
Tidak ada data yang tercatat untuk tanggal yang dipilih
</div>
)}
</div>
</div>
)}
</div>
);
};
export default Pasien;
@@ -0,0 +1,781 @@
"use client";
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FaSearch, FaEdit, FaChevronDown } from 'react-icons/fa';
import { getRiwayatBilling} from '@/lib/api-helper';
interface RiwayatBillingPasienProps {
onLogout?: () => void;
userRole?: "dokter" | "admin";
onEdit?: (billingId: number, pasienName?: string) => void;
selectedRuangan?: string | null;
}
interface BillingData {
ID_Billing?: number;
ID_Pasien?: number;
Nama_Pasien?: string;
Billing_Sign?: string;
// Backend sends lowercase field names
id_billing?: number;
id_pasien?: number;
nama_pasien?: string;
billing_sign?: string;
// Additional fields from backend
Kelas?: string;
kelas?: string;
ruangan?: string;
Ruangan?: string;
total_tarif_rs?: number;
total_klaim?: number;
ID_DPJP?: number;
id_dpjp?: number;
// Add tanggal fields to interface
Tanggal_Masuk?: string;
tanggal_masuk?: string;
Tanggal_Keluar?: string;
tanggal_keluar?: string;
// Add dokter fields to interface
Nama_Dokter?: string;
nama_dokter?: string;
}
const RiwayatBillingPasien = ({ onLogout, userRole, onEdit, selectedRuangan }: RiwayatBillingPasienProps) => {
const router = useRouter();
const [billingData, setBillingData] = useState<BillingData[]>([]);
const [filteredData, setFilteredData] = useState<BillingData[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [loggedInDokterId, setLoggedInDokterId] = useState<number | null>(null);
const [loggedInDokterName, setLoggedInDokterName] = useState<string>('');
const [ruangan, setRuangan] = useState<string>('');
const [ruanganSearch, setRuanganSearch] = useState<string>('');
const [ruanganDropdownOpen, setRuanganDropdownOpen] = useState<boolean>(false);
const [selectedTanggal, setSelectedTanggal] = useState<string>('');
const [availableTanggal, setAvailableTanggal] = useState<string[]>([]);
const [filterType, setFilterType] = useState<'bulan' | 'masuk' | 'keluar' | ''>(''); // Combined filter type
const [filterByMonth, setFilterByMonth] = useState<string>(''); // Format: MM (01-12)
const [filterStartDate, setFilterStartDate] = useState<string>('');
const [filterEndDate, setFilterEndDate] = useState<string>('');
const [filterSingleDate, setFilterSingleDate] = useState<string>(''); // For Tanggal Keluar
// Ambil info dokter yang login
useEffect(() => {
const dokterData = localStorage.getItem("dokter");
if (dokterData) {
try {
const dokter = JSON.parse(dokterData);
if (dokter.id) {
setLoggedInDokterId(dokter.id);
}
if (dokter.nama) {
setLoggedInDokterName(dokter.nama);
}
} catch (err) {
console.error('Error parsing dokter data:', err);
}
}
}, []);
// Fetch billing data
useEffect(() => {
const fetchBillingData = async () => {
try {
setLoading(true);
setError('');
const response = await getRiwayatBilling();
if (response.error) {
setError(response.error);
return;
}
// Handle berbagai struktur response
if (response.data) {
let dataArray: BillingData[] = [];
// Cek kalo response.data udah array langsung
if (Array.isArray(response.data)) {
dataArray = response.data;
}
// Cek kalo response.data punya property data
else if ((response.data as any).data && Array.isArray((response.data as any).data)) {
dataArray = (response.data as any).data;
}
// Cek kalo response.data punya status sama property data
else if ((response.data as any).status && (response.data as any).data && Array.isArray((response.data as any).data)) {
dataArray = (response.data as any).data;
} else {
console.error('Unexpected response structure:', response.data);
setError('Format data tidak dikenali');
return;
}
// Log untuk debugging
console.log('Billing data loaded:', dataArray.length, 'items');
if (dataArray.length > 0) {
console.log('Sample item:', dataArray[0]);
console.log('Sample tanggal_masuk:', dataArray[0].Tanggal_Masuk || dataArray[0].tanggal_masuk);
console.log('Available fields in item:', Object.keys(dataArray[0]));
}
// Tidak filter berdasarkan dokter - ambil semua data dari database
setBillingData(dataArray);
setFilteredData(dataArray);
// Generate available tanggal from all data
const tanggalSet = new Set<string>();
dataArray.forEach((item) => {
const tanggalMasuk = item.Tanggal_Masuk || item.tanggal_masuk;
console.log('Processing item - tanggalMasuk:', tanggalMasuk);
if (tanggalMasuk) {
const dateStr = tanggalMasuk.substring(0, 10); // Extract YYYY-MM-DD
tanggalSet.add(dateStr);
}
});
const tanggalList = Array.from(tanggalSet).sort().reverse(); // Sort latest first
console.log('✅ Available tanggal list:', tanggalList);
setAvailableTanggal(tanggalList);
// Set default selected tanggal ke yang pertama (paling baru)
if (tanggalList.length > 0) {
setSelectedTanggal(tanggalList[0]);
console.log('✅ Selected default tanggal:', tanggalList[0]);
} else {
console.warn('⚠️ No tanggal found in data');
}
} else {
console.error('No data in response:', response);
setError('Tidak ada data yang diterima dari server');
}
} catch (err) {
setError('Gagal memuat data billing. Pastikan backend server berjalan.');
console.error(err);
} finally {
setLoading(false);
}
};
// Always fetch data regardless of login status
fetchBillingData();
}, []);
// Filter data based on search term, ruangan, tanggal, dan month
useEffect(() => {
let filtered = billingData;
// Filter 1: By filter type (bulan, tanggal masuk, atau tanggal keluar)
if (filterType === 'bulan' && filterByMonth) {
// filterByMonth is MM (01-12)
filtered = filtered.filter((item) => {
const tanggalMasuk = item.Tanggal_Masuk || item.tanggal_masuk || '';
const tanggalKeluar = item.Tanggal_Keluar || item.tanggal_keluar || '';
const monthFromMasuk = tanggalMasuk.substring(5, 7); // Extract MM from YYYY-MM-DD
const monthFromKeluar = tanggalKeluar.substring(5, 7); // Extract MM from YYYY-MM-DD
// Match either tanggal_masuk or tanggal_keluar month
return monthFromMasuk === filterByMonth || monthFromKeluar === filterByMonth;
});
} else if (filterType === 'masuk' && (filterStartDate || filterEndDate)) {
// Filter by date range for Tanggal Masuk
filtered = filtered.filter((item) => {
const tanggalMasuk = item.Tanggal_Masuk || item.tanggal_masuk || '';
if (!tanggalMasuk) return false;
const dateStr = tanggalMasuk.substring(0, 10); // Extract YYYY-MM-DD
// If only start date is provided
if (filterStartDate && !filterEndDate) {
return dateStr >= filterStartDate;
}
// If only end date is provided
if (!filterStartDate && filterEndDate) {
return dateStr <= filterEndDate;
}
// If both dates are provided (range filter)
if (filterStartDate && filterEndDate) {
return dateStr >= filterStartDate && dateStr <= filterEndDate;
}
return false;
});
} else if (filterType === 'keluar' && filterSingleDate) {
// Filter by single date for Tanggal Keluar
filtered = filtered.filter((item) => {
const tanggalKeluar = item.Tanggal_Keluar || item.tanggal_keluar || '';
if (!tanggalKeluar) return false;
const dateStr = tanggalKeluar.substring(0, 10); // Extract YYYY-MM-DD
return dateStr === filterSingleDate;
});
}
// Filter 2: By ruangan (if selectedRuangan provided)
if (selectedRuangan) {
filtered = filtered.filter((item) => {
const itemRuangan = item.ruangan || item.Ruangan || '';
return itemRuangan.trim() === selectedRuangan.trim();
});
}
// Filter 3: By search term
if (searchTerm.trim()) {
filtered = filtered.filter(
(item) => {
const namaPasien = item.Nama_Pasien || item.nama_pasien || '';
const idPasien = item.ID_Pasien || item.id_pasien || 0;
const idBilling = item.ID_Billing || item.id_billing || 0;
return (
namaPasien.toString().toLowerCase().includes(searchTerm.toLowerCase()) ||
idPasien.toString().includes(searchTerm) ||
idBilling.toString().includes(searchTerm)
);
}
);
}
setFilteredData(filtered);
}, [searchTerm, billingData, selectedRuangan, filterType, filterStartDate, filterEndDate, filterByMonth, filterSingleDate]);
const getStatusColor = (billingSign: string) => {
// Map billing sign to color
if (!billingSign) return "bg-gray-400";
const sign = billingSign.toLowerCase();
// Map Indonesian enum values from database
if (sign === "hijau" || sign === "green") {
return "bg-green-500"; // Tarif RS <=25% dari BPJS
} else if (sign === "kuning" || sign === "yellow") {
return "bg-yellow-500"; // 26%-50%
} else if (sign === "merah" || sign === "red" || sign === "orange") {
return "bg-red-500"; // >50%
}
// Legacy mappings (for backward compatibility)
if (sign === "selesai" || sign === "completed" || sign === "1") {
return "bg-green-500";
} else if (sign === "pending" || sign === "proses" || sign === "0") {
return "bg-yellow-500";
} else {
return "bg-gray-400";
}
};
// Hitung warning sign secara dinamis berdasarkan current tarif RS vs existing klaim
// This ensures warning updates even if INACBG hasn't been input yet
const calculateDynamicWarningSign = (totalTarifRS: number | undefined, totalKlaim: number | undefined): string => {
if (!totalTarifRS || !totalKlaim || totalTarifRS <= 0 || totalKlaim <= 0) {
return ""; // No data to calculate
}
const percentage = (totalTarifRS / totalKlaim) * 100;
if (percentage <= 25) {
return "Hijau"; // Safe
} else if (percentage <= 50) {
return "Kuning"; // Warning
} else {
return "Merah"; // Alert
}
};
const handleSelectRuangan = (idRuangan: string, namaRuangan: string) => {
setRuangan(namaRuangan); // ✅ SIMPAN NAMA RUANGAN!
setRuanganSearch(namaRuangan);
setRuanganDropdownOpen(false);
};
return (
<div className="bg-white flex flex-col h-screen">
{/* Fixed Header */}
<div className="flex-shrink-0 bg-white border-b border-gray-100">
{/* Tanggal */}
<div className="p-3 sm:p-4 md:p-6 pb-2 sm:pb-3">
<div className="text-xs sm:text-sm text-[#2591D0]">
{new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
{/* Search Bar */}
<div className="px-3 sm:px-4 md:px-6 pb-3 sm:pb-4 md:pb-6">
<div className="relative">
<input
type="text"
placeholder="Cari billing pasien disini"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full border text-sm border-blue-200 rounded-full py-2 sm:py-3 pl-3 sm:pl-4 pr-10 sm:pr-12 text-[#2591D0] placeholder-blue-400 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 focus:outline-0"
/>
<FaSearch className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-[#2591D0] cursor-pointer text-sm sm:text-base" />
</div>
</div>
{/* Combined Filter Section */}
<div className="px-3 sm:px-4 md:px-6 pb-3 sm:pb-4 md:pb-6">
{/* Dropdown: Filter Type (Bulan, Tanggal Masuk, Tanggal Keluar) */}
<div className="flex items-center gap-3 mb-4">
<label className="text-sm font-semibold text-[#2591D0]">Filter:</label>
<div className="relative flex-1 sm:flex-initial">
<select
value={filterType}
onChange={(e) => {
const value = e.target.value as 'bulan' | 'masuk' | 'keluar' | '';
setFilterType(value);
// Reset values when changing filter type
setFilterByMonth('');
setFilterStartDate('');
setFilterEndDate('');
}}
className="w-full sm:w-48 border border-blue-200 rounded-lg py-2 px-3 text-sm text-[#2591D0] bg-white focus:ring-2 focus:ring-blue-400 focus:border-transparent appearance-none"
>
<option value="" disabled hidden>Pilih Filter</option>
<option value="bulan">Bulan</option>
<option value="masuk">Tanggal Masuk</option>
<option value="keluar">Tanggal Keluar</option>
</select>
<FaChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-400 pointer-events-none text-sm" />
</div>
{filterType && (
<button
onClick={() => {
setFilterType('');
setFilterByMonth('');
setFilterStartDate('');
setFilterEndDate('');
setFilterSingleDate('');
}}
className="px-4 py-2 text-sm bg-red-100 text-red-600 rounded-lg hover:bg-red-200 transition whitespace-nowrap"
>
Reset Filter
</button>
)}
</div>
{/* Month Picker - Show only when "Bulan" is selected */}
{filterType === 'bulan' && (
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex-1 sm:flex-initial">
<label className="block text-xs font-semibold text-[#2591D0] mb-1">Pilih Bulan:</label>
<select
value={filterByMonth}
onChange={(e) => setFilterByMonth(e.target.value)}
className="w-full sm:w-48 border border-blue-200 rounded-lg py-2 px-3 text-sm text-[#2591D0] bg-white focus:ring-2 focus:ring-blue-400 focus:border-transparent appearance-none"
>
<option value="">Pilih Bulan</option>
<option value="01">Januari</option>
<option value="02">Februari</option>
<option value="03">Maret</option>
<option value="04">April</option>
<option value="05">Mei</option>
<option value="06">Juni</option>
<option value="07">Juli</option>
<option value="08">Agustus</option>
<option value="09">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Desember</option>
</select>
</div>
</div>
)}
{/* Date Range Picker - Show only when "Tanggal Masuk" is selected */}
{filterType === 'masuk' && (
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex-1 sm:flex-initial">
<label className="block text-xs font-semibold text-[#2591D0] mb-1">Dari Tanggal:</label>
<input
type="date"
value={filterStartDate}
onChange={(e) => {
setFilterStartDate(e.target.value);
setFilterType('masuk');
}}
className="w-full sm:w-40 border border-blue-200 rounded-lg py-2 px-3 text-sm text-[#2591D0] focus:ring-2 focus:ring-blue-400 focus:border-transparent"
/>
</div>
<div className="flex-1 sm:flex-initial">
<label className="block text-xs font-semibold text-[#2591D0] mb-1">Sampai Tanggal:</label>
<input
type="date"
value={filterEndDate}
onChange={(e) => {
setFilterEndDate(e.target.value);
if (!filterType) {
setFilterType('masuk');
}
}}
className="w-full sm:w-40 border border-blue-200 rounded-lg py-2 px-3 text-sm text-[#2591D0] focus:ring-2 focus:ring-blue-400 focus:border-transparent"
/>
</div>
</div>
)}
{/* Single Date Picker - Show only when "Tanggal Keluar" is selected */}
{filterType === 'keluar' && (
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex-1 sm:flex-initial">
<label className="block text-xs font-semibold text-[#2591D0] mb-1">Pilih Tanggal Keluar:</label>
<input
type="date"
value={filterSingleDate}
onChange={(e) => setFilterSingleDate(e.target.value)}
className="w-full sm:w-40 border border-blue-200 rounded-lg py-2 px-3 text-sm text-[#2591D0] focus:ring-2 focus:ring-blue-400 focus:border-transparent"
/>
</div>
</div>
)}
{/* Filter Info Display */}
{filterType === 'bulan' && filterByMonth && (
<p className="text-xs text-[#2591D0] mt-2">📅 Filter: {new Date(2025, parseInt(filterByMonth) - 1).toLocaleDateString('id-ID', { month: 'long' })}</p>
)}
{filterType === 'masuk' && filterStartDate && filterEndDate && (
<p className="text-xs text-[#2591D0] mt-2">📅 Filter Tanggal Masuk: {filterStartDate} sampai {filterEndDate}</p>
)}
{filterType === 'masuk' && filterStartDate && !filterEndDate && (
<p className="text-xs text-[#2591D0] mt-2">📅 Filter Tanggal Masuk: Dari {filterStartDate}</p>
)}
{filterType === 'masuk' && !filterStartDate && filterEndDate && (
<p className="text-xs text-[#2591D0] mt-2">📅 Filter Tanggal Masuk: Hingga {filterEndDate}</p>
)}
{filterType === 'keluar' && filterSingleDate && (
<p className="text-xs text-[#2591D0] mt-2">📅 Filter Tanggal Keluar: {filterSingleDate}</p>
)}
</div>
{/* Table Header - Desktop Only */}
<div className="hidden md:block border-t border-blue-200">
<div className="w-full overflow-x-auto">
<table className="w-full">
<thead className="bg-[#87CEEB]">
<tr className="bg-[#87CEEB]">
<th className="px-4 md:px-6 lg:px-15 py-3 md:py-4 text-left text-sm md:text-base font-bold text-white w-24">
ID Pasien
</th>
<th className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-left text-sm md:text-base font-bold text-white flex-1">
Nama
</th>
<th className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-left text-sm md:text-base font-bold text-white flex-1">
Dokter
</th>
<th className="px-4 md:px-6 lg:px-30 py-3 md:py-4 text-right text-sm md:text-base font-bold text-white w-28">
Billing Sign
</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
{/* Fixed Logout Button - Top Right */}
{onLogout && (
<button
onClick={onLogout}
className="fixed top-4 right-4 z-50 flex items-center space-x-1 sm:space-x-2 bg-white sm:bg-transparent px-2 sm:px-0 py-1.5 sm:py-0 rounded-lg sm:rounded-none shadow-md sm:shadow-none text-blue-500 hover:text-red-500 transition text-xs sm:text-sm font-medium"
title="Logout"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 sm:h-5 sm:w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H6a2 2 0 01-2-2V7a2 2 0 012-2h5a2 2 0 012 2v1"
/>
</svg>
<span className="hidden sm:inline text-xs sm:text-sm font-medium">Logout</span>
</button>
)}
{/* Error Message */}
{error && (
<div className="mx-3 sm:mx-4 md:mx-6 mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg">
{error}
</div>
)}
{/* Scrollable Content Container */}
<div className="flex-1 overflow-y-auto">
{/* Table - Desktop View */}
<div className="hidden md:block border border-blue-200 border-t-0 m-3 sm:m-4 md:m-6 mt-0 overflow-x-auto">
<table className="w-full">
<thead style={{display: 'none'}}>
<tr>
<th className="w-24">ID Pasien</th>
<th className="flex-1">Nama</th>
<th className="flex-1">Dokter</th>
<th className="w-28">Billing Sign</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="px-4 md:px-6 lg:px-8 py-12 text-center text-[#2591D0] text-base md:text-lg">
Memuat data...
</td>
</tr>
) : filteredData.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 md:px-6 lg:px-8 py-12 text-center text-[#2591D0] text-base md:text-lg">
{searchTerm ? 'Tidak ada data yang sesuai dengan pencarian' : 'Tidak ada data billing'}
</td>
</tr>
) : (
filteredData.map((item, index) => {
// Support both PascalCase and lowercase field names from backend
const idBilling = item.ID_Billing || item.id_billing || 0;
const idPasien = item.ID_Pasien || item.id_pasien || 0;
const namaPasien = item.Nama_Pasien || item.nama_pasien || 'N/A';
const billingSign = item.Billing_Sign || item.billing_sign || '';
// Ambil nama dokter dari backend response - bisa kosong kalo belum ada di database
const namaDokter = item.Nama_Dokter || item.nama_dokter || '';
return (
<tr
key={idBilling || index}
onClick={() => router.push(`/pasien?billingId=${idBilling}&namaPasien=${encodeURIComponent(namaPasien)}`)}
className={`${
index % 2 === 0 ? "bg-white" : "bg-gray-50"
} hover:bg-blue-50 transition-colors cursor-pointer`}
>
<td className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-sm md:text-base text-[#2591D0] w-24">
P.{idPasien.toString().padStart(4, '0')}
</td>
<td className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-sm md:text-base text-[#2591D0] break-words flex-1">
{namaPasien}
</td>
<td className="px-4 md:px-6 lg:px-8 py-3 md:py-4 text-sm md:text-base text-[#2591D0] break-words flex-1">
{namaDokter || '-'}
</td>
<td className="px-4 md:px-6 lg:px-8 py-3 md:py-4 w-28">
<div className="flex items-center gap-3 md:gap-4 justify-between group relative">
{(() => {
// Hitung dynamic warning sign kalo data tarif ada
const dynamicSign = calculateDynamicWarningSign(item.total_tarif_rs, item.total_klaim);
const displaySign = dynamicSign || billingSign; // Use dynamic if available, fallback to DB sign
return (
<>
<span
className={`${getStatusColor(
displaySign
)} w-16 md:w-20 lg:w-24 h-5 md:h-6 lg:h-7 rounded-full flex-shrink-0 cursor-help`}
title={item.total_tarif_rs && item.total_klaim ?
`Tarif RS: Rp ${item.total_tarif_rs?.toLocaleString('id-ID')} | BPJS: Rp ${item.total_klaim?.toLocaleString('id-ID')}`
: ''}
></span>
{/* Hover Tooltip */}
{item.total_tarif_rs && item.total_klaim && (
<div className="hidden group-hover:block absolute left-0 bottom-full mb-2 bg-gray-900 text-white text-xs rounded-lg px-2 py-1 whitespace-nowrap z-10">
Tarif RS: Rp {item.total_tarif_rs?.toLocaleString('id-ID')} | BPJS: Rp {item.total_klaim?.toLocaleString('id-ID')}
</div>
)}
</>
);
})()}
{userRole === "admin" && (
<FaEdit
onClick={() => {
console.log("🖱️ FaEdit clicked! onEdit exists?", !!onEdit, "idBilling:", idBilling, "namaPasien:", namaPasien);
if (onEdit && idBilling) {
console.log("✅ Calling onEdit with:", idBilling, namaPasien);
onEdit(idBilling, namaPasien);
} else {
console.error("❌ Cannot call onEdit - onEdit exists?", !!onEdit, "idBilling exists?", !!idBilling);
}
}}
className="text-[#2591D0] cursor-pointer hover:text-[#1e7ba8] text-base md:text-lg flex-shrink-0 ml-auto"
/>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Mobile/Tablet Card View */}
<div className="md:hidden space-y-3 p-3 sm:p-4 md:p-6">
{loading ? (
<div className="py-12 text-center text-[#2591D0] text-base">
Memuat data...
</div>
) : filteredData.length === 0 ? (
<div className="py-12 text-center text-[#2591D0] text-base bg-white border border-blue-200 rounded-lg">
{searchTerm ? 'Tidak ada data yang sesuai dengan pencarian' : 'Tidak ada data billing'}
</div>
) : (
filteredData.map((item, index) => {
const idBilling = item.ID_Billing || item.id_billing || 0;
const idPasien = item.ID_Pasien || item.id_pasien || 0;
const namaPasien = item.Nama_Pasien || item.nama_pasien || 'N/A';
const billingSign = item.Billing_Sign || item.billing_sign || '';
const namaDokter = item.Nama_Dokter || item.nama_dokter || '';
return (
<div
key={idBilling || index}
onClick={() => router.push(`/pasien?billingId=${idBilling}&namaPasien=${encodeURIComponent(namaPasien)}`)}
className="bg-white border border-blue-200 rounded-lg shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer"
>
<div className="space-y-3">
{/* ID Pasien */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
ID Pasien
</span>
<span className="text-sm font-semibold text-[#2591D0]">
P.{idPasien.toString().padStart(4, '0')}
</span>
</div>
{/* Nama */}
<div className="flex flex-col space-y-1">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Nama
</span>
<span className="text-sm text-[#2591D0] break-words">
{namaPasien}
</span>
</div>
{/* Dokter */}
<div className="flex flex-col space-y-1">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dokter
</span>
<span className="text-sm text-[#2591D0] break-words">
{namaDokter || '-'}
</span>
</div>
{/* Kelas */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Kelas
</span>
<span className="text-sm font-semibold text-[#2591D0]">
{item.Kelas || item.kelas || '-'}
</span>
</div>
{/* DPJP */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
DPJP
</span>
<span className="text-sm font-semibold text-[#2591D0]">
{item.ID_DPJP || item.id_dpjp ? `ID: ${item.ID_DPJP || item.id_dpjp}` : '-'}
</span>
</div>
{/* Total Tarif RS */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Total Tarif RS
</span>
<span className="text-sm font-semibold text-[#2591D0]">
{item.total_tarif_rs ? `Rp ${item.total_tarif_rs?.toLocaleString('id-ID')}` : '-'}
</span>
</div>
{/* Total Klaim */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Total Klaim
</span>
<span className="text-sm font-semibold text-[#2591D0]">
{item.total_klaim ? `Rp ${item.total_klaim?.toLocaleString('id-ID')}` : '-'}
</span>
</div>
{/* Billing Sign */}
<div className="flex items-center justify-between pt-2 border-t border-blue-100">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Billing Sign
</span>
<div className="flex items-center gap-2">
{(() => {
// Calculate dynamic warning sign if tarif data exists
const dynamicSign = calculateDynamicWarningSign(item.total_tarif_rs, item.total_klaim);
const displaySign = dynamicSign || billingSign; // Use dynamic if available, fallback to DB sign
return (
<span
className={`${getStatusColor(displaySign)} w-16 h-5 rounded-full flex-shrink-0`}
></span>
);
})()}
{userRole === "admin" && (
<FaEdit
onClick={() => {
console.log("🖱️ FaEdit (mobile) clicked! onEdit exists?", !!onEdit, "idBilling:", idBilling, "namaPasien:", namaPasien);
if (onEdit && idBilling) {
console.log("✅ Calling onEdit (mobile) with:", idBilling, namaPasien);
onEdit(idBilling, namaPasien);
} else {
console.error("❌ Cannot call onEdit (mobile) - onEdit exists?", !!onEdit, "idBilling exists?", !!idBilling);
}
}}
className="text-[#2591D0] cursor-pointer hover:text-[#1e7ba8] text-lg flex-shrink-0"
/>
)}
</div>
</div>
{/* Warning Info */}
{item.total_tarif_rs && item.total_klaim && (() => {
// Calculate dynamic warning sign
const dynamicSign = calculateDynamicWarningSign(item.total_tarif_rs, item.total_klaim);
const displaySign = dynamicSign || billingSign;
return (
<div className="mt-3 p-2 rounded-lg" style={{
backgroundColor: displaySign === 'Merah' ? '#fee2e2' : displaySign === 'Kuning' ? '#fef3c7' : '#ecfdf5',
borderLeft: `4px solid ${displaySign === 'Merah' ? '#dc2626' : displaySign === 'Kuning' ? '#f59e0b' : '#10b981'}`
}}>
<p className="text-xs font-semibold" style={{
color: displaySign === 'Merah' ? '#7f1d1d' : displaySign === 'Kuning' ? '#92400e' : '#065f46'
}}>
{displaySign === 'Merah' ? '⚠️ Tarif RS Melebihi' : displaySign === 'Kuning' ? '⚠️ Mendekati Batas' : '✅ Aman'}
</p>
<p className="text-xs mt-1" style={{
color: displaySign === 'Merah' ? '#991b1b' : displaySign === 'Kuning' ? '#b45309' : '#047857'
}}>
RS: Rp {item.total_tarif_rs?.toLocaleString('id-ID')} | BPJS: Rp {item.total_klaim?.toLocaleString('id-ID')}
</p>
</div>
);
})()}
</div>
</div>
);
})
)}
</div>
</div>
</div>
);
};
export default RiwayatBillingPasien;
@@ -0,0 +1,261 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { FaChevronDown } from "react-icons/fa";
// Static import for logo
import logoImage from "../../../public/assets/LOGO_CAREIT.svg";
interface SidebarProps {
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
activeMenu: string;
setActiveMenu: React.Dispatch<React.SetStateAction<string>>;
menuItems?: {
name: string;
icon: string;
}[];
userRole?: "dokter" | "admin" | "";
}
const Sidebar = ({
isSidebarOpen,
setIsSidebarOpen,
activeMenu,
setActiveMenu,
menuItems,
userRole,
}: SidebarProps) => {
const [informasiDropdownOpen, setInformasiDropdownOpen] = useState(false);
const [warningDropdownOpen, setWarningDropdownOpen] = useState(false);
const [ruanganDropdownOpen, setRuanganDropdownOpen] = useState(false);
// Handler buat item di dalam dropdown (tetap buka dropdown)
const handleDropdownItemClick = (menuName: string) => {
setActiveMenu(menuName);
setIsSidebarOpen(false);
// Dropdown tetap terbuka
};
// Handler buat menu tanpa dropdown (Home, Ruangan) - tutup semua dropdown
const handleMenuClick = (menuName: string) => {
setActiveMenu(menuName);
setIsSidebarOpen(false);
// Tutup semua dropdown saat klik menu item tanpa dropdown
setInformasiDropdownOpen(false);
setWarningDropdownOpen(false);
setRuanganDropdownOpen(false);
};
const informasiItems = [
{ name: "Tarif Rumah Sakit", icon: "💰" },
{ name: "Tarif BPJS", icon: "🏥" },
{ name: "Fornas", icon: "📋" },
{ name: "Buku Saku", icon: "📚" },
];
const billingItems = [
{ name: "Billing Pasien", icon: "📝" },
{ name: "Riwayat Billing Pasien", icon: "📊" },
];
const ruanganItems = [
{ name: "Ruangan", icon: "🏢" },
];
return (
<>
{/* Mobile Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
<div
className={`
fixed top-0 left-0
w-48 sm:w-56 md:w-64 lg:w-72 h-screen
bg-[#ECF6FB] rounded-r-2xl sm:rounded-r-3xl shadow-lg
transition-all duration-300 z-50
overflow-y-auto
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0 lg:rounded-r-none lg:rounded-l-3xl lg:shadow-none
`}
>
{/* LOGO */}
<div className="p-3 sm:p-5 flex justify-center border-b border-blue-100">
<Image
src={logoImage}
alt="Care It Logo"
width={140}
height={70}
className="object-contain w-24 sm:w-32 md:w-36"
/>
</div>
{/* MENU */}
<nav className="mt-4 sm:mt-6 space-y-1 sm:space-y-2 px-2 sm:px-4">
{/* Home */}
<button
onClick={() => handleMenuClick("Home")}
className={`
w-full flex items-center gap-2 sm:gap-3 py-2 sm:py-3 px-2 sm:px-4
rounded-lg sm:rounded-xl text-left transition-all
${activeMenu === "Home"
? "bg-white text-blue-600 border-l-4 border-blue-500 shadow-sm"
: "text-gray-400 hover:bg-white"
}
`}
>
<span className={`text-base sm:text-lg ${activeMenu === "Home" ? "text-blue-500" : ""}`}>
🏠
</span>
<span className="text-xs sm:text-sm font-medium">Home</span>
</button>
{/* Informasi Umum Dropdown */}
<div>
<button
onClick={() => {
// Jika dropdown sedang terbuka, tutup; jika tertutup, buka dan tutup yang lain
if (informasiDropdownOpen) {
setInformasiDropdownOpen(false);
} else {
setInformasiDropdownOpen(true);
setWarningDropdownOpen(false);
setRuanganDropdownOpen(false);
}
}}
className={`
w-full flex items-center justify-between gap-2 sm:gap-3 py-2 sm:py-3 px-2 sm:px-4
rounded-lg sm:rounded-xl text-left transition-all
${informasiItems.some((item) => activeMenu === item.name)
? "bg-white text-blue-600 border-l-4 border-blue-500 shadow-sm"
: "text-gray-400 hover:bg-white"
}
`}
>
<div className="flex items-center gap-2 sm:gap-3">
<span className={`text-base sm:text-lg ${informasiItems.some((item) => activeMenu === item.name) ? "text-blue-500" : ""
}`}>
</span>
<span className="text-xs sm:text-sm font-medium">Informasi Umum</span>
</div>
<FaChevronDown
className={`text-xs transition-transform ${informasiDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{/* Informasi Dropdown Items */}
{informasiDropdownOpen && (
<div className="mt-1 ml-2 space-y-1 border-l-2 border-blue-200 pl-2 sm:pl-4">
{informasiItems.map((item, idx) => (
<button
key={idx}
onClick={() => handleDropdownItemClick(item.name)}
className={`
w-full flex items-center gap-2 sm:gap-3 py-2 px-2 sm:px-3
rounded-lg text-left text-xs sm:text-sm transition-all
${activeMenu === item.name
? "bg-blue-500 text-white"
: "text-gray-600 hover:bg-blue-100"
}
`}
>
<span>{item.icon}</span>
<span>{item.name}</span>
</button>
))}
</div>
)}
</div>
{/* Warning Billing Sign Dropdown */}
<div>
<button
onClick={() => {
// Jika dropdown sedang terbuka, tutup; jika tertutup, buka dan tutup yang lain
if (warningDropdownOpen) {
setWarningDropdownOpen(false);
} else {
setWarningDropdownOpen(true);
setInformasiDropdownOpen(false);
setRuanganDropdownOpen(false);
}
}}
className={`
w-full flex items-center justify-between gap-2 sm:gap-3 py-2 sm:py-3 px-2 sm:px-4
rounded-lg sm:rounded-xl text-left transition-all
${billingItems.some((item) => activeMenu === item.name)
? "bg-white text-blue-600 border-l-4 border-blue-500 shadow-sm"
: "text-gray-400 hover:bg-white"
}
`}
>
<div className="flex items-center gap-2 sm:gap-3">
<span className={`text-base sm:text-lg ${billingItems.some((item) => activeMenu === item.name) ? "text-blue-500" : ""
}`}>
</span>
<span className="text-xs sm:text-sm font-medium">Warning Billing Sign</span>
</div>
<FaChevronDown
className={`text-xs transition-transform ${warningDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{/* Warning Dropdown Items */}
{warningDropdownOpen && (
<div className="mt-1 ml-2 space-y-1 border-l-2 border-blue-200 pl-2 sm:pl-4">
{billingItems.map((item, idx) => (
<button
key={idx}
onClick={() => handleDropdownItemClick(item.name)}
className={`
w-full flex items-center gap-2 sm:gap-3 py-2 px-2 sm:px-3
rounded-lg text-left text-xs sm:text-sm transition-all
${activeMenu === item.name
? "bg-blue-500 text-white"
: "text-gray-600 hover:bg-blue-100"
}
`}
>
<span>{item.icon}</span>
<span>{item.name}</span>
</button>
))}
</div>
)}
</div>
{/* Ruangan Menu - Available for both Admin and Dokter */}
<button
onClick={() => handleMenuClick("Ruangan")}
className={`
w-full flex items-center gap-2 sm:gap-3 py-2 sm:py-3 px-2 sm:px-4
rounded-lg sm:rounded-xl text-left transition-all
${activeMenu === "Ruangan"
? "bg-white text-blue-600 border-l-4 border-blue-500 shadow-sm"
: "text-gray-400 hover:bg-white"
}
`}
>
<span className={`text-base sm:text-lg ${activeMenu === "Ruangan" ? "text-blue-500" : ""}`}>
🏢
</span>
<span className="text-xs sm:text-sm font-medium">Ruangan</span>
</button>
</nav>
</div>
</>
);
};
export default Sidebar;
@@ -0,0 +1,343 @@
"use client";
import React, { useState, useEffect } from "react";
import {
getTarifBPJSRawatInap,
getTarifBPJSRawatJalan,
type TarifBPJSRawatInap,
type TarifBPJSRawatJalan,
} from "@/lib/api-helper";
type TarifType = "rawat_inap" | "rawat_jalan";
const TarifBPJS = () => {
const [search, setSearch] = useState("");
const [tarifType, setTarifType] = useState<TarifType>("rawat_inap");
const [kelas, setKelas] = useState<1 | 2 | 3>(1);
// Data states
const [dataRawatInap, setDataRawatInap] = useState<TarifBPJSRawatInap[]>([]);
const [dataRawatJalan, setDataRawatJalan] = useState<TarifBPJSRawatJalan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Fetch data dari backend
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError("");
if (tarifType === "rawat_inap") {
const response = await getTarifBPJSRawatInap();
if (response.error) {
setError(response.error);
return;
}
if (response.data) {
setDataRawatInap(response.data);
}
} else {
const response = await getTarifBPJSRawatJalan();
if (response.error) {
setError(response.error);
return;
}
if (response.data) {
setDataRawatJalan(response.data);
}
}
} catch (err) {
setError("Gagal memuat data tarif BPJS. Pastikan backend server berjalan.");
console.error(err);
} finally {
setLoading(false);
}
};
fetchData();
}, [tarifType]);
// Filter data based on search
const getFilteredData = () => {
if (tarifType === "rawat_inap") {
return dataRawatInap.filter(
(item) =>
(item as any).KodeINA?.toLowerCase().includes(search.toLowerCase()) ||
(item as any).Deskripsi?.toLowerCase().includes(search.toLowerCase())
);
} else {
return dataRawatJalan.filter(
(item) =>
(item as any).KodeINA?.toLowerCase().includes(search.toLowerCase()) ||
(item as any).Deskripsi?.toLowerCase().includes(search.toLowerCase())
);
}
};
const filteredData = getFilteredData();
// Format currency
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(value);
};
return (
<div className="flex flex-col h-screen bg-white">
{/* Header Section - Sticky */}
<div className="sticky top-0 z-40 bg-white border-b border-blue-100 p-3 sm:p-4 md:p-6">
{/* Tanggal */}
<div className="text-xs sm:text-sm text-[#2591D0] mb-2 sm:mb-3">
{new Date().toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg">
{error}
</div>
)}
{/* Type Selector (Rawat Inap / Rawat Jalan) */}
<div className="flex gap-2 sm:gap-3 mb-4 sm:mb-5">
<button
onClick={() => setTarifType("rawat_inap")}
className={`px-4 sm:px-6 py-2 sm:py-2.5 rounded-full border transition text-sm sm:text-base font-medium ${
tarifType === "rawat_inap"
? "bg-blue-500 text-white border-blue-500"
: "text-[#2591D0] border-blue-300 bg-white hover:bg-blue-50"
}`}
>
Rawat Inap
</button>
<button
onClick={() => setTarifType("rawat_jalan")}
className={`px-4 sm:px-6 py-2 sm:py-2.5 rounded-full border transition text-sm sm:text-base font-medium ${
tarifType === "rawat_jalan"
? "bg-blue-500 text-white border-blue-500"
: "text-[#2591D0] border-blue-300 bg-white hover:bg-blue-50"
}`}
>
Rawat Jalan
</button>
</div>
{/* Kelas Selector (only for Rawat Inap) */}
{tarifType === "rawat_inap" && (
<div className="flex gap-2 sm:gap-3 mb-4 sm:mb-5">
<span className="text-sm sm:text-base text-[#2591D0] font-medium self-center">
Kelas:
</span>
{[1, 2, 3].map((k) => (
<button
key={k}
onClick={() => setKelas(k as 1 | 2 | 3)}
className={`px-3 sm:px-4 py-1.5 sm:py-2 rounded-full border transition text-xs sm:text-sm ${
kelas === k
? "bg-blue-500 text-white border-blue-500"
: "text-[#2591D0] border-blue-300 bg-white hover:bg-blue-50"
}`}
>
Kelas {k}
</button>
))}
</div>
)}
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Cari tindakan disini"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full border border-blue-200 rounded-lg py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-sm sm:text-base text-blue-400 focus:ring-2 focus:ring-blue-400"
/>
<div className="absolute right-2 sm:right-3 top-2 sm:top-3.5 text-blue-400">
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
</div>
{/* Table Header - Desktop Sticky */}
<div className="hidden md:flex sticky top-0 z-30 bg-blue-50 border-b border-blue-200 border-t text-[#2591D0] font-semibold text-sm md:text-base mt-4 sm:mt-5 px-3 md:px-4 lg:px-6 py-3 md:py-4 gap-0">
<div className="flex-none w-[16.666%]">Kode</div>
<div className="flex-1 break-words pr-2">Tindakan</div>
<div className="flex-none w-[16.666%] text-right">
{tarifType === "rawat_inap" ? `Tarif Kelas ${kelas}` : "Tarif BPJS"}
</div>
</div>
{/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto px-3 sm:px-4 md:px-6">
{/* Table - Desktop View */}
<div className="hidden md:block border border-blue-200 rounded-lg md:rounded-xl shadow-sm mt-0">
<div className="min-w-full">
{/* Loading State */}
{loading && (
<div className="py-12 md:py-16 text-center text-[#2591D0] text-base md:text-lg">
Memuat data...
</div>
)}
{/* Rows */}
{!loading &&
filteredData.map((item, i) => {
let tarifValue: number = 0;
if (tarifType === "rawat_inap") {
const rawatInapItem = item as any;
if (kelas === 1) tarifValue = rawatInapItem.Kelas1 || 0;
else if (kelas === 2) tarifValue = rawatInapItem.Kelas2 || 0;
else tarifValue = rawatInapItem.Kelas3 || 0;
} else {
const rawatJalanItem = item as any;
// Backend sends tarif_inacbg (lowercase), but we also check TarifINACBG for compatibility
tarifValue = rawatJalanItem.tarif_inacbg || rawatJalanItem.TarifINACBG || 0;
}
return (
<div
key={i}
className="grid grid-cols-6 px-3 md:px-4 lg:px-6 py-3 md:py-4 text-sm md:text-base border-b border-blue-100 hover:bg-blue-50 transition-colors"
>
<div className="col-span-1 text-[#2591D0] font-medium break-words">
{tarifType === "rawat_inap"
? (item as any).KodeINA
: (item as any).KodeINA}
</div>
<div className="col-span-4 text-[#2591D0] break-words pr-2">
{tarifType === "rawat_inap"
? (item as any).Deskripsi
: (item as any).Deskripsi}
</div>
<div className="col-span-1 text-right text-[#2591D0] font-medium whitespace-nowrap">
{formatCurrency(tarifValue)}
</div>
</div>
);
})}
{/* Empty State */}
{!loading && filteredData.length === 0 && (
<div className="py-8 md:py-12 text-center text-gray-500 text-sm md:text-base">
{search
? "Tidak ada data yang sesuai dengan pencarian"
: "Tidak ada data ditemukan"}
</div>
)}
</div>
</div>
{/* Mobile/Tablet Card View */}
<div className="md:hidden space-y-3 mt-4 sm:mt-5 pb-6">
{/* Mobile Header - Sticky */}
<div className="sticky top-0 z-30 grid grid-cols-3 bg-blue-50 border border-blue-200 rounded-lg px-3 sm:px-4 py-3 text-[#2591D0] font-semibold text-xs sm:text-sm">
<div>Kode</div>
<div>Tindakan</div>
<div className="text-right">{tarifType === "rawat_inap" ? `Tarif Kelas ${kelas}` : "Tarif BPJS"}</div>
</div>
{/* Loading State */}
{loading && (
<div className="py-12 text-center text-[#2591D0] text-base">
Memuat data...
</div>
)}
{/* Cards */}
{!loading &&
filteredData.map((item, i) => {
let tarifValue: number = 0;
if (tarifType === "rawat_inap") {
const rawatInapItem = item as any;
if (kelas === 1) tarifValue = rawatInapItem.Kelas1 || 0;
else if (kelas === 2) tarifValue = rawatInapItem.Kelas2 || 0;
else tarifValue = rawatInapItem.Kelas3 || 0;
} else {
const rawatJalanItem = item as any;
tarifValue = rawatJalanItem.tarif_inacbg || rawatJalanItem.TarifINACBG || 0;
}
return (
<div
key={i}
className="bg-white border border-blue-200 rounded-lg shadow-sm p-3 sm:p-4 hover:shadow-md transition-shadow"
>
<div className="flex flex-col space-y-2">
{/* Kode */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Kode
</span>
<span className="text-sm font-semibold text-[#2591D0]">
{tarifType === "rawat_inap"
? (item as any).KodeINA
: (item as any).KodeINA}
</span>
</div>
{/* Deskripsi */}
<div className="flex flex-col space-y-1">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Tindakan
</span>
<span className="text-sm text-[#2591D0] break-words">
{tarifType === "rawat_inap"
? (item as any).Deskripsi
: (item as any).Deskripsi}
</span>
</div>
{/* Tarif */}
<div className="flex items-center justify-between pt-2 border-t border-blue-100">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
{tarifType === "rawat_inap" ? `Tarif Kelas ${kelas}` : "Tarif BPJS"}
</span>
<span className="text-base font-bold text-[#2591D0]">
{formatCurrency(tarifValue)}
</span>
</div>
</div>
</div>
);
})}
{/* Empty State */}
{!loading && filteredData.length === 0 && (
<div className="py-8 text-center text-gray-500 text-sm bg-white border border-blue-200 rounded-lg">
{search
? "Tidak ada data yang sesuai dengan pencarian"
: "Tidak ada data ditemukan"}
</div>
)}
</div>
</div>
</div>
);
};
export default TarifBPJS;
@@ -0,0 +1,289 @@
"use client";
import React, { useState, useEffect } from "react";
import { getTarifRumahSakit, type TarifData } from "@/lib/api-helper";
type FilterType = "Semua" | "Rawat Darurat" | "Rawat Inap" | "Rawat Jalan";
const TarifRumahSakit = () => {
const [search, setSearch] = useState("");
const [filterType, setFilterType] = useState<FilterType>("Semua");
// Data states
const [allData, setAllData] = useState<TarifData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Fetch data dari backend
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError("");
const response = await getTarifRumahSakit();
if (response.error) {
setError(response.error);
return;
}
if (response.data) {
setAllData(response.data);
}
} catch (err) {
setError("Gagal memuat data tarif rumah sakit. Pastikan backend server berjalan.");
console.error(err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Filter data based on search and filter type
const getFilteredData = () => {
let filtered = [...allData];
// Apply category filter
if (filterType !== "Semua") {
filtered = filtered.filter((item) => {
const kategori = (item as any).Kategori || "";
if (filterType === "Rawat Darurat") {
return kategori.toLowerCase().includes("darurat");
} else if (filterType === "Rawat Inap") {
return kategori.toLowerCase().includes("inap");
} else if (filterType === "Rawat Jalan") {
return kategori.toLowerCase().includes("jalan");
}
return true;
});
}
// Apply search filter
if (search.trim()) {
const searchLower = search.toLowerCase();
filtered = filtered.filter(
(item) =>
(item as any).KodeRS?.toLowerCase().includes(searchLower) ||
(item as any).Deskripsi?.toLowerCase().includes(searchLower)
);
}
return filtered;
};
const filteredData = getFilteredData();
// Format currency
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(value);
};
return (
<div className="flex flex-col h-screen bg-white">
{/* Header Section - Sticky */}
<div className="sticky top-0 z-40 bg-white border-b border-blue-100 p-3 sm:p-4 md:p-6">
{/* Tanggal */}
<div className="text-xs sm:text-sm text-[#2591D0] mb-2 sm:mb-3">
{new Date().toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg">
{error}
</div>
)}
{/* Filter Type Selector */}
<div className="flex flex-wrap gap-2 sm:gap-3 mb-4 sm:mb-5">
{(["Semua", "Rawat Darurat", "Rawat Inap", "Rawat Jalan"] as FilterType[]).map((filter) => (
<button
key={filter}
onClick={() => setFilterType(filter)}
className={`px-3 sm:px-4 md:px-6 py-1.5 sm:py-2 md:py-2.5 rounded-full border transition text-xs sm:text-sm md:text-base font-medium whitespace-nowrap ${
filterType === filter
? "bg-blue-500 text-white border-blue-500 shadow-md"
: "text-[#2591D0] border-blue-300 bg-white hover:bg-blue-50"
}`}
>
{filter}
</button>
))}
</div>
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Cari tindakan disini"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full border border-blue-200 rounded-lg py-2 sm:py-3 pl-3 sm:pl-4 pr-8 sm:pr-10 text-sm sm:text-base text-blue-400 focus:ring-2 focus:ring-blue-400"
/>
<div className="absolute right-2 sm:right-3 top-2 sm:top-3.5 text-blue-400">
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
</div>
{/* Table Header - Desktop Sticky */}
<div className="hidden md:flex sticky top-0 z-30 bg-blue-50 border-b border-blue-200 border-t text-[#2591D0] font-semibold text-sm md:text-base mt-4 sm:mt-5 px-3 md:px-4 lg:px-6 py-3 md:py-4 gap-0">
<div className="flex-none w-[16.666%]">Kode RS</div>
<div className="flex-1 break-words pr-2">Tindakan</div>
<div className="flex-none w-[16.666%] text-right">Harga</div>
</div>
{/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto px-3 sm:px-4 md:px-6">
{/* Table - Desktop View */}
<div className="hidden md:block border border-blue-200 rounded-lg md:rounded-xl shadow-sm mt-0">
<div className="min-w-full">
{/* Loading State */}
{loading && (
<div className="py-12 md:py-16 text-center text-[#2591D0] text-base md:text-lg">
Memuat data...
</div>
)}
{/* Rows */}
{!loading &&
filteredData.map((item, i) => {
return (
<div
key={i}
className="grid grid-cols-6 px-3 md:px-4 lg:px-6 py-3 md:py-4 text-sm md:text-base border-b border-blue-100 hover:bg-blue-50 transition-colors"
>
<div className="col-span-1 text-[#2591D0] font-medium break-words">
{(item as any).KodeRS}
</div>
<div className="col-span-4 text-[#2591D0] break-words pr-2">
{(item as any).Deskripsi}
</div>
<div className="col-span-1 text-right text-[#2591D0] font-medium whitespace-nowrap">
{formatCurrency((item as any).Harga)}
</div>
</div>
);
})}
{/* Empty State */}
{!loading && filteredData.length === 0 && (
<div className="py-8 md:py-12 text-center text-gray-500 text-sm md:text-base">
{search
? "Tidak ada data yang sesuai dengan pencarian"
: "Tidak ada data ditemukan"}
</div>
)}
</div>
</div>
{/* Mobile/Tablet Card View */}
<div className="md:hidden space-y-3 mt-4 sm:mt-5 pb-6">
{/* Mobile Header - Sticky */}
<div className="sticky top-0 z-30 grid grid-cols-3 bg-blue-50 border border-blue-200 rounded-lg px-3 sm:px-4 py-3 text-[#2591D0] font-semibold text-xs sm:text-sm">
<div>Kode RS</div>
<div>Tindakan</div>
<div className="text-right">Harga</div>
</div>
{/* Loading State */}
{loading && (
<div className="py-12 text-center text-[#2591D0] text-base">
Memuat data...
</div>
)}
{/* Cards */}
{!loading &&
filteredData.map((item, i) => {
return (
<div
key={i}
className="bg-white border border-blue-200 rounded-lg shadow-sm p-3 sm:p-4 hover:shadow-md transition-shadow"
>
<div className="flex flex-col space-y-2">
{/* Kode */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Kode
</span>
<span className="text-sm font-semibold text-[#2591D0]">
{(item as any).KodeRS}
</span>
</div>
{/* Deskripsi */}
<div className="flex flex-col space-y-1">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Tindakan
</span>
<span className="text-sm text-[#2591D0] break-words">
{(item as any).Deskripsi}
</span>
</div>
{/* Kategori */}
{(item as any).Kategori && (
<div className="flex items-center space-x-2">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Kategori:
</span>
<span className="text-xs px-2 py-1 bg-blue-100 text-[#2591D0] rounded-full">
{(item as any).Kategori}
</span>
</div>
)}
{/* Harga */}
<div className="flex items-center justify-between pt-2 border-t border-blue-100">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Harga
</span>
<span className="text-base font-bold text-[#2591D0]">
{formatCurrency((item as any).Harga)}
</span>
</div>
</div>
</div>
);
})}
{/* Empty State */}
{!loading && filteredData.length === 0 && (
<div className="py-12 text-center text-gray-500 text-sm bg-white border border-blue-200 rounded-lg">
{search
? "Tidak ada data yang sesuai dengan pencarian"
: "Tidak ada data ditemukan"}
</div>
)}
</div>
</div>
</div>
);
};
export default TarifRumahSakit;
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+46
View File
@@ -0,0 +1,46 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: #ffffff;
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
overflow-x: hidden;
}
html {
overflow-x: hidden;
background: #ffffff;
}
/* Landscape orientation support */
@media (max-height: 500px) and (orientation: landscape) {
/* Reduce padding/margins on landscape mobile */
body {
font-size: 14px;
}
/* Adjust form elements for landscape */
input, select, textarea {
padding: 0.4rem 0.6rem;
font-size: 14px;
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "CARE-IT - Tarif BPJS Tepat Layanan Hebat",
description: "Care It memudahkan dokter memverifikasi tarif tindakan agar sesuai standar BPJS, dengan fitur warning billing sign yang memberi peringatan otomatis saat tarif melebihi batas",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
userScalable: true,
viewportFit: "cover",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">
{children}
</body>
</html>
);
}
+336
View File
@@ -0,0 +1,336 @@
"use client";
import { useState, useEffect } from "react";
import DashboardDokter from "./component/dashboard_Dokter";
import DashboardAdmin from "./component/dashboard_Admin_Ruangan";
import INACBG_Admin_Ruangan from "./component/INACBG_Admin_Ruangan";
import LandingPage from "./component/landingpage";
import Login from "./component/login";
import { getAllBilling } from "@/lib/api-helper";
export default function Home() {
const [currentPage, setCurrentPage] = useState<"landing" | "login" | "dashboard" | "inacbg">("landing");
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userRole, setUserRole] = useState<"dokter" | "admin" | "">("");
const [isInitialized, setIsInitialized] = useState(false);
const [isFirstVisit, setIsFirstVisit] = useState(false);
const [editingBillingData, setEditingBillingData] = useState<any>(null);
const [isNavigatingFromPopState, setIsNavigatingFromPopState] = useState(false);
const [activeMenu, setActiveMenu] = useState<string>("Home");
// Handle browser back/forward button
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
// When user clicks back/forward, restore page from history state
setIsNavigatingFromPopState(true);
if (event.state && event.state.page) {
const page = event.state.page as "landing" | "login" | "dashboard" | "inacbg";
setCurrentPage(page);
// Restore authentication state if needed
if (page === "dashboard" || page === "inacbg") {
const authStatus = localStorage.getItem("isAuthenticated");
const role = localStorage.getItem("userRole");
if (authStatus === "true" && role) {
setIsAuthenticated(true);
setUserRole(role as "dokter" | "admin");
}
}
} else {
// If no state, go back to previous page based on localStorage
const savedPage = localStorage.getItem("currentPage") as "landing" | "login" | "dashboard" | null;
if (savedPage) {
setCurrentPage(savedPage);
} else {
setCurrentPage("landing");
}
}
// Reset flag after a short delay to allow useEffect to process
setTimeout(() => setIsNavigatingFromPopState(false), 0);
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, []);
// Check authentication and restore page state on mount
// Check authentication and restore page state on mount
useEffect(() => {
const checkAuth = () => {
try {
console.log("Checking auth...");
const authStatus = localStorage.getItem("isAuthenticated");
const role = localStorage.getItem("userRole");
const savedPage = localStorage.getItem("currentPage") as "landing" | "login" | "dashboard" | null;
// Check if this is first visit in this browser session (using sessionStorage)
// This ensures that every time user opens URL (copy-paste, new tab, etc),
// they see landing page first
let hasVisitedThisSession = null;
try {
hasVisitedThisSession = sessionStorage.getItem("hasVisitedThisSession");
} catch (e) {
console.warn("Session storage not available:", e);
}
// Always show landing page on first visit in this session, regardless of auth status
if (!hasVisitedThisSession) {
// First time access in this session, always show landing page
setCurrentPage("landing");
try {
sessionStorage.setItem("hasVisitedThisSession", "true");
} catch (e) {
// Ignore session storage error
}
setIsFirstVisit(true);
// Add to browser history
window.history.replaceState({ page: "landing" }, "", window.location.href);
// Don't save landing page to localStorage on first visit - wait for user interaction
setIsInitialized(true);
return;
}
// After first visit in this session, check authentication
if (authStatus === "true" && role) {
// User is authenticated, go to dashboard
setIsAuthenticated(true);
setUserRole(role as "dokter" | "admin");
setCurrentPage("dashboard");
window.history.replaceState({ page: "dashboard" }, "", window.location.href);
} else {
// User is not authenticated
// Check if there's a saved page (for refresh persistence)
if (savedPage && savedPage !== "dashboard") {
// Use saved page (can be landing or login)
setCurrentPage(savedPage);
window.history.replaceState({ page: savedPage }, "", window.location.href);
} else {
// No saved page, go to login
setCurrentPage("login");
window.history.replaceState({ page: "login" }, "", window.location.href);
}
}
setIsInitialized(true);
} catch (error) {
console.error("Initial check fail:", error);
// Fallback if error occurs
setCurrentPage("landing");
setIsInitialized(true);
}
};
checkAuth();
}, []);
// Save current page to localStorage and browser history whenever it changes
// This ensures refresh will restore the current page and back button works
useEffect(() => {
if (isInitialized && !isNavigatingFromPopState) {
// Don't save landing page on first visit - wait for user interaction
// After first visit, always save current page to localStorage
if (!(isFirstVisit && currentPage === "landing")) {
localStorage.setItem("currentPage", currentPage);
// Add to browser history for back button support (only if not from popstate)
window.history.pushState({ page: currentPage }, "", window.location.href);
}
// Clear first visit flag after first interaction
if (isFirstVisit && currentPage !== "landing") {
setIsFirstVisit(false);
}
}
}, [currentPage, isInitialized, isFirstVisit, isNavigatingFromPopState]);
// Handle navigation from landing page to login
const handleStartNow = () => {
setCurrentPage("login");
localStorage.setItem("currentPage", "login");
};
// Handle back to landing page
const handleBackToLanding = () => {
setCurrentPage("landing");
localStorage.setItem("currentPage", "landing");
};
// Handle login success
const handleLoginSuccess = (role: string) => {
setIsAuthenticated(true);
setUserRole(role as "dokter" | "admin");
localStorage.setItem("isAuthenticated", "true");
localStorage.setItem("userRole", role);
setCurrentPage("dashboard");
localStorage.setItem("currentPage", "dashboard");
};
// Handle logout
const handleLogout = () => {
setIsAuthenticated(false);
setUserRole("");
localStorage.removeItem("isAuthenticated");
localStorage.removeItem("userRole");
localStorage.removeItem("token");
localStorage.removeItem("dokter");
// Keep hasVisited so user goes to login, not landing page
setCurrentPage("login");
localStorage.setItem("currentPage", "login");
};
// Handle edit billing - navigate to INACBG page for both admin and dokter
const handleEditBilling = async (billingId: number, pasienName?: string) => {
// Check if user is admin or dokter (from localStorage or current state)
const currentRole = userRole || localStorage.getItem("userRole");
console.log("🔍 handleEditBilling called with:", { billingId, pasienName, currentRole });
if (currentRole === "admin" || currentRole === "dokter") {
try {
// Fetch billing data to get patient information
const response = await getAllBilling();
console.log("📦 getAllBilling response:", response);
if (response.data) {
let billingArray: any[] = [];
if (Array.isArray(response.data)) {
billingArray = response.data;
} else if ((response.data as any).data && Array.isArray((response.data as any).data)) {
billingArray = (response.data as any).data;
} else if ((response.data as any).status && (response.data as any).data && Array.isArray((response.data as any).data)) {
billingArray = (response.data as any).data;
}
console.log("📋 billingArray:", billingArray.length, "items");
// Find the billing with matching ID
const billing = billingArray.find(
(item) => (item.ID_Billing || item.id_billing) === billingId
);
console.log("🎯 Found billing:", billing);
console.log("🔍 Searching for billingId:", billingId);
console.log("📊 Item comparison debug:");
billingArray.slice(0, 3).forEach((item, idx) => {
console.log(` Item ${idx}: ID_Billing=${item.ID_Billing}, id_billing=${item.id_billing}`);
});
if (billing) {
// Handle tindakan_rs, icd9, icd10 which might be arrays or strings
const tindakanRS = Array.isArray(billing.Tindakan_RS || billing.tindakan_rs)
? (billing.Tindakan_RS || billing.tindakan_rs).join(", ")
: (billing.Tindakan_RS || billing.tindakan_rs || "");
const icd9Array = Array.isArray(billing.ICD9 || billing.icd9)
? (billing.ICD9 || billing.icd9)
: (billing.ICD9 || billing.icd9 ? [billing.ICD9 || billing.icd9] : []);
const icd10Array = Array.isArray(billing.ICD10 || billing.icd10)
? (billing.ICD10 || billing.icd10)
: (billing.ICD10 || billing.icd10 ? [billing.ICD10 || billing.icd10] : []);
// Prepare pasien data for INACBG component
const pasienData = {
nama: pasienName || billing.Nama_Pasien || billing.nama_pasien || "",
idPasien: `P.${String(billing.ID_Pasien || billing.id_pasien || 0).padStart(4, "0")}`,
kelas: billing.Kelas || billing.kelas || "",
tindakan: tindakanRS,
totalTarifRS: billing.Total_Tarif_RS || billing.total_tarif_rs || 0,
icd9: icd9Array,
icd10: icd10Array,
};
console.log("✅ Setting editingBillingData:", { billingId, pasienData });
setEditingBillingData({
billingId: billingId,
pasienData: pasienData,
});
// Navigate to INACBG page
console.log("🚀 Setting currentPage to inacbg");
setCurrentPage("inacbg");
localStorage.setItem("currentPage", "inacbg");
localStorage.setItem("editingBillingId", billingId.toString());
} else {
console.error("❌ Billing not found with ID:", billingId);
alert("Data billing tidak ditemukan");
}
} else {
console.error("❌ No data in response:", response);
alert("Tidak ada data billing yang diterima dari server");
}
} catch (error) {
console.error("❌ Error fetching billing data:", error);
alert("Gagal memuat data billing: " + (error instanceof Error ? error.message : "Unknown error"));
}
}
};
// Handle back from INACBG to dashboard
const handleBackToDashboard = () => {
// Restore activeMenu from localStorage or set to Ruangan since that's where edit came from
const savedMenu = localStorage.getItem("activeMenu") || "Ruangan";
setActiveMenu(savedMenu);
setCurrentPage("dashboard");
localStorage.setItem("currentPage", "dashboard");
setEditingBillingData(null);
};
// Don't render until initialized to prevent flash of wrong page
if (!isInitialized) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-[#2591D0]">Memuat...</p>
</div>
</div>
);
}
// Render based on current page
if (currentPage === "landing") {
return <LandingPage onStartNow={handleStartNow} />;
}
if (currentPage === "login") {
return <Login onLoginSuccess={handleLoginSuccess} onBackToLanding={handleBackToLanding} />;
}
// Render INACBG page for both admin and dokter
if (currentPage === "inacbg") {
const currentRole = userRole || (typeof window !== "undefined" ? localStorage.getItem("userRole") : null);
if ((currentRole === "admin" || currentRole === "dokter") && editingBillingData) {
return (
<INACBG_Admin_Ruangan
billingId={editingBillingData.billingId}
pasienData={editingBillingData.pasienData}
onLogout={handleLogout}
onBack={handleBackToDashboard}
/>
);
}
// If no data, redirect to dashboard
// Use useEffect to handle redirect
useEffect(() => {
handleBackToDashboard();
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-[#2591D0]">Memuat...</p>
</div>
</div>
);
}
// Render dashboard based on role
// Both admin and dokter go to dokter dashboard (same UI)
// Get role from state or localStorage to ensure correct dashboard
const currentRole = userRole || (typeof window !== "undefined" ? localStorage.getItem("userRole") : null);
// Both admin and dokter see the same dokter dashboard
return <DashboardDokter onLogout={handleLogout} onEditBilling={handleEditBilling} onActiveMenuChange={setActiveMenu} initialActiveMenu={activeMenu} />;
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { Suspense } from 'react';
import Pasien from '@/app/component/pasien';
export default function PasienPage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen bg-white">
<div className="text-center">
<div className="text-lg text-[#2591D0] mb-4">Memuat...</div>
<div className="animate-spin h-8 w-8 border-4 border-[#2591D0] border-t-transparent rounded-full mx-auto"></div>
</div>
</div>
}>
<Pasien />
</Suspense>
);
}
+281
View File
@@ -0,0 +1,281 @@
// Helper function untuk API calls - langsung ke backend Go tanpa melalui Next.js API routes
// Semua platform (Web, Electron, Mobile) langsung memanggil backend Go
// Detect if running in Electron
const isElectron = typeof window !== 'undefined' &&
((window as any).__ELECTRON__ === true ||
(window as any).electron !== undefined ||
(window as any).process?.type === 'renderer' ||
navigator.userAgent.toLowerCase().includes('electron'));
// Detect if running in Capacitor (mobile app)
const isCapacitor = typeof window !== 'undefined' && (window as any).Capacitor !== undefined;
// Get API base URL based on platform - selalu ke backend Go langsung
export const getApiBaseUrl = (): string => {
if (isElectron) {
// Electron app: direct backend call
const electronApiUrl = typeof window !== 'undefined' ? (window as any).__API_URL__ : undefined;
const envApiUrl = process.env.NEXT_PUBLIC_API_URL;
const defaultApiUrl = "http://31.97.109.192:8082";
const finalUrl = electronApiUrl || envApiUrl || defaultApiUrl;
// Debug logging
console.log('🔧 API URL Configuration:', { isElectron, electronApiUrl, envApiUrl, defaultApiUrl, finalUrl });
return finalUrl;
} else if (isCapacitor) {
// Mobile app: direct backend call (Android emulator)
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://31.97.109.192:8082";
console.log('🔧 Mobile API URL:', apiUrl);
return apiUrl;
} else {
// Web browser: direct backend call (tidak lagi melalui Next.js API routes)
const windowApiUrl = typeof window !== 'undefined' ? (window as any).__API_URL__ : undefined;
const envApiUrl = process.env.NEXT_PUBLIC_API_URL;
const defaultApiUrl = "http://localhost:8081";
const apiUrl = windowApiUrl || envApiUrl || defaultApiUrl;
// Debug logging
console.log('🔧 Web API URL Configuration:', { windowApiUrl, envApiUrl, defaultApiUrl, finalUrl: apiUrl });
return apiUrl;
}
};
// Export a resolved base URL for consumers that need it
export const API_BASE_URL = getApiBaseUrl();
// Generic fetch function - selalu memanggil backend Go langsung
export async function apiFetch<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ data?: T; error?: string; status: number }> {
const API_BASE = getApiBaseUrl();
// Build URL - selalu ke backend Go langsung
// Remove /api prefix jika ada, karena endpoint backend Go tidak menggunakan prefix /api
const cleanEndpoint = endpoint.startsWith("/api/")
? endpoint.substring(4)
: endpoint.startsWith("/")
? endpoint
: `/${endpoint}`;
const url = `${API_BASE}${cleanEndpoint}`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
let response: Response;
try {
response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...options.headers,
},
});
clearTimeout(timeoutId);
} catch (fetchError) {
clearTimeout(timeoutId);
if (fetchError instanceof Error) {
if (fetchError.name === 'AbortError') {
return { error: "Request timeout - Server tidak merespon dalam 30 detik", status: 408 };
}
if (fetchError.message.includes("fetch") || fetchError.message.includes("ECONNREFUSED") || fetchError.message.includes("ENOTFOUND") || fetchError.message.includes("CORS")) {
return { error: `Tidak dapat terhubung ke server backend (${API_BASE}). Pastikan backend server berjalan dan dapat diakses.`, status: 0 };
}
}
return { error: fetchError instanceof Error ? fetchError.message : "Terjadi kesalahan yang tidak diketahui", status: 500 };
}
// Handle response
let data: any;
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
try {
const text = await response.text();
if (!text || text.trim() === '') {
return { error: "Empty response from server", status: response.status };
}
data = JSON.parse(text);
} catch (jsonError) {
const text = await response.text().catch(() => "Unable to read response");
return { error: `Invalid JSON response: ${text.substring(0, 200)}`, status: response.status };
}
} else {
const text = await response.text().catch(() => "Unable to read response");
return { error: text || "Request failed", status: response.status };
}
if (!response.ok) {
return { error: data?.message || data?.error || `HTTP ${response.status}: ${response.statusText}`, status: response.status };
}
return { data, status: response.status };
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("fetch") || error.message.includes("ECONNREFUSED") || error.message.includes("ENOTFOUND") || error.message.includes("CORS")) {
return { error: `Tidak dapat terhubung ke server backend (${API_BASE}). Pastikan backend server berjalan di ${API_BASE} dan dapat diakses.`, status: 0 };
}
}
return { error: error instanceof Error ? error.message : "Terjadi kesalahan yang tidak diketahui", status: 500 };
}
}
// Type definitions
export interface Dokter {
id: number;
nama: string;
email: string;
}
export interface Ruangan {
id: number;
nama: string;
}
export interface ICD9 {
kode: string;
deskripsi: string;
}
export interface ICD10 {
kode: string;
deskripsi: string;
}
export interface TarifData {
kode: string;
deskripsi: string;
harga: number;
}
export interface TarifBPJSRawatInap {
kode: string;
deskripsi: string;
tarif: number;
}
export interface TarifBPJSRawatJalan {
kode: string;
deskripsi: string;
tarif: number;
}
export interface BillingRequest {
nama_pasien: string;
id_pasien?: number;
jenis_kelamin: string;
usia: number;
ruangan: string;
kelas: string;
nama_dokter: string[];
tindakan_rs: string[];
billing_sign: string;
tanggal_masuk: string;
tanggal_keluar: string;
icd9: string[];
icd10: string[];
cara_bayar: string;
total_tarif_rs: number;
total_klaim_bpjs?: number; // ← Added: Baseline BPJS claim from FE
id_dpjp?: number; // ← Added: Doctor In Charge (DPJP) ID
}
export interface LoginResponse {
token: string;
dokter?: Dokter;
admin?: { id: number; nama_admin: string };
}
export interface CloseBilling{
id_billing: number;
tanggal_keluar: string;
}
// API Functions
export async function getDokter() {
return apiFetch<Dokter[]>("/dokter", { method: "GET" });
}
export async function CloseBilling() {
return apiFetch<CloseBilling[]>("/billing/close", { method: "post" });
}
export async function getRuangan() {
return apiFetch<Ruangan[]>("/ruangan", { method: "GET" });
}
export async function getICD9() {
return apiFetch<ICD9[]>("/icd9", { method: "GET" });
}
export async function getICD10() {
return apiFetch<ICD10[]>("/icd10", { method: "GET" });
}
export async function getTarifRumahSakit() {
return apiFetch<TarifData[]>("/tarifRS", { method: "GET" });
}
export async function getTarifBPJSRawatInap() {
return apiFetch<TarifBPJSRawatInap[]>("/tarifBPJSRawatInap", { method: "GET" });
}
export async function getTarifBPJSRawatJalan() {
return apiFetch<TarifBPJSRawatJalan[]>("/tarifBPJSRawatJalan", { method: "GET" });
}
export async function getTarifBPJSInacbgRI() {
return apiFetch<TarifBPJSRawatInap[]>("/tarifBPJSRawatInap", { method: "GET" });
}
export async function getTarifBPJSInacbgRJ() {
return apiFetch<TarifBPJSRawatJalan[]>("/tarifBPJSRawatJalan", { method: "GET" });
}
export async function searchPasien(nama: string) {
return apiFetch(`/pasien/search?nama=${encodeURIComponent(nama)}`, { method: "GET" });
}
export async function createBilling(billingData: BillingRequest) {
return apiFetch("/billing", {
method: "POST",
body: JSON.stringify(billingData)
});
}
export async function getAllBilling() {
return apiFetch("/admin/billing", { method: "GET" });
}
export async function getRiwayatBilling() {
return apiFetch("/admin/riwayat-pasien-all", { method: "GET" }); // return apiFetch("/admin/riwayat-billing", { method: "GET" });
}
export async function editINACBG(id: number) {
return apiFetch(`/admin/inacbg`, { method: "PUT" });
}
export async function getallbilingaktif() {
return apiFetch("/billing/aktif/all", { method: "GET" });
}
export async function getBillingAktifByNama(nama: string) {
return apiFetch(`/billing/aktif?nama_pasien=${encodeURIComponent(nama)}`, { method: "GET" });
}
export async function loginDokter(credentials: { email: string; password: string }) {
return apiFetch<LoginResponse>("/login", {
method: "POST",
body: JSON.stringify(credentials)
});
}
export async function loginAdmin(credentials: { nama_admin: string; password: string }) {
return apiFetch<LoginResponse>("/admin/login", {
method: "POST",
body: JSON.stringify(credentials)
});
}
+519
View File
@@ -0,0 +1,519 @@
// API Configuration and Utilities
// Auto-detect platform: use Next.js API routes for web, direct backend calls for mobile/Electron
// Detect if running in Capacitor (mobile app)
const isCapacitor = typeof window !== 'undefined' &&
(window as any).Capacitor !== undefined;
// Detect if running in Electron (desktop app)
const isElectron = typeof window !== 'undefined' &&
(window as any).electron !== undefined;
// All platforms: use direct backend URL
const getApiBaseUrl = (): string => {
if (isCapacitor) {
// Mobile app: direct backend call
return process.env.NEXT_PUBLIC_API_URL || "http://31.97.109.192:8082";
} else if (isElectron) {
// Electron desktop app: direct backend call
return process.env.NEXT_PUBLIC_API_URL || "http://31.97.109.192:8082";
} else {
// Web browser: direct backend call
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
}
};
const API_BASE_URL = getApiBaseUrl();
interface ApiResponse<T> {
data?: T;
error?: string;
message?: string;
status: number;
}
interface FetchOptions extends RequestInit {
timeout?: number;
}
// Generic API fetch function
export async function apiRequest<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<ApiResponse<T>> {
const { timeout = 10000, ...fetchOptions } = options;
// Build URL based on platform
let url: string;
// All platforms use direct backend call
const cleanEndpoint = endpoint.startsWith("/api/")
? endpoint.substring(4) // Remove "/api"
: endpoint.startsWith("/")
? endpoint
: `/${endpoint}`;
url = `${API_BASE_URL}${cleanEndpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...fetchOptions.headers,
},
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
error:
errorData.message ||
errorData.error ||
`HTTP ${response.status}: ${response.statusText}`,
status: response.status,
};
}
const data = await response.json();
return {
data,
status: response.status,
};
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error) {
if (error.name === "AbortError") {
return {
error:
"Request timeout - Server tidak merespon dalam waktu yang ditentukan",
status: 408,
};
}
// Network errors
if (error.message.includes("fetch")) {
return {
error: `Tidak dapat terhubung ke server (${API_BASE_URL}). Pastikan backend server berjalan.`,
status: 0,
};
}
}
return {
error:
error instanceof Error
? error.message
: "Terjadi kesalahan yang tidak diketahui",
status: 500,
};
}
}
// Specific API functions for Tarif Rumah Sakit
export interface TarifData {
KodeRS: string;
Deskripsi: string;
Harga: number;
Kategori: string;
}
export interface TarifQueryParams {
kategori?: string;
search?: string;
page?: number;
limit?: number;
}
export async function getTarifRumahSakit(
params: TarifQueryParams = {}
): Promise<ApiResponse<TarifData[]>> {
const queryParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
queryParams.append(key, value.toString());
}
});
const endpoint = `/api/tarifRS${queryParams.toString() ? "?" + queryParams.toString() : ""
}`;
return apiRequest<TarifData[]>(endpoint);
}
export async function createTarifRumahSakit(
data: TarifData
): Promise<ApiResponse<TarifData>> {
return apiRequest<TarifData>("/api/tarifRS", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function updateTarifRumahSakit(
kodeRS: string,
data: Partial<TarifData>
): Promise<ApiResponse<TarifData>> {
return apiRequest<TarifData>(`/api/tarifRS/${kodeRS}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function deleteTarifRumahSakit(
kodeRS: string
): Promise<ApiResponse<void>> {
return apiRequest<void>(`/api/tarifRS/${kodeRS}`, {
method: "DELETE",
});
}
// Health check function
export async function checkBackendHealth(): Promise<
ApiResponse<{ status: string; message: string }>
> {
// Health check can use any endpoint, using login as it's lightweight
return apiRequest("/api/login", {
method: "POST",
body: JSON.stringify({ email: "healthcheck", password: "healthcheck" }),
timeout: 3000,
});
}
// ============ LOGIN API ============
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
status: string;
token: string;
dokter: {
id: number;
nama: string;
ksm: string;
email: string;
};
}
export async function loginDokter(
credentials: LoginRequest
): Promise<ApiResponse<LoginResponse>> {
// Validate credentials before sending
if (!credentials.email || !credentials.password) {
return {
error: "Email/username dan password harus diisi",
status: 400,
};
}
// Ensure credentials are properly formatted
const payload = {
email: credentials.email.trim(),
password: credentials.password.trim(),
};
// Ensure body is not empty
const bodyString = JSON.stringify(payload);
if (!bodyString || bodyString === "{}") {
return {
error: "Payload tidak valid",
status: 400,
};
}
return apiRequest<LoginResponse>("/api/login", {
method: "POST",
body: bodyString,
});
}
// Admin login request/response types
export interface AdminLoginRequest {
nama_admin: string;
password: string;
}
export interface AdminLoginResponse {
status: string;
token: string;
admin: {
id: number;
nama_admin: string;
id_ruangan: string;
};
}
export async function loginAdmin(
credentials: AdminLoginRequest
): Promise<ApiResponse<AdminLoginResponse>> {
// Validate credentials before sending
if (!credentials.nama_admin || !credentials.password) {
return {
error: "Username dan password harus diisi",
status: 400,
};
}
// Ensure credentials are properly formatted
const payload = {
Nama_Admin: credentials.nama_admin.trim(),
Password: credentials.password.trim(),
};
// Ensure body is not empty
const bodyString = JSON.stringify(payload);
if (!bodyString || bodyString === "{}") {
return {
error: "Payload tidak valid",
status: 400,
};
}
return apiRequest<AdminLoginResponse>("/api/admin/login", {
method: "POST",
body: bodyString,
});
}
// ============ DOKTER API ============
export interface Dokter {
ID_Dokter: number;
Nama_Dokter: string;
KSM: string;
Email_UB: string;
Email_Pribadi: string;
Status: string;
}
export async function getDokter(): Promise<ApiResponse<Dokter[]>> {
return apiRequest<Dokter[]>("/api/dokter");
}
// ============ RUANGAN API ============
export interface Ruangan {
ID_Ruangan: string;
Jenis_Ruangan: string;
Nama_Ruangan: string;
Keterangan: string;
Kategori_ruangan: string;
}
export async function getRuangan(): Promise<ApiResponse<Ruangan[]>> {
return apiRequest<Ruangan[]>("/api/ruangan");
}
// ============ ICD9 API ============
export interface ICD9 {
Kode_ICD9: string;
Prosedur: string;
Versi: string;
}
export async function getICD9(): Promise<ApiResponse<ICD9[]>> {
return apiRequest<ICD9[]>("/api/icd9");
}
// ============ ICD10 API ============
export interface ICD10 {
Kode_ICD10: string;
Diagnosa: string;
Versi: string;
}
export async function getICD10(): Promise<ApiResponse<ICD10[]>> {
return apiRequest<ICD10[]>("/api/icd10");
}
// ============ TARIF BPJS API ============
export interface TarifBPJSRawatInap {
KodeINA: string;
Deskripsi: string;
Kelas1: number;
Kelas2: number;
Kelas3: number;
}
export interface TarifBPJSRawatJalan {
KodeINA: string;
Deskripsi: string;
TarifINACBG: number;
tarif_inacbg?: number; // Backend sends this field name
}
export async function getTarifBPJSRawatInap(): Promise<
ApiResponse<TarifBPJSRawatInap[]>
> {
return apiRequest<TarifBPJSRawatInap[]>("/api/tarifBPJSRawatInap");
}
export async function getTarifBPJSRawatInapByKode(
kode: string
): Promise<ApiResponse<TarifBPJSRawatInap>> {
return apiRequest<TarifBPJSRawatInap>(`/api/tarifBPJS/${kode}`);
}
export async function getTarifBPJSRawatJalan(): Promise<
ApiResponse<TarifBPJSRawatJalan[]>
> {
return apiRequest<TarifBPJSRawatJalan[]>("/api/tarifBPJSRawatJalan");
}
export async function getTarifBPJSRawatJalanByKode(
kode: string
): Promise<ApiResponse<TarifBPJSRawatJalan>> {
return apiRequest<TarifBPJSRawatJalan>(`/api/tarifBPJSRawatJalan/${kode}`);
}
// ============ TARIF RS API (already exists, but adding detail function) ============
export async function getTarifRSByKode(
kode: string
): Promise<ApiResponse<TarifData>> {
return apiRequest<TarifData>(`/api/tarifRS/${kode}`);
}
export async function getTarifRSByKategori(
kategori: string
): Promise<ApiResponse<TarifData[]>> {
// Backend uses path parameter: /tarifRSByKategori/:kategori
return apiRequest<TarifData[]>(`/api/tarifRSByKategori/${encodeURIComponent(kategori)}`);
}
// ============ PASIEN API ============
export interface Pasien {
ID_Pasien: number;
Nama_Pasien: string;
Jenis_Kelamin: string;
Usia: number;
Ruangan: string;
Kelas: string;
}
export async function getPasienById(
id: number
): Promise<ApiResponse<{ message: string; data: Pasien }>> {
return apiRequest<{ message: string; data: Pasien }>(`/api/pasien/${id}`);
}
export async function searchPasien(
nama: string
): Promise<ApiResponse<{ status: string; data: Pasien[] }>> {
return apiRequest<{ status: string; data: Pasien[] }>(
`/api/pasien/search?nama=${encodeURIComponent(nama)}`
);
}
// ============ BILLING API ============
export interface BillingRequest {
nama_dokter: string[];
nama_pasien: string;
id_pasien?: number;
jenis_kelamin: string;
usia: number;
ruangan: string;
kelas: string;
tindakan_rs: string[];
billing_sign?: string | null;
tanggal_masuk?: string;
tanggal_keluar?: string;
icd9: string[];
icd10: string[];
cara_bayar: string;
total_tarif_rs: number;
total_klaim_bpjs?: number; // ← Added: Baseline BPJS claim amount from FE
}
export interface BillingResponse {
status: string;
message: string;
data: {
pasien: Pasien;
billing: {
ID_Billing: number;
ID_Pasien: number;
Total_Tarif_RS: number;
[key: string]: any;
};
tindakan_rs: any[];
icd9: any[];
icd10: any[];
};
}
export async function createBilling(
data: BillingRequest
): Promise<ApiResponse<BillingResponse>> {
return apiRequest<BillingResponse>("/api/billing", {
method: "POST",
body: JSON.stringify(data),
});
}
export interface BillingAktifResponse {
status: string;
message: string;
data: {
billing: any;
tindakan_rs: any[];
icd9: any[];
icd10: any[];
};
}
export async function getBillingAktifByNama(
namaPasien: string
): Promise<ApiResponse<BillingAktifResponse>> {
return apiRequest<BillingAktifResponse>(
`/api/billing/aktif?nama_pasien=${encodeURIComponent(namaPasien)}`
);
}
// ============ ADMIN BILLING API ============
export async function getAllBilling(): Promise<
ApiResponse<{ status: string; data: any[] }>
> {
return apiRequest<{ status: string; data: any[] }>("/api/admin/billing");
}
export async function getBillingById(
id: number
): Promise<ApiResponse<any>> {
return apiRequest<any>(`/api/admin/billing/${id}`);
}
export async function getRuanganDenganPasien(): Promise<
ApiResponse<any[]>
> {
return apiRequest<any[]>("/api/admin/ruangan-dengan-pasien");
}
export interface PostINACBGRequest {
id_billing: number;
tipe_inacbg: string;
kode_inacbg: string[];
total_klaim: number;
billing_sign: string;
tanggal_keluar: string;
}
export async function postINACBGAdmin(
data: PostINACBGRequest
): Promise<ApiResponse<{ status: string; message: string }>> {
return apiRequest<{ status: string; message: string }>("/api/admin/inacbg", {
method: "POST",
body: JSON.stringify(data),
});
}
// API_BASE_URL is now "/api" for Next.js API routes
export { API_BASE_URL };