first commit
This commit is contained in:
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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user