fix new schema and new view and new theme blue and white

This commit is contained in:
2025-08-14 16:30:19 +07:00
parent ee62ec1ff6
commit d31adcc52f
21 changed files with 5487 additions and 5101 deletions

View File

@@ -6,14 +6,21 @@ import {
} from "react-router-dom";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import MedicalRecord from "./pages/MedicalRecord";
import CostRecommendation from "./pages/CostRecommendation";
import BPJSSync from "./pages/BPJSSync";
import BPJSCode from "./pages/BPJSCode";
import MedicalRecordSync from "./pages/MedicalRecordSync";
import UserManagement from "./pages/UserManagement";
import RoleManagement from "./pages/RoleManagement";
// Medical Records Components
import {
Clinical,
Administrative,
CostRecommendation,
BPJSCodeification,
} from "./pages/medical-records";
// Integration Components
import { BPJSSyncLogs, MedicalRecordSyncLogs } from "./pages/integration";
// System Administration Components
import { User, Role } from "./pages/system-administration";
import NotFound from "./pages/NotFound";
import NotFoundProtected from "./pages/NotFoundProtected";
import Layout from "./components/Layout";
@@ -33,18 +40,67 @@ function App() {
}
>
<Route path="dashboard" element={<Dashboard />} />
<Route path="cost-recommendation" element={<CostRecommendation />} />
<Route path="patients/medical-record" element={<MedicalRecord />} />
<Route path="patients/bpjs-code" element={<BPJSCode />} />
<Route path="integration/bpjs" element={<BPJSSync />} />
{/* Medical Records Routes */}
<Route path="medical-records/clinical" element={<Clinical />} />
<Route
path="integration/medical-record"
element={<MedicalRecordSync />}
path="medical-records/administrative"
element={<Administrative />}
/>
<Route
path="medical-records/cost-recommendation"
element={<CostRecommendation />}
/>
<Route
path="medical-records/bpjs-codification"
element={<BPJSCodeification />}
/>
{/* Data Integration Routes */}
<Route path="integration/bpjs-sync" element={<BPJSSyncLogs />} />
<Route
path="integration/medical-record-sync"
element={<MedicalRecordSyncLogs />}
/>
{/* System Administration Routes */}
<Route path="system-administration/user" element={<User />} />
<Route path="system-administration/role" element={<Role />} />
{/* Legacy Routes (redirects to new organized routes) */}
<Route
path="cost-recommendation"
element={
<Navigate to="/medical-records/cost-recommendation" replace />
}
/>
<Route
path="patients/medical-record"
element={<Navigate to="/medical-records/clinical" replace />}
/>
<Route
path="patients/bpjs-code"
element={
<Navigate to="/medical-records/bpjs-codification" replace />
}
/>
<Route
path="integration/bpjs"
element={<Navigate to="/integration/bpjs-sync" replace />}
/>
<Route
path="integration/medical-record"
element={<Navigate to="/integration/medical-record-sync" replace />}
/>
<Route
path="admin/users"
element={<Navigate to="/system-administration/user" replace />}
/>
<Route
path="admin/roles"
element={<Navigate to="/system-administration/role" replace />}
/>
<Route path="admin/users" element={<UserManagement />} />
<Route path="admin/roles" element={<RoleManagement />} />
<Route index element={<Navigate to="/dashboard" replace />} />
{/* Protected 404 - untuk route di dalam layout sidebar */}
<Route path="*" element={<NotFoundProtected />} />

View File

@@ -33,52 +33,64 @@ export default function Sidebar({
path: "/dashboard",
color: "text-blue-600",
},
{
title: "Cost Recommendation",
icon: TrendingUp,
path: "/cost-recommendation",
color: "text-green-600",
},
{
title: "Integrasi Data",
icon: Database,
color: "text-purple-600",
color: "text-blue-600",
submenu: [
{ title: "BPJS", icon: Shield, path: "/integration/bpjs" },
{
title: "Medical Record",
title: "Sinkronisasi BPJS",
icon: Shield,
path: "/integration/bpjs-sync",
},
{
title: "Sinkronisasi Rekam Medis",
icon: FileText,
path: "/integration/medical-record",
path: "/integration/medical-record-sync",
},
],
},
{
title: "Pasien",
title: "Rekam Medis",
icon: Users,
color: "text-orange-600",
color: "text-blue-600",
submenu: [
{
title: "Medical Record Pasien",
title: "Klinis",
icon: FileText,
path: "/patients/medical-record",
path: "/medical-records/clinical",
},
{
title: "Administratif",
icon: FileText,
path: "/medical-records/administrative",
},
{
title: "Rekomendasi Biaya",
icon: TrendingUp,
path: "/medical-records/cost-recommendation",
},
{
title: "Kodefikasi BPJS",
icon: FileText,
path: "/medical-records/bpjs-codification",
},
{ title: "BPJS Code", icon: Shield, path: "/patients/bpjs-code" },
],
},
{
title: "System Administration",
title: "Administrasi Sistem",
icon: UserCog,
color: "text-red-700",
color: "text-blue-600",
submenu: [
{
title: "Manajemen User",
title: "Pengguna",
icon: Users,
path: "/admin/users",
path: "/system-administration/user",
},
{
title: "Manajemen Role",
title: "Peran",
icon: Lock,
path: "/admin/roles",
path: "/system-administration/role",
},
],
},
@@ -127,7 +139,7 @@ export default function Sidebar({
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-1 hover:bg-gray-100 rounded-md transition-colors"
className="p-1 hover:bg-blue-50 hover:text-blue-600 rounded-md transition-colors"
title="Collapse Sidebar"
>
<ChevronLeft className="h-4 w-4 text-gray-500" />
@@ -138,7 +150,7 @@ export default function Sidebar({
<div className="w-full flex justify-center">
<button
onClick={onToggleCollapse}
className="bg-white p-2 rounded-lg hover:bg-gray-50 transition-colors border border-gray-200 shadow-sm"
className="bg-white p-2 rounded-lg hover:bg-blue-50 hover:border-blue-300 transition-colors border border-gray-200 shadow-sm"
title="ClaimGuard - Expand Sidebar"
>
<img
@@ -174,7 +186,7 @@ export default function Sidebar({
}
className={clsx(
"w-full flex items-center py-2 rounded-lg text-sm font-medium transition-colors",
"hover:bg-gray-50 text-gray-700",
"hover:bg-blue-50 hover:text-blue-700 text-gray-700",
isCollapsed ? "px-2 justify-center" : "px-3"
)}
>
@@ -212,8 +224,8 @@ export default function Sidebar({
className={clsx(
"flex items-center px-3 py-2 rounded-lg text-sm transition-colors",
isActive(subItem.path)
? "bg-green-50 text-green-700 border-r-2 border-green-600"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
? "bg-blue-50 text-blue-700 border-r-2 border-blue-600"
: "text-gray-600 hover:bg-blue-50 hover:text-blue-700"
)}
>
<subItem.icon className="h-4 w-4 mr-3" />
@@ -230,8 +242,8 @@ export default function Sidebar({
className={clsx(
"flex items-center py-2 rounded-lg text-sm font-medium transition-colors",
isActive(item.path)
? "bg-green-50 text-green-700 border-r-2 border-green-600"
: "text-gray-700 hover:bg-gray-50",
? "bg-blue-50 text-blue-700 border-r-2 border-blue-600"
: "text-gray-700 hover:bg-blue-50 hover:text-blue-700",
isCollapsed ? "px-2 justify-center" : "px-3"
)}
>

View File

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

View File

@@ -1,708 +0,0 @@
import { useState, useMemo } from "react";
import {
Calendar,
Filter,
AlertCircle,
CheckCircle,
Clock,
RefreshCw,
Upload,
Database,
Building2,
XCircle,
} from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
interface BPJSSyncLog {
id: string;
timestamp: string;
type: "import" | "sync";
status: "success" | "failed" | "in_progress";
claimsProcessed: number;
claimsSuccess: number;
claimsFailed: number;
source: string;
duration: number;
errorMessage?: string;
}
interface BPJSSyncStats {
totalSyncs: number;
successfulSyncs: number;
failedSyncs: number;
lastSyncTime: string;
totalClaimsProcessed: number;
averageDuration: number;
}
export default function BPJSSync() {
// Helper function to get default dates (1 month back)
const getDefaultDates = () => {
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
return { startDate, endDate };
};
const [statusInput, setStatusInput] = useState("all");
const [appliedStatus, setAppliedStatus] = useState("all");
const [isImporting, setIsImporting] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const { startDate: defaultStartDate, endDate: defaultEndDate } =
getDefaultDates();
const [startDateInput, setStartDateInput] = useState<Date | undefined>(
defaultStartDate
);
const [endDateInput, setEndDateInput] = useState<Date | undefined>(
defaultEndDate
);
const [appliedStartDate, setAppliedStartDate] = useState<Date | undefined>(
defaultStartDate
);
const [appliedEndDate, setAppliedEndDate] = useState<Date | undefined>(
defaultEndDate
);
const [hasDateFiltered, setHasDateFiltered] = useState(true); // Start with default filter applied
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// Sample BPJS sync logs data with additional entries for pagination
const [syncLogs] = useState<BPJSSyncLog[]>(() => {
const baseLogs: BPJSSyncLog[] = [
{
id: "1",
timestamp: "2024-01-15T14:30:00Z",
type: "sync",
status: "success",
claimsProcessed: 234,
claimsSuccess: 230,
claimsFailed: 4,
source: "BPJS Kesehatan API",
duration: 32,
},
{
id: "2",
timestamp: "2024-01-15T10:15:00Z",
type: "import",
status: "success",
claimsProcessed: 89,
claimsSuccess: 89,
claimsFailed: 0,
source: "Hospital Billing System",
duration: 15,
},
{
id: "3",
timestamp: "2024-01-14T16:45:00Z",
type: "sync",
status: "failed",
claimsProcessed: 0,
claimsSuccess: 0,
claimsFailed: 0,
source: "BPJS Kesehatan API",
duration: 0,
errorMessage: "API rate limit exceeded",
},
{
id: "4",
timestamp: "2024-01-14T09:30:00Z",
type: "import",
status: "success",
claimsProcessed: 156,
claimsSuccess: 150,
claimsFailed: 6,
source: "External Claims System",
duration: 28,
},
{
id: "5",
timestamp: "2024-01-13T13:20:00Z",
type: "sync",
status: "in_progress",
claimsProcessed: 45,
claimsSuccess: 45,
claimsFailed: 0,
source: "BPJS Kesehatan API",
duration: 0,
},
];
// Generate additional logs for pagination
const generated: BPJSSyncLog[] = Array.from({ length: 20 }).map(
(_, idx) => {
const n = idx + 6;
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 30));
date.setHours(
Math.floor(Math.random() * 24),
Math.floor(Math.random() * 60)
);
return {
id: String(n),
timestamp: date.toISOString(),
type: Math.random() > 0.5 ? "sync" : "import",
status:
Math.random() > 0.8
? "failed"
: Math.random() > 0.1
? "success"
: "in_progress",
claimsProcessed: Math.floor(Math.random() * 500) + 50,
claimsSuccess: Math.floor(Math.random() * 450) + 40,
claimsFailed: Math.floor(Math.random() * 20),
source: [
"BPJS Kesehatan API",
"Hospital Billing System",
"External Claims System",
"Pharmacy System",
][Math.floor(Math.random() * 4)],
duration: Math.floor(Math.random() * 60) + 10,
...(Math.random() > 0.9 && { errorMessage: "Connection timeout" }),
} as BPJSSyncLog;
}
);
return [...baseLogs, ...generated];
});
// Calculate statistics
const stats: BPJSSyncStats = {
totalSyncs: syncLogs.length,
successfulSyncs: syncLogs.filter((log) => log.status === "success").length,
failedSyncs: syncLogs.filter((log) => log.status === "failed").length,
lastSyncTime: syncLogs[0]?.timestamp || "",
totalClaimsProcessed: syncLogs.reduce(
(sum, log) => sum + log.claimsProcessed,
0
),
averageDuration:
syncLogs
.filter((log) => log.duration > 0)
.reduce((sum, log) => sum + log.duration, 0) /
syncLogs.filter((log) => log.duration > 0).length || 0,
};
// Filter sync logs based on date and status (status applied via button)
const filteredLogs = useMemo(() => {
return syncLogs.filter((log) => {
const matchesStatus =
appliedStatus === "all" || log.status === appliedStatus;
let matchesDate = true;
if (hasDateFiltered && (appliedStartDate || appliedEndDate)) {
const logDate = new Date(log.timestamp);
const startOk = !appliedStartDate || logDate >= appliedStartDate;
const endOk = !appliedEndDate || logDate <= appliedEndDate;
matchesDate = startOk && endOk;
}
return matchesStatus && matchesDate;
});
}, [
syncLogs,
appliedStatus,
hasDateFiltered,
appliedStartDate,
appliedEndDate,
]);
const columnHelper = createColumnHelper<BPJSSyncLog>();
const columns: ColumnDef<BPJSSyncLog, unknown>[] = useMemo(
() => [
columnHelper.display({
id: "timeAndType",
header: "Waktu & Type",
cell: (info) => {
const log = info.row.original;
return (
<div>
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatDate(log.timestamp)}
</div>
<div className="text-sm font-medium text-gray-900 mt-1">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.type === "import"
? "bg-blue-100 text-blue-800"
: "bg-purple-100 text-purple-800"
}`}
>
{log.type === "import" ? "Import" : "Sync"}
</span>
</div>
</div>
);
},
}),
columnHelper.display({
id: "source",
header: "Source System",
cell: (info) => {
const log = info.row.original;
return (
<div className="flex items-center">
<Building2 className="h-4 w-4 text-gray-400 mr-2" />
<div>
<div className="text-sm font-medium text-gray-900">
{log.source}
</div>
{log.errorMessage && (
<div className="text-sm text-red-600 mt-1">
{log.errorMessage}
</div>
)}
</div>
</div>
);
},
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (info) => {
const log = info.row.original;
return (
<div className="flex items-center">
<span
className={`inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${
log.status === "success"
? "bg-green-100 text-green-800"
: log.status === "failed"
? "bg-red-100 text-red-800"
: "bg-blue-100 text-blue-800"
}`}
>
{log.status === "success" ? (
<CheckCircle className="h-4 w-4 mr-1" />
) : log.status === "failed" ? (
<AlertCircle className="h-4 w-4 mr-1" />
) : (
<Clock className="h-4 w-4 mr-1" />
)}
{log.status === "success"
? "Berhasil"
: log.status === "failed"
? "Gagal"
: "Berlangsung"}
</span>
</div>
);
},
}),
columnHelper.display({
id: "claimsProcessed",
header: "Claims Processed",
cell: (info) => {
const log = info.row.original;
return (
<div>
<div className="text-sm text-gray-900">
{log.claimsProcessed.toLocaleString()}
</div>
<div className="text-sm text-gray-500">
{log.claimsSuccess > 0 && (
<span className="text-green-600"> {log.claimsSuccess}</span>
)}
{log.claimsFailed > 0 && (
<span className="text-red-600 ml-2">
{log.claimsFailed}
</span>
)}
</div>
</div>
);
},
}),
columnHelper.display({
id: "successRate",
header: "Success Rate",
cell: (info) => {
const log = info.row.original;
const successRate =
log.claimsProcessed > 0
? Math.round((log.claimsSuccess / log.claimsProcessed) * 100)
: 0;
const displayRate = Math.min(100, successRate); // Batasi maksimal 100%
return (
<div>
<div className="text-sm text-gray-900">{successRate}%</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{ width: `${displayRate}%` }}
></div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "duration",
header: "Duration",
cell: (info) => {
const log = info.row.original;
return (
<div className="text-sm text-gray-900">
{log.duration > 0 ? `${log.duration}s` : "-"}
</div>
);
},
}),
],
[columnHelper]
);
const table = useReactTable({
data: filteredLogs,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
enableRowSelection: false,
debugTable: false,
});
const handleImport = async () => {
setIsImporting(true);
// Simulate import process
setTimeout(() => {
setIsImporting(false);
// Add new log entry here
}, 3000);
};
const handleSync = async () => {
setIsSyncing(true);
// Simulate sync process
setTimeout(() => {
setIsSyncing(false);
// Add new log entry here
}, 5000);
};
return (
<div className="p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
<Database className="h-8 w-8 text-blue-600 mr-3" />
BPJS Sync
</h1>
<p className="text-gray-600 mt-1">
Sinkronisasi dan integrasi data klaim BPJS dari sistem eksternal
</p>
</div>
<div className="flex space-x-3">
<button
onClick={handleImport}
disabled={isImporting || isSyncing}
className="btn-secondary flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
<span>{isImporting ? "Importing..." : "Import Data"}</span>
</button>
<button
onClick={handleSync}
disabled={isImporting || isSyncing}
className="btn-primary flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSyncing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span>{isSyncing ? "Syncing..." : "Sync by API"}</span>
</button>
</div>
</div>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Sync</p>
<p className="text-2xl font-bold text-blue-600">
{stats.totalSyncs}
</p>
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<RefreshCw className="h-6 w-6 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Berhasil</p>
<p className="text-2xl font-bold text-green-600">
{stats.successfulSyncs}
</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="h-6 w-6 text-green-600" />
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Gagal</p>
<p className="text-2xl font-bold text-red-600">
{stats.failedSyncs}
</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<XCircle className="h-6 w-6 text-red-600" />
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Avg Duration
</p>
<p className="text-2xl font-bold text-orange-600">
{Math.round(stats.averageDuration)}s
</p>
</div>
<div className="p-3 bg-orange-100 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" />
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white p-6 rounded-lg shadow-sm border mb-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-3">
{/* Date range */}
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-400" />
<DatePicker
selected={startDateInput}
onChange={(date) => setStartDateInput(date || undefined)}
selectsStart
startDate={startDateInput}
endDate={endDateInput}
placeholderText="Start Date"
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-36"
dateFormat="dd MMM yyyy"
/>
<span className="text-gray-400 text-sm">s/d</span>
<DatePicker
selected={endDateInput}
onChange={(date) => setEndDateInput(date || undefined)}
selectsEnd
startDate={startDateInput}
endDate={endDateInput}
minDate={startDateInput}
placeholderText="End Date"
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-36"
dateFormat="dd MMM yyyy"
/>
</div>
{/* Status */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">Semua Status</option>
<option value="success">Berhasil</option>
<option value="failed">Gagal</option>
<option value="in_progress">Berlangsung</option>
</select>
</div>
{/* Buttons */}
<div className="flex items-center space-x-2">
<button
onClick={() => {
setAppliedStartDate(startDateInput);
setAppliedEndDate(endDateInput);
setHasDateFiltered(Boolean(startDateInput || endDateInput));
setAppliedStatus(statusInput);
}}
className="btn-primary px-3 py-2"
>
Filter
</button>
<button
onClick={() => {
const { startDate, endDate } = getDefaultDates();
setStartDateInput(startDate);
setEndDateInput(endDate);
setAppliedStartDate(startDate);
setAppliedEndDate(endDate);
setHasDateFiltered(true);
setStatusInput("all");
setAppliedStatus("all");
}}
className="btn-secondary px-3 py-2 border border-gray-300 rounded-md"
>
Reset
</button>
</div>
</div>
</div>
</div>
{/* Sync Logs Table */}
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Log Sinkronisasi BPJS
</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 flex items-center space-x-3">
<span>Page</span>
<input
type="number"
min={1}
max={Math.max(1, table.getPageCount())}
value={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = Number(e.target.value) - 1;
if (!Number.isNaN(page)) table.setPageIndex(page);
}}
className="w-16 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span>of {table.getPageCount() || 1}</span>
</div>
<div className="flex items-center space-x-3">
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
« First
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Prev
</button>
<select
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 30, 50].map((ps) => (
<option key={ps} value={ps}>
{ps}/page
</option>
))}
</select>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Last »
</button>
</div>
</div>
</div>
</div>
{/* Empty State */}
{filteredLogs.length === 0 && (
<div className="text-center py-12">
<AlertCircle className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada log sinkronisasi ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Tidak ada log yang sesuai dengan kriteria pencarian.
</p>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,593 +0,0 @@
import { useState } from "react";
import { FileText, Search } from "lucide-react";
interface MedicalRecord {
id: string;
patientId: string;
patientName: string;
patientAge: number;
patientGender: string;
recordDate: string;
diagnosis: string;
icdCode: string;
treatment: string;
doctor: string;
vital: {
bloodPressure: string;
heartRate: number;
temperature: number;
weight: number;
};
}
const dateDaysAgo = (days: number, hour = 10, minute = 30) => {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - days);
d.setHours(hour, minute, 0, 0);
return d.toISOString();
};
const sampleMedicalRecords: MedicalRecord[] = [
{
id: "MR001",
patientId: "P001",
patientName: "Ahmad Rizki",
patientAge: 45,
patientGender: "Laki-laki",
recordDate: dateDaysAgo(5, 10, 30),
diagnosis: "Hipertensi Grade 2",
icdCode: "I10",
treatment: "Amlodipine 10mg 1x1, Diet rendah garam",
doctor: "Dr. Siti Nurhaliza",
vital: {
bloodPressure: "160/100",
heartRate: 88,
temperature: 36.5,
weight: 75,
},
},
{
id: "MR002",
patientId: "P002",
patientName: "Maria Lopez",
patientAge: 32,
patientGender: "Perempuan",
recordDate: dateDaysAgo(12, 14, 15),
diagnosis: "Gastritis Akut",
icdCode: "K29.0",
treatment: "Omeprazole 20mg 2x1, Antasida 3x1",
doctor: "Dr. Budi Santoso",
vital: {
bloodPressure: "120/80",
heartRate: 76,
temperature: 37.2,
weight: 58,
},
},
{
id: "MR003",
patientId: "P003",
patientName: "Dewi Sartika",
patientAge: 28,
patientGender: "Perempuan",
recordDate: dateDaysAgo(20, 9, 45),
diagnosis: "Kehamilan Normal G1P0A0",
icdCode: "Z34.0",
treatment: "Asam folat 1x1, Vitamin prenatal",
doctor: "Dr. Ahmad Rizki",
vital: {
bloodPressure: "110/70",
heartRate: 82,
temperature: 36.8,
weight: 62,
},
},
{
id: "MR004",
patientId: "P004",
patientName: "Jamal",
patientAge: 56,
patientGender: "Laki-laki",
recordDate: dateDaysAgo(28, 16, 20),
diagnosis: "Diabetes Mellitus Tipe 2",
icdCode: "E11.9",
treatment: "Metformin 500mg 2x1, Diet DM",
doctor: "Dr. Siti Nurhaliza",
vital: {
bloodPressure: "140/90",
heartRate: 92,
temperature: 36.7,
weight: 82,
},
},
{
id: "MR005",
patientId: "P004",
patientName: "Jamal",
patientAge: 56,
patientGender: "Laki-laki",
recordDate: dateDaysAgo(7, 11, 10),
diagnosis: "Kontrol DM tipe 2, evaluasi terapi",
icdCode: "E11.9",
treatment: "Metformin 850mg 2x1, Edukasi diet & aktivitas",
doctor: "Dr. Budi Santoso",
vital: {
bloodPressure: "130/85",
heartRate: 78,
temperature: 36.6,
weight: 81,
},
},
];
export default function MedicalRecord() {
const [records] = useState<MedicalRecord[]>(sampleMedicalRecords);
const [nameInput, setNameInput] = useState("");
const [idInput, setIdInput] = useState("");
const [appliedName, setAppliedName] = useState("");
const [appliedId, setAppliedId] = useState("");
const [hasSearched, setHasSearched] = useState(false);
type SectionTab = "riwayat" | "obat" | "alergi";
const [activeSectionById, setActiveSectionById] = useState<
Record<string, SectionTab>
>({});
const getActiveSection = (recId: string): SectionTab =>
activeSectionById[recId] ?? "riwayat";
const setActiveSection = (recId: string, tab: SectionTab) =>
setActiveSectionById((prev) => ({ ...prev, [recId]: tab }));
// departments removed (unused)
const handleSearch = () => {
setAppliedName(nameInput.trim());
setAppliedId(idInput.trim());
setHasSearched(true);
};
const handleReset = () => {
setNameInput("");
setIdInput("");
setAppliedName("");
setAppliedId("");
setHasSearched(false);
};
const filteredRecords = (() => {
if (!hasSearched) return [] as MedicalRecord[];
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const matches = records.filter((record) => {
const inLast30Days = new Date(record.recordDate) >= thirtyDaysAgo;
const matchName =
!!appliedName &&
record.patientName.toLowerCase().includes(appliedName.toLowerCase());
const matchId =
!!appliedId &&
record.patientId.toLowerCase() === appliedId.toLowerCase();
return inLast30Days && matchName && matchId;
});
if (matches.length === 0) return [] as MedicalRecord[];
const latest = matches.reduce((prev, curr) =>
new Date(curr.recordDate) > new Date(prev.recordDate) ? curr : prev
);
return [latest];
})();
// status removed
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<div className="p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-green-100 p-3 rounded-lg">
<FileText className="h-6 w-6 text-green-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900">
Medical Record
</h1>
<p className="text-gray-600">
Kelola rekam medis pasien dan riwayat diagnosa
</p>
</div>
</div>
<div />
</div>
</div>
{/* Filters: Search by Patient Name and Patient ID with trigger button */}
<div className="bg-white p-6 rounded-lg shadow-sm border mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cari Nama Pasien
</label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Contoh: Ahmad Rizki"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-transparent w-full"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cari ID Pasien
</label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Contoh: P001"
value={idInput}
onChange={(e) => setIdInput(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-transparent w-full"
/>
</div>
</div>
<div className="flex items-end">
<button
onClick={handleSearch}
disabled={!nameInput || !idInput}
className="btn-primary flex items-center space-x-2 disabled:opacity-60"
>
<Search className="h-4 w-4" />
<span>Cari</span>
</button>
<button
onClick={handleReset}
className="ml-3 btn-secondary px-3 py-2 rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50"
>
Reset
</button>
</div>
</div>
<div className="text-xs text-gray-500 mt-3">
Menampilkan riwayat medical record 30 hari terakhir sesuai kriteria.
</div>
</div>
{/* Results as Card Sections */}
{hasSearched && (
<div className="space-y-6">
{filteredRecords.length === 0 ? (
<div className="text-center py-12">
<FileText className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada medical record ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Coba gunakan nama/ID pasien yang berbeda.
</p>
</div>
) : (
filteredRecords.map((rec) => {
const details = (() => {
switch (rec.icdCode) {
case "I10":
return {
chiefComplaint: "Pusing dan tekanan darah tinggi",
hpi: "Keluhan sejak 3 hari, pusing berdenyut terutama pagi hari. Tidak ada nyeri dada/sesak.",
pmh: ["Hipertensi 2 tahun", "Dislipidemia"],
meds: ["Amlodipine 10mg 1x1", "Atorvastatin 20mg 1x1"],
allergies: ["Tidak ada"],
labs: [
"Profil lipid: LDL 140 mg/dl",
"Fungsi ginjal normal",
],
imaging: ["Foto toraks dalam batas normal"],
procedures: [
"Edukasi diet rendah garam",
"Monitoring TD rumah",
],
plan: [
"Lanjut obat, evaluasi 2 minggu",
"Olahraga 30 menit/hari",
"Kontrol tekanan darah harian",
],
discharge:
"Pulang kondisi stabil, edukasi dan rencana kontrol diberikan.",
} as const;
case "K29.0":
return {
chiefComplaint: "Nyeri ulu hati dan mual",
hpi: "Nyeri ulu hati terutama setelah makan pedas/asam, mual tanpa muntah.",
pmh: ["Gastritis episodik"],
meds: ["Omeprazole 20mg 2x1", "Antasida 3x1"],
allergies: ["Tidak ada"],
labs: ["Hb normal", "Helicobacter pylori: negatif"],
imaging: ["Tidak dilakukan"],
procedures: ["Edukasi diet lambung", "Hindari NSAID"],
plan: [
"PPI 2 minggu",
"Diet lunak, porsi kecil",
"Kontrol bila nyeri menetap",
],
discharge: "Pulang, kontrol bila keluhan berlanjut.",
} as const;
case "E11.9":
return {
chiefComplaint: "Sering haus dan berkemih",
hpi: "Keluhan polidipsia dan poliuria 1 minggu, gula darah rumah meningkat.",
pmh: ["DM tipe 2 3 tahun"],
meds: ["Metformin 500mg 2x1"],
allergies: ["Tidak ada"],
labs: ["GDP 180 mg/dl", "HbA1c 8.1%"],
imaging: ["Tidak dilakukan"],
procedures: ["Edukasi diet DM", "Jurnal gula harian"],
plan: [
"Optimasi metformin",
"Rencana edukasi diet dan aktivitas",
"Kontrol 2-4 minggu",
],
discharge:
"Pulang, monitoring gula dan kontrol terjadwal.",
} as const;
default:
return {
chiefComplaint: "Keluhan sesuai diagnosa utama",
hpi: "Riwayat penyakit sekarang sesuai catatan klinis.",
pmh: ["Tidak ada yang menonjol"],
meds: [rec.treatment],
allergies: ["Tidak diketahui"],
labs: ["Dalam batas normal"],
imaging: ["Tidak ada temuan bermakna"],
procedures: ["Observasi dan edukasi"],
plan: ["Kontrol sesuai kebutuhan"],
discharge: "Pulang dalam kondisi stabil.",
} as const;
}
})();
return (
<div key={rec.id} className="card">
<div className="p-4 md:p-6 text-sm text-gray-800">
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Identitas Pasien
</div>
<div className="p-3 space-y-1 text-sm">
<div className="text-gray-900 font-medium">
{rec.patientName}
</div>
<div className="text-gray-500">
{rec.patientGender}, {rec.patientAge} th
</div>
<div className="text-gray-500">
ID Pasien: {rec.patientId}
</div>
</div>
</div>
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Kunjungan
</div>
<div className="p-3 space-y-1 text-sm">
<div className="text-gray-900 font-medium">
{formatDate(rec.recordDate)}
</div>
<div className="text-gray-500">{rec.doctor}</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Keluhan Utama
</div>
<div className="p-3 text-sm text-gray-900">
{details.chiefComplaint}
</div>
</div>
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Riwayat Penyakit Sekarang
</div>
<div className="p-3 text-sm text-gray-900 leading-relaxed">
{details.hpi}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Riwayat, Obat & Alergi
</div>
<div className="p-3">
<div className="border-b border-gray-200 mb-3">
<nav className="-mb-px flex space-x-4">
{(
[
"riwayat",
"obat",
"alergi",
] as SectionTab[]
).map((tab) => {
const active =
getActiveSection(rec.id) === tab;
return (
<button
key={tab}
onClick={() =>
setActiveSection(rec.id, tab)
}
className={`whitespace-nowrap py-1.5 px-1 border-b-2 text-sm font-medium ${
active
? "border-green-600 text-green-700"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
{tab === "riwayat" && "Riwayat"}
{tab === "obat" && "Obat"}
{tab === "alergi" && "Alergi"}
</button>
);
})}
</nav>
</div>
<div>
{getActiveSection(rec.id) === "riwayat" && (
<div className="space-y-2">
<div className="text-xs uppercase tracking-wide text-gray-500">
Riwayat Penyakit Dahulu
</div>
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
{details.pmh.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
)}
{getActiveSection(rec.id) === "obat" && (
<div className="space-y-2">
<div className="text-xs uppercase tracking-wide text-gray-500">
Daftar Obat Saat Ini
</div>
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
{details.meds.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
)}
{getActiveSection(rec.id) === "alergi" && (
<div className="space-y-2">
<div className="text-xs uppercase tracking-wide text-gray-500">
Alergi
</div>
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
{details.allergies.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Laboratorium
</div>
<div className="p-3">
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
{details.labs.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Imaging
</div>
<div className="p-3">
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
{details.imaging.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
</div>
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Ringkasan Pulang
</div>
<div className="p-3 text-sm text-gray-700">
{details.discharge}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Diagnosa & ICD
</div>
<div className="p-3 text-sm">
<div className="text-gray-900">
{rec.diagnosis}
</div>
<div className="text-xs font-mono inline-block mt-1 bg-gray-100 px-2 py-1 rounded">
{rec.icdCode}
</div>
</div>
</div>
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Prosedur/Tindakan
</div>
<div className="p-3">
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
{details.procedures.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Tanda Vital
</div>
<div className="p-3">
<ul className="mt-1 list-disc pl-5 space-y-1 text-sm text-gray-800">
<li>TD: {rec.vital.bloodPressure} mmHg</li>
<li>Nadi: {rec.vital.heartRate} bpm</li>
<li>Suhu: {rec.vital.temperature}°C</li>
<li>Berat: {rec.vital.weight} kg</li>
</ul>
</div>
</div>
<div className="rounded-md border border-gray-200">
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Rencana
</div>
<div className="p-3">
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
{details.plan.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
);
})
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,775 +0,0 @@
import { useState, useMemo } from "react";
import {
Database,
RefreshCw,
Calendar,
Filter,
CheckCircle,
XCircle,
Clock,
AlertCircle,
Building2,
Upload,
} from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
interface SyncLog {
id: string;
timestamp: string;
type: "import" | "sync";
status: "success" | "failed" | "in_progress";
recordsProcessed: number;
recordsSuccess: number;
recordsFailed: number;
source: string;
duration: number; // in seconds
errorMessage?: string;
details: {
patientsUpdated: number;
diagnosesAdded: number;
treatmentsAdded: number;
vitalsUpdated: number;
};
}
interface SyncStats {
totalSyncs: number;
successfulSyncs: number;
failedSyncs: number;
lastSyncTime: string;
totalRecordsProcessed: number;
averageDuration: number;
}
export default function MedicalRecordSync() {
// Helper function to get default dates (1 month back)
const getDefaultDates = () => {
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
return { startDate, endDate };
};
const [statusInput, setStatusInput] = useState("all");
const [appliedStatus, setAppliedStatus] = useState("all");
const [isImporting, setIsImporting] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const { startDate: defaultStartDate, endDate: defaultEndDate } =
getDefaultDates();
const [startDateInput, setStartDateInput] = useState<Date | undefined>(
defaultStartDate
);
const [endDateInput, setEndDateInput] = useState<Date | undefined>(
defaultEndDate
);
const [appliedStartDate, setAppliedStartDate] = useState<Date | undefined>(
defaultStartDate
);
const [appliedEndDate, setAppliedEndDate] = useState<Date | undefined>(
defaultEndDate
);
const [hasDateFiltered, setHasDateFiltered] = useState(true); // Start with default filter applied
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getStatusColor = (status: string) => {
switch (status) {
case "success":
return "bg-green-100 text-green-800";
case "failed":
return "bg-red-100 text-red-800";
case "in_progress":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "success":
return <CheckCircle className="h-4 w-4" />;
case "failed":
return <XCircle className="h-4 w-4" />;
case "in_progress":
return <Clock className="h-4 w-4" />;
default:
return <AlertCircle className="h-4 w-4" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "success":
return "Berhasil";
case "failed":
return "Gagal";
case "in_progress":
return "Berlangsung";
default:
return status;
}
};
// Sample sync logs data with additional entries for pagination
const [syncLogs] = useState<SyncLog[]>(() => {
const baseLogs: SyncLog[] = [
{
id: "1",
timestamp: "2024-01-15T14:30:00Z",
type: "sync",
status: "success",
recordsProcessed: 1250,
recordsSuccess: 1245,
recordsFailed: 5,
source: "Hospital Management System API",
duration: 45,
details: {
patientsUpdated: 89,
diagnosesAdded: 156,
treatmentsAdded: 203,
vitalsUpdated: 797,
},
},
{
id: "2",
timestamp: "2024-01-15T10:15:00Z",
type: "import",
status: "success",
recordsProcessed: 567,
recordsSuccess: 567,
recordsFailed: 0,
source: "External Lab System",
duration: 23,
details: {
patientsUpdated: 0,
diagnosesAdded: 234,
treatmentsAdded: 0,
vitalsUpdated: 333,
},
},
{
id: "3",
timestamp: "2024-01-14T16:45:00Z",
type: "sync",
status: "failed",
recordsProcessed: 0,
recordsSuccess: 0,
recordsFailed: 0,
source: "Radiology System API",
duration: 0,
errorMessage: "Connection timeout - API endpoint tidak merespons",
details: {
patientsUpdated: 0,
diagnosesAdded: 0,
treatmentsAdded: 0,
vitalsUpdated: 0,
},
},
{
id: "4",
timestamp: "2024-01-14T09:30:00Z",
type: "import",
status: "success",
recordsProcessed: 834,
recordsSuccess: 820,
recordsFailed: 14,
source: "Pharmacy System",
duration: 38,
details: {
patientsUpdated: 45,
diagnosesAdded: 67,
treatmentsAdded: 567,
vitalsUpdated: 155,
},
},
{
id: "5",
timestamp: "2024-01-13T13:20:00Z",
type: "sync",
status: "in_progress",
recordsProcessed: 423,
recordsSuccess: 423,
recordsFailed: 0,
source: "Emergency System API",
duration: 0,
details: {
patientsUpdated: 12,
diagnosesAdded: 89,
treatmentsAdded: 134,
vitalsUpdated: 188,
},
},
];
// Generate additional logs for pagination
const generated: SyncLog[] = Array.from({ length: 20 }).map((_, idx) => {
const n = idx + 6;
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 30));
date.setHours(
Math.floor(Math.random() * 24),
Math.floor(Math.random() * 60)
);
return {
id: String(n),
timestamp: date.toISOString(),
type: Math.random() > 0.5 ? "sync" : "import",
status:
Math.random() > 0.8
? "failed"
: Math.random() > 0.1
? "success"
: "in_progress",
recordsProcessed: Math.floor(Math.random() * 800) + 100,
recordsSuccess: Math.floor(Math.random() * 750) + 90,
recordsFailed: Math.floor(Math.random() * 30),
source: [
"Hospital Management System API",
"External Lab System",
"Radiology System API",
"Pharmacy System",
"Emergency System API",
][Math.floor(Math.random() * 5)],
duration: Math.floor(Math.random() * 80) + 15,
details: {
patientsUpdated: Math.floor(Math.random() * 100),
diagnosesAdded: Math.floor(Math.random() * 200),
treatmentsAdded: Math.floor(Math.random() * 300),
vitalsUpdated: Math.floor(Math.random() * 500),
},
...(Math.random() > 0.9 && { errorMessage: "API connection failed" }),
} as SyncLog;
});
return [...baseLogs, ...generated];
});
// Calculate statistics
const stats: SyncStats = {
totalSyncs: syncLogs.length,
successfulSyncs: syncLogs.filter((log) => log.status === "success").length,
failedSyncs: syncLogs.filter((log) => log.status === "failed").length,
lastSyncTime: syncLogs[0]?.timestamp || "",
totalRecordsProcessed: syncLogs.reduce(
(sum, log) => sum + log.recordsProcessed,
0
),
averageDuration:
syncLogs
.filter((log) => log.duration > 0)
.reduce((sum, log) => sum + log.duration, 0) /
syncLogs.filter((log) => log.duration > 0).length || 0,
};
// Filter logs based on date and status (status applied via button)
const filteredLogs = useMemo(() => {
return syncLogs.filter((log) => {
const matchesStatus =
appliedStatus === "all" || log.status === appliedStatus;
let matchesDate = true;
if (hasDateFiltered && (appliedStartDate || appliedEndDate)) {
const logDate = new Date(log.timestamp);
const startOk = !appliedStartDate || logDate >= appliedStartDate;
const endOk = !appliedEndDate || logDate <= appliedEndDate;
matchesDate = startOk && endOk;
}
return matchesStatus && matchesDate;
});
}, [
syncLogs,
appliedStatus,
hasDateFiltered,
appliedStartDate,
appliedEndDate,
]);
const columnHelper = createColumnHelper<SyncLog>();
const columns: ColumnDef<SyncLog, unknown>[] = useMemo(
() => [
columnHelper.display({
id: "timeAndType",
header: "Waktu & Type",
cell: (info) => {
const log = info.row.original;
return (
<div>
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatDate(log.timestamp)}
</div>
<div className="text-sm font-medium text-gray-900 mt-1">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.type === "import"
? "bg-blue-100 text-blue-800"
: "bg-purple-100 text-purple-800"
}`}
>
{log.type === "import" ? "Import" : "Sync"}
</span>
</div>
</div>
);
},
}),
columnHelper.display({
id: "source",
header: "Source System",
cell: (info) => {
const log = info.row.original;
return (
<div className="flex items-center">
<Building2 className="h-4 w-4 text-gray-400 mr-2" />
<div>
<div className="text-sm font-medium text-gray-900">
{log.source}
</div>
{log.errorMessage && (
<div className="text-sm text-red-600 mt-1">
{log.errorMessage}
</div>
)}
</div>
</div>
);
},
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (info) => {
const log = info.row.original;
return (
<div className="flex items-center">
<span
className={`inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(
log.status
)}`}
>
{getStatusIcon(log.status)}
<span className="ml-1">{getStatusText(log.status)}</span>
</span>
</div>
);
},
}),
columnHelper.display({
id: "recordsProcessed",
header: "Records Processed",
cell: (info) => {
const log = info.row.original;
return (
<div>
<div className="text-sm text-gray-900">
{log.recordsProcessed.toLocaleString()}
</div>
<div className="text-sm text-gray-500">
{log.recordsSuccess > 0 && (
<span className="text-green-600"> {log.recordsSuccess}</span>
)}
{log.recordsFailed > 0 && (
<span className="text-red-600 ml-2">
{log.recordsFailed}
</span>
)}
</div>
</div>
);
},
}),
columnHelper.display({
id: "successRate",
header: "Success Rate",
cell: (info) => {
const log = info.row.original;
const successRate =
log.recordsProcessed > 0
? Math.round((log.recordsSuccess / log.recordsProcessed) * 100)
: 0;
const displayRate = Math.min(100, successRate); // Batasi maksimal 100%
return (
<div>
<div className="text-sm text-gray-900">{successRate}%</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{ width: `${displayRate}%` }}
></div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "duration",
header: "Duration",
cell: (info) => {
const log = info.row.original;
return (
<div className="text-sm text-gray-900">
{log.duration > 0 ? `${log.duration}s` : "-"}
</div>
);
},
}),
],
[columnHelper]
);
const table = useReactTable({
data: filteredLogs,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
enableRowSelection: false,
debugTable: false,
});
const handleImport = async () => {
setIsImporting(true);
// Simulate import process
setTimeout(() => {
setIsImporting(false);
// Add new log entry here
}, 3000);
};
const handleSync = async () => {
setIsSyncing(true);
// Simulate sync process
setTimeout(() => {
setIsSyncing(false);
// Add new log entry here
}, 5000);
};
return (
<div className="p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
<Database className="h-8 w-8 text-blue-600 mr-3" />
Medical Record Sync
</h1>
<p className="text-gray-600 mt-1">
Sinkronisasi dan import data medical record dari sistem
eksternal
</p>
</div>
<div className="flex space-x-3">
<button
onClick={handleImport}
disabled={isImporting || isSyncing}
className="btn-secondary flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
<span>{isImporting ? "Importing..." : "Import Data"}</span>
</button>
<button
onClick={handleSync}
disabled={isImporting || isSyncing}
className="btn-primary flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSyncing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span>{isSyncing ? "Syncing..." : "Sync by API"}</span>
</button>
</div>
</div>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Sync</p>
<p className="text-2xl font-bold text-blue-600">
{stats.totalSyncs}
</p>
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<RefreshCw className="h-6 w-6 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Berhasil</p>
<p className="text-2xl font-bold text-green-600">
{stats.successfulSyncs}
</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="h-6 w-6 text-green-600" />
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Gagal</p>
<p className="text-2xl font-bold text-red-600">
{stats.failedSyncs}
</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<XCircle className="h-6 w-6 text-red-600" />
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Avg Duration
</p>
<p className="text-2xl font-bold text-orange-600">
{Math.round(stats.averageDuration)}s
</p>
</div>
<div className="p-3 bg-orange-100 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" />
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white p-6 rounded-lg shadow-sm border mb-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-3">
{/* Date range */}
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-400" />
<DatePicker
selected={startDateInput}
onChange={(date) => setStartDateInput(date || undefined)}
selectsStart
startDate={startDateInput}
endDate={endDateInput}
placeholderText="Start Date"
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-36"
dateFormat="dd MMM yyyy"
/>
<span className="text-gray-400 text-sm">s/d</span>
<DatePicker
selected={endDateInput}
onChange={(date) => setEndDateInput(date || undefined)}
selectsEnd
startDate={startDateInput}
endDate={endDateInput}
minDate={startDateInput}
placeholderText="End Date"
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-36"
dateFormat="dd MMM yyyy"
/>
</div>
{/* Status */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">Semua Status</option>
<option value="success">Berhasil</option>
<option value="failed">Gagal</option>
<option value="in_progress">Berlangsung</option>
</select>
</div>
{/* Buttons */}
<div className="flex items-center space-x-2">
<button
onClick={() => {
setAppliedStartDate(startDateInput);
setAppliedEndDate(endDateInput);
setHasDateFiltered(Boolean(startDateInput || endDateInput));
setAppliedStatus(statusInput);
}}
className="btn-primary px-3 py-2"
>
Filter
</button>
<button
onClick={() => {
const { startDate, endDate } = getDefaultDates();
setStartDateInput(startDate);
setEndDateInput(endDate);
setAppliedStartDate(startDate);
setAppliedEndDate(endDate);
setHasDateFiltered(true);
setStatusInput("all");
setAppliedStatus("all");
}}
className="btn-secondary px-3 py-2 border border-gray-300 rounded-md"
>
Reset
</button>
</div>
</div>
</div>
</div>
{/* Sync Logs Table */}
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Log Sinkronisasi Medical Record
</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 flex items-center space-x-3">
<span>Page</span>
<input
type="number"
min={1}
max={Math.max(1, table.getPageCount())}
value={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = Number(e.target.value) - 1;
if (!Number.isNaN(page)) table.setPageIndex(page);
}}
className="w-16 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span>of {table.getPageCount() || 1}</span>
</div>
<div className="flex items-center space-x-3">
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
« First
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Prev
</button>
<select
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 30, 50].map((ps) => (
<option key={ps} value={ps}>
{ps}/page
</option>
))}
</select>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Last »
</button>
</div>
</div>
</div>
</div>
{/* Empty State */}
{filteredLogs.length === 0 && (
<div className="text-center py-12">
<AlertCircle className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada log sinkronisasi ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Tidak ada log yang sesuai dengan kriteria pencarian.
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,581 +0,0 @@
import { useState, useMemo, useCallback } from "react";
import {
Shield,
Plus,
Edit,
Trash2,
Search,
Filter,
Users,
XCircle,
Calendar,
} from "lucide-react";
import { sampleRoles, MODULES, ACTIONS } from "../types/roles";
import type { IRole } from "../types/roles";
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
// Helper function to get proper module display names
const getModuleDisplayName = (module: string) => {
const moduleNames: Record<string, string> = {
[MODULES.DASHBOARD]: "Dashboard",
[MODULES.COST_RECOMMENDATION]: "Cost Recommendation",
[MODULES.INTEGRASI_DATA_BPJS]: "Integrasi Data - BPJS",
[MODULES.INTEGRASI_DATA_MEDICAL_RECORD]: "Integrasi Data - Medical Record",
[MODULES.PASIEN_MANAJEMEN]: "Pasien - Manajemen Pasien",
[MODULES.PASIEN_MEDICAL_RECORD]: "Pasien - Medical Record Pasien",
[MODULES.PASIEN_BPJS_CODE]: "Pasien - BPJS Code",
[MODULES.USER_MANAGEMENT]: "System Administration - Manajemen User",
[MODULES.ROLE_MANAGEMENT]: "System Administration - Manajemen Role",
};
return moduleNames[module] || module.replace(/_/g, " ");
};
export default function RoleManagement() {
// Seed more roles once so pagination appears
const [roles, setRoles] = useState<IRole[]>(() => {
const generated: IRole[] = Array.from({ length: 25 }).map((_, idx) => {
const base = sampleRoles[idx % sampleRoles.length];
const n = idx + 6; // continue after existing 5 sample roles
return {
id: String(n),
name: `Role ${n}`,
description: `Deskripsi untuk role ${n} - ${base.description.slice(
0,
30
)}...`,
permissions: base.permissions.slice(
0,
Math.floor(Math.random() * 8) + 2
), // Random 2-10 permissions
isActive: n % 3 !== 0, // Mix of active/inactive
createdAt: new Date(
Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000
).toISOString(),
updatedAt: new Date(
Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000
).toISOString(),
} as IRole;
});
return [...sampleRoles, ...generated];
});
const [searchInput, setSearchInput] = useState("");
const [selectedRole, setSelectedRole] = useState<IRole | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [statusInput, setStatusInput] = useState("all");
const [permissionFilter] = useState("all");
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedStatus, setAppliedStatus] = useState("all");
const [hasFiltered, setHasFiltered] = useState(false);
const filteredRoles = useMemo(() => {
return roles.filter((role) => {
const matchesSearch = !hasFiltered
? true
: !appliedSearch ||
role.name.toLowerCase().includes(appliedSearch.toLowerCase()) ||
role.description.toLowerCase().includes(appliedSearch.toLowerCase());
const statusToUse = hasFiltered ? appliedStatus : "all";
const matchesStatus =
statusToUse === "all" ||
(statusToUse === "active" && role.isActive) ||
(statusToUse === "inactive" && !role.isActive);
const matchesPermission =
permissionFilter === "all" ||
(permissionFilter === "high" && role.permissions.length >= 8) ||
(permissionFilter === "medium" &&
role.permissions.length >= 4 &&
role.permissions.length <= 7) ||
(permissionFilter === "low" && role.permissions.length <= 3);
return matchesSearch && matchesStatus && matchesPermission;
});
}, [roles, hasFiltered, appliedSearch, appliedStatus, permissionFilter]);
const handleEditRole = useCallback((role: IRole) => {
setSelectedRole(role);
setModalMode("edit");
setIsModalOpen(true);
}, []);
const handleDeleteRole = useCallback((roleId: string) => {
if (confirm("Apakah Anda yakin ingin menghapus role ini?")) {
setRoles((prev) => prev.filter((role) => role.id !== roleId));
}
}, []);
const handleToggleStatus = useCallback((roleId: string) => {
setRoles((prev) =>
prev.map((role) =>
role.id === roleId ? { ...role, isActive: !role.isActive } : role
)
);
}, []);
const columnHelper = createColumnHelper<IRole>();
const columns: ColumnDef<IRole, unknown>[] = useMemo(
() => [
columnHelper.display({
id: "role",
header: "Role",
cell: (info) => {
const role = info.row.original;
return (
<div className="flex items-center">
<div className="bg-purple-100 p-2 rounded-lg mr-3">
<Shield className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm font-medium text-gray-900">
{role.name}
</div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "description",
header: "Deskripsi",
cell: (info) => (
<div className="text-sm text-gray-900 max-w-xs truncate">
{info.row.original.description}
</div>
),
}),
columnHelper.display({
id: "permissions",
header: "Permissions",
cell: (info) => {
const role = info.row.original;
return (
<div className="flex items-center">
<Users className="h-4 w-4 text-gray-400 mr-1" />
<span className="text-sm font-medium text-gray-900">
{role.permissions.length}
</span>
<span className="text-sm text-gray-500 ml-1">permissions</span>
</div>
);
},
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (info) => {
const role = info.row.original;
return (
<button
onClick={() => handleToggleStatus(role.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
role.isActive
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{role.isActive ? "Active" : "Inactive"}
</button>
);
},
}),
columnHelper.display({
id: "createdAt",
header: "Tanggal Dibuat",
cell: (info) => (
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatDate(info.row.original.createdAt)}
</div>
),
}),
columnHelper.display({
id: "actions",
header: "Aksi",
cell: (info) => {
const role = info.row.original;
return (
<div className="flex items-center space-x-2">
<button
onClick={() => handleEditRole(role)}
className="text-blue-600 hover:text-blue-900"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteRole(role.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
},
}),
],
[columnHelper, handleToggleStatus, handleEditRole, handleDeleteRole]
);
const table = useReactTable({
data: filteredRoles,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
enableRowSelection: false,
debugTable: false,
});
const handleCreateRole = () => {
setSelectedRole(null);
setModalMode("create");
setIsModalOpen(true);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<div className="p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-purple-100 p-3 rounded-lg">
<Shield className="h-6 w-6 text-purple-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900">
Manajemen Role
</h1>
<p className="text-gray-600">
Kelola role dan permission untuk sistem rumah sakit
</p>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={handleCreateRole}
className="btn-primary flex items-center space-x-2"
>
<Plus className="h-4 w-4" />
<span>Tambah Role</span>
</button>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white p-6 rounded-lg shadow-sm border mb-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Cari nama role atau deskripsi..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500 focus:border-transparent w-80"
/>
</div>
{/* Status */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="all">Semua Status</option>
<option value="active">Aktif</option>
<option value="inactive">Tidak Aktif</option>
</select>
</div>
{/* Buttons */}
<div className="flex items-center space-x-2">
<button
onClick={() => {
setAppliedSearch(searchInput.trim());
setAppliedStatus(statusInput);
setHasFiltered(true);
}}
className="btn-primary px-3 py-2"
>
Filter
</button>
<button
onClick={() => {
setSearchInput("");
setStatusInput("all");
setAppliedSearch("");
setAppliedStatus("all");
setHasFiltered(false);
}}
className="btn-secondary px-3 py-2 border border-gray-300 rounded-md"
>
Reset
</button>
</div>
</div>
</div>
</div>
{/* Roles Table */}
<div className="card">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={`px-6 py-4 whitespace-nowrap ${
cell.column.id === "actions"
? "text-sm font-medium"
: ""
}`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 flex items-center space-x-3">
<span>Page</span>
<input
type="number"
min={1}
max={Math.max(1, table.getPageCount())}
value={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = Number(e.target.value) - 1;
if (!Number.isNaN(page)) table.setPageIndex(page);
}}
className="w-16 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span>of {table.getPageCount() || 1}</span>
</div>
<div className="flex items-center space-x-3">
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
« First
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Prev
</button>
<select
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 30, 50].map((ps) => (
<option key={ps} value={ps}>
{ps}/page
</option>
))}
</select>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Last »
</button>
</div>
</div>
</div>
</div>
{/* Empty State */}
{filteredRoles.length === 0 && (
<div className="text-center py-12">
<Shield className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada role ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Mulai dengan membuat role baru untuk sistem rumah sakit.
</p>
<div className="mt-6">
<button
onClick={handleCreateRole}
className="btn-primary flex items-center space-x-2 mx-auto"
>
<Plus className="h-4 w-4" />
<span>Tambah Role</span>
</button>
</div>
</div>
)}
{/* Role Modal - Create/Edit */}
{isModalOpen && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">
{modalMode === "create" ? "Tambah Role Baru" : "Edit Role"}
</h3>
<button
onClick={() => setIsModalOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nama Role
</label>
<input
type="text"
className="input w-full"
placeholder="Masukkan nama role"
defaultValue={selectedRole?.name || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Deskripsi
</label>
<textarea
className="input w-full h-20"
placeholder="Deskripsi role"
defaultValue={selectedRole?.description || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-4">
Permissions (CRUD Operations)
</label>
<div className="bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{Object.values(MODULES).map((module) => (
<div
key={module}
className="bg-white border rounded-lg p-4 shadow-sm"
>
<div className="flex items-center mb-3">
<div className="bg-blue-100 p-2 rounded-lg mr-3">
<Shield className="h-4 w-4 text-blue-600" />
</div>
<h4 className="font-semibold text-gray-900 text-sm">
{getModuleDisplayName(module)}
</h4>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.values(ACTIONS).map((action) => (
<label
key={`${module}-${action}`}
className="flex items-center p-2 rounded-md hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
defaultChecked={
selectedRole?.permissions.some(
(p) =>
p.module === module &&
p.action === action
) || false
}
/>
<span className="ml-2 text-sm text-gray-700 capitalize font-medium">
{action}
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-3 mt-6 pt-4 border-t">
<button
onClick={() => setIsModalOpen(false)}
className="btn-secondary"
>
Batal
</button>
<button className="btn-primary">
{modalMode === "create" ? "Simpan Role" : "Update Role"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,577 +0,0 @@
import { useState, useMemo, useCallback } from "react";
import {
Users,
Plus,
Edit,
Trash2,
Search,
Filter,
Mail,
Phone,
XCircle,
Shield,
Calendar,
} from "lucide-react";
import { sampleUsers, sampleRoles } from "../types/roles";
import type { IUser } from "../types/roles";
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
export default function UserManagement() {
// Seed more users once so pagination appears
const [users, setUsers] = useState<IUser[]>(() => {
const generated: IUser[] = Array.from({ length: 30 }).map((_, idx) => {
const base = sampleUsers[idx % sampleUsers.length];
const role = sampleRoles[(idx + 1) % sampleRoles.length];
const n = idx + 6; // continue after existing 5 sample users
return {
id: String(n),
name: `User ${n}`,
email: `user${n}@claimguard.com`,
phone: `+62 81${(200000000 + n).toString()}`,
role,
department: role.name,
isActive: n % 2 === 0,
lastLogin: base.lastLogin,
createdAt: base.createdAt,
updatedAt: base.updatedAt,
} as IUser;
});
return [...sampleUsers, ...generated];
});
const [searchInput, setSearchInput] = useState("");
const [selectedUser, setSelectedUser] = useState<IUser | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [statusInput, setStatusInput] = useState("");
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedStatus, setAppliedStatus] = useState("");
const [hasFiltered, setHasFiltered] = useState(false);
const filteredUsers = useMemo(() => {
return users.filter((user) => {
const matchesSearch = !hasFiltered
? true
: !appliedSearch ||
user.name.toLowerCase().includes(appliedSearch.toLowerCase()) ||
user.email.toLowerCase().includes(appliedSearch.toLowerCase()) ||
user.role.name.toLowerCase().includes(appliedSearch.toLowerCase());
const statusToUse = hasFiltered ? appliedStatus : "";
const matchesStatus =
!statusToUse ||
(statusToUse === "active" && user.isActive) ||
(statusToUse === "inactive" && !user.isActive);
return matchesSearch && matchesStatus;
});
}, [users, hasFiltered, appliedSearch, appliedStatus]);
const handleEditUser = useCallback((user: IUser) => {
setSelectedUser(user);
setModalMode("edit");
setIsModalOpen(true);
}, []);
const handleDeleteUser = useCallback((userId: string) => {
if (confirm("Apakah Anda yakin ingin menghapus user ini?")) {
setUsers((prev) => prev.filter((user) => user.id !== userId));
}
}, []);
const handleToggleStatus = useCallback((userId: string) => {
setUsers((prev) =>
prev.map((user) =>
user.id === userId ? { ...user, isActive: !user.isActive } : user
)
);
}, []);
const columnHelper = createColumnHelper<IUser>();
const columns: ColumnDef<IUser, unknown>[] = useMemo(
() => [
columnHelper.display({
id: "user",
header: "User",
cell: (info) => {
const u = info.row.original;
return (
<div className="flex items-center">
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{u.name}
</div>
<div className="text-sm text-gray-500 flex items-center">
<Mail className="h-3 w-3 mr-1" />
{u.email}
</div>
<div className="text-sm text-gray-500 flex items-center">
<Phone className="h-3 w-3 mr-1" />
{u.phone}
</div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "role",
header: "Role",
cell: (info) => {
const u = info.row.original;
return (
<div className="flex items-center">
<Shield className="h-4 w-4 text-purple-500 mr-2" />
<div>
<div className="text-sm font-medium text-gray-900">
{u.role.name}
</div>
<div className="text-xs text-gray-500">
{u.role.permissions.length} permissions
</div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (info) => {
const u = info.row.original;
return (
<button
onClick={() => handleToggleStatus(u.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
u.isActive
)}`}
>
{u.isActive ? "Active" : "Inactive"}
</button>
);
},
}),
columnHelper.display({
id: "lastLogin",
header: "Login Terakhir",
cell: (info) => (
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatLastLogin(info.row.original.lastLogin)}
</div>
),
}),
columnHelper.display({
id: "actions",
header: "Aksi",
cell: (info) => {
const u = info.row.original;
return (
<div className="flex items-center space-x-2">
<button
onClick={() => handleEditUser(u)}
className="text-blue-600 hover:text-blue-900"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteUser(u.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
},
}),
],
[columnHelper, handleToggleStatus, handleEditUser, handleDeleteUser]
);
const table = useReactTable({
data: filteredUsers,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
enableRowSelection: false,
debugTable: false,
});
const handleCreateUser = () => {
setSelectedUser(null);
setModalMode("create");
setIsModalOpen(true);
};
const getStatusColor = (isActive: boolean) => {
return isActive ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800";
};
const formatLastLogin = (lastLogin?: string) => {
if (!lastLogin) return "Belum pernah login";
const date = new Date(lastLogin);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<div className="p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-blue-100 p-3 rounded-lg">
<Users className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900">
Manajemen User
</h1>
<p className="text-gray-600">
Kelola user dan akses sistem rumah sakit
</p>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={handleCreateUser}
className="btn-primary flex items-center space-x-2"
>
<Plus className="h-4 w-4" />
<span>Tambah User</span>
</button>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white p-6 rounded-lg shadow-sm border mb-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Cari nama, email, atau role..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent w-80"
/>
</div>
{/* Status */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Semua Status</option>
<option value="active">Aktif</option>
<option value="inactive">Tidak Aktif</option>
</select>
</div>
{/* Buttons */}
<div className="flex items-center space-x-2">
<button
onClick={() => {
setAppliedSearch(searchInput.trim());
setAppliedStatus(statusInput);
setHasFiltered(true);
}}
className="btn-primary px-3 py-2"
>
Filter
</button>
<button
onClick={() => {
setSearchInput("");
setStatusInput("");
setAppliedSearch("");
setAppliedStatus("");
setHasFiltered(false);
}}
className="btn-secondary px-3 py-2 border border-gray-300 rounded-md"
>
Reset
</button>
</div>
</div>
</div>
</div>
{/* Users Table */}
<div className="card">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={`px-6 py-4 whitespace-nowrap ${
cell.column.id === "actions"
? "text-sm font-medium"
: ""
}`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 flex items-center space-x-3">
<span>Page</span>
<input
type="number"
min={1}
max={Math.max(1, table.getPageCount())}
value={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = Number(e.target.value) - 1;
if (!Number.isNaN(page)) table.setPageIndex(page);
}}
className="w-16 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span>of {table.getPageCount() || 1}</span>
</div>
<div className="flex items-center space-x-3">
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
« First
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Prev
</button>
<select
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 30, 50].map((ps) => (
<option key={ps} value={ps}>
{ps}/page
</option>
))}
</select>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Last »
</button>
</div>
</div>
</div>
</div>
{/* Empty State */}
{filteredUsers.length === 0 && (
<div className="text-center py-12">
<Users className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada user ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Mulai dengan menambahkan user baru ke sistem.
</p>
<div className="mt-6">
<button
onClick={handleCreateUser}
className="btn-primary flex items-center space-x-2 mx-auto"
>
<Plus className="h-4 w-4" />
<span>Tambah User</span>
</button>
</div>
</div>
)}
{/* User Modal - Create/Edit */}
{isModalOpen && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">
{modalMode === "create" ? "Tambah User Baru" : "Edit User"}
</h3>
<button
onClick={() => setIsModalOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="h-6 w-6" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nama Lengkap
</label>
<input
type="text"
className="input w-full"
placeholder="Masukkan nama lengkap"
defaultValue={selectedUser?.name || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
className="input w-full"
placeholder="user@claimguard.com"
defaultValue={selectedUser?.email || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nomor Telepon
</label>
<input
type="tel"
className="input w-full"
placeholder="+62 812-3456-7890"
defaultValue={selectedUser?.phone || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<select
className="input w-full"
defaultValue={selectedUser?.role.id || ""}
>
<option value="">Pilih Role</option>
{sampleRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
className="input w-full"
defaultValue={selectedUser?.isActive ? "true" : "false"}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
{modalMode === "create" && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
className="input w-full"
placeholder="Masukkan password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Konfirmasi Password
</label>
<input
type="password"
className="input w-full"
placeholder="Konfirmasi password"
/>
</div>
</>
)}
</div>
<div className="flex items-center justify-end space-x-3 mt-6 pt-4 border-t">
<button
onClick={() => setIsModalOpen(false)}
className="btn-secondary"
>
Batal
</button>
<button className="btn-primary">
{modalMode === "create" ? "Simpan User" : "Update User"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,675 @@
import { useState, useMemo } from "react";
import {
Calendar,
Filter,
AlertCircle,
CheckCircle,
Clock,
RefreshCw,
XCircle,
Shield,
} from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { registerLocale, setDefaultLocale } from "react-datepicker";
import { id } from "date-fns/locale";
interface BPJSSyncLog {
id: string;
timestamp: string;
type: "sync";
status: "success" | "failed" | "in_progress";
claimsProcessed: number;
claimsSuccess: number;
claimsFailed: number;
source: string;
duration: number;
errorMessage?: string;
}
interface BPJSSyncStats {
totalSyncs: number;
successfulSyncs: number;
failedSyncs: number;
lastSyncTime: string;
totalClaimsProcessed: number;
averageDuration: number;
}
// Register Indonesian locale for DatePicker
registerLocale("id", id);
setDefaultLocale("id");
export default function BPJSSyncLogs() {
// Helper function to get default dates (1 month back)
const getDefaultDates = () => {
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
return { startDate, endDate };
};
const [statusInput, setStatusInput] = useState("all");
const [appliedStatus, setAppliedStatus] = useState("all");
const [isSyncing, setIsSyncing] = useState(false);
const { startDate: defaultStartDate, endDate: defaultEndDate } =
getDefaultDates();
const [startDateInput, setStartDateInput] = useState<Date | undefined>(
defaultStartDate
);
const [endDateInput, setEndDateInput] = useState<Date | undefined>(
defaultEndDate
);
const [appliedStartDate, setAppliedStartDate] = useState<Date | undefined>(
defaultStartDate
);
const [appliedEndDate, setAppliedEndDate] = useState<Date | undefined>(
defaultEndDate
);
const [hasDateFiltered, setHasDateFiltered] = useState(true);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// Sample BPJS sync logs data
const [syncLogs] = useState<BPJSSyncLog[]>(() => {
const baseLogs: BPJSSyncLog[] = [
{
id: "1",
timestamp: "2024-01-15T14:30:00Z",
type: "sync",
status: "success",
claimsProcessed: 234,
claimsSuccess: 230,
claimsFailed: 4,
source: "BPJS Kesehatan API",
duration: 32,
},
{
id: "2",
timestamp: "2024-01-15T10:15:00Z",
type: "sync",
status: "success",
claimsProcessed: 89,
claimsSuccess: 89,
claimsFailed: 0,
source: "Hospital Billing System",
duration: 15,
},
{
id: "3",
timestamp: "2024-01-14T16:45:00Z",
type: "sync",
status: "failed",
claimsProcessed: 0,
claimsSuccess: 0,
claimsFailed: 0,
source: "BPJS Kesehatan API",
duration: 0,
errorMessage: "API rate limit exceeded",
},
{
id: "4",
timestamp: "2024-01-14T09:30:00Z",
type: "sync",
status: "success",
claimsProcessed: 156,
claimsSuccess: 150,
claimsFailed: 6,
source: "External Claims System",
duration: 28,
},
{
id: "5",
timestamp: "2024-01-13T13:20:00Z",
type: "sync",
status: "in_progress",
claimsProcessed: 45,
claimsSuccess: 45,
claimsFailed: 0,
source: "BPJS Kesehatan API",
duration: 0,
},
];
// Generate additional logs for pagination
const generated: BPJSSyncLog[] = Array.from({ length: 15 }).map(
(_, idx) => {
const n = idx + 6;
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 30));
date.setHours(
Math.floor(Math.random() * 24),
Math.floor(Math.random() * 60)
);
const processed = Math.floor(Math.random() * 500) + 50;
const failed = Math.floor(Math.random() * (processed * 0.1)); // Max 10% failure
const success = processed - failed;
return {
id: String(n),
timestamp: date.toISOString(),
type: "sync",
status:
Math.random() > 0.8
? "failed"
: Math.random() > 0.1
? "success"
: "in_progress",
claimsProcessed: processed,
claimsSuccess: success,
claimsFailed: failed,
source: [
"BPJS Kesehatan API",
"Hospital Billing System",
"External Claims System",
"Pharmacy System",
][Math.floor(Math.random() * 4)],
duration: Math.floor(Math.random() * 60) + 10,
...(Math.random() > 0.9 && { errorMessage: "Koneksi timeout" }),
} as BPJSSyncLog;
}
);
return [...baseLogs, ...generated];
});
// Calculate statistics
const stats: BPJSSyncStats = {
totalSyncs: syncLogs.length,
successfulSyncs: syncLogs.filter((log) => log.status === "success").length,
failedSyncs: syncLogs.filter((log) => log.status === "failed").length,
lastSyncTime: syncLogs[0]?.timestamp || "",
totalClaimsProcessed: syncLogs.reduce(
(sum, log) => sum + log.claimsProcessed,
0
),
averageDuration:
syncLogs
.filter((log) => log.duration > 0)
.reduce((sum, log) => sum + log.duration, 0) /
syncLogs.filter((log) => log.duration > 0).length || 0,
};
// Filter sync logs based on date and status
const filteredLogs = useMemo(() => {
return syncLogs.filter((log) => {
const matchesStatus =
appliedStatus === "all" || log.status === appliedStatus;
let matchesDate = true;
if (hasDateFiltered && (appliedStartDate || appliedEndDate)) {
const logDate = new Date(log.timestamp);
const startOk = !appliedStartDate || logDate >= appliedStartDate;
const endOk = !appliedEndDate || logDate <= appliedEndDate;
matchesDate = startOk && endOk;
}
return matchesStatus && matchesDate;
});
}, [
syncLogs,
appliedStatus,
hasDateFiltered,
appliedStartDate,
appliedEndDate,
]);
const columnHelper = createColumnHelper<BPJSSyncLog>();
const columns: ColumnDef<BPJSSyncLog, unknown>[] = useMemo(
() => [
columnHelper.display({
id: "timeAndType",
header: "Waktu & Tipe",
cell: (info) => {
const log = info.row.original;
return (
<div>
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatDate(log.timestamp)}
</div>
<div className="text-sm font-medium text-gray-900 mt-1">
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">
Sync
</span>
</div>
</div>
);
},
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (info) => {
const log = info.row.original;
return (
<div className="flex items-center">
<span
className={`inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${
log.status === "success"
? "bg-green-100 text-green-800"
: log.status === "failed"
? "bg-red-100 text-red-800"
: "bg-blue-100 text-blue-800"
}`}
>
{log.status === "success" ? (
<CheckCircle className="h-4 w-4 mr-1" />
) : log.status === "failed" ? (
<AlertCircle className="h-4 w-4 mr-1" />
) : (
<Clock className="h-4 w-4 mr-1" />
)}
{log.status === "success"
? "Berhasil"
: log.status === "failed"
? "Gagal"
: "Dalam Proses"}
</span>
</div>
);
},
}),
columnHelper.display({
id: "claimsProcessed",
header: "Klaim Diproses",
cell: (info) => {
const log = info.row.original;
return (
<div>
<div className="text-sm text-gray-900">
{log.claimsProcessed.toLocaleString()}
</div>
<div className="text-sm text-gray-500">
{log.claimsSuccess > 0 && (
<span className="text-green-600"> {log.claimsSuccess}</span>
)}
{log.claimsFailed > 0 && (
<span className="text-red-600 ml-2">
{log.claimsFailed}
</span>
)}
</div>
</div>
);
},
}),
columnHelper.display({
id: "successRate",
header: "Tingkat Keberhasilan",
cell: (info) => {
const log = info.row.original;
const successRate =
log.claimsProcessed > 0
? Math.round((log.claimsSuccess / log.claimsProcessed) * 100)
: 0;
const displayRate = Math.min(100, successRate);
return (
<div>
<div className="text-sm text-gray-900">{successRate}%</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{ width: `${displayRate}%` }}
></div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "duration",
header: "Durasi",
cell: (info) => {
const log = info.row.original;
return (
<div className="text-sm text-gray-900">
{log.duration > 0 ? `${log.duration}s` : "-"}
</div>
);
},
}),
],
[columnHelper]
);
const table = useReactTable({
data: filteredLogs,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
enableRowSelection: false,
debugTable: false,
});
const handleSync = async () => {
setIsSyncing(true);
// Simulate sync process
setTimeout(() => {
setIsSyncing(false);
// Add new log entry here
}, 5000);
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Shield className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Log Sinkronisasi BPJS
</h1>
<p className="text-gray-600">
Riwayat dan log sinkronisasi data klaim BPJS dari sistem eksternal
</p>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={handleSync}
disabled={isSyncing}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSyncing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span>
{isSyncing ? "Menyinkronkan..." : "Sinkronisasi Data BPJS"}
</span>
</button>
</div>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Sinkronisasi</p>
<p className="text-2xl font-bold text-blue-600">
{stats.totalSyncs}
</p>
</div>
<div className="p-2 bg-blue-100 rounded-lg">
<RefreshCw className="h-6 w-6 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Berhasil</p>
<p className="text-2xl font-bold text-green-600">
{stats.successfulSyncs}
</p>
</div>
<div className="p-2 bg-green-100 rounded-lg">
<CheckCircle className="h-6 w-6 text-green-600" />
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Gagal</p>
<p className="text-2xl font-bold text-red-600">
{stats.failedSyncs}
</p>
</div>
<div className="p-2 bg-red-100 rounded-lg">
<XCircle className="h-6 w-6 text-red-600" />
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Durasi Rata-rata</p>
<p className="text-2xl font-bold text-orange-600">
{Math.round(stats.averageDuration)}s
</p>
</div>
<div className="p-2 bg-orange-100 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" />
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-3">
{/* Date range */}
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-400" />
<DatePicker
selected={startDateInput}
onChange={(date) => setStartDateInput(date || undefined)}
selectsStart
startDate={startDateInput}
endDate={endDateInput}
placeholderText="Tanggal Mulai"
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-44"
dateFormat="dd MMMM yyyy"
showYearDropdown
showMonthDropdown
dropdownMode="select"
locale="id"
/>
<span className="text-gray-400 text-sm">to</span>
<DatePicker
selected={endDateInput}
onChange={(date) => setEndDateInput(date || undefined)}
selectsEnd
startDate={startDateInput}
endDate={endDateInput}
minDate={startDateInput}
placeholderText="Tanggal Akhir"
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-44"
dateFormat="dd MMMM yyyy"
showYearDropdown
showMonthDropdown
dropdownMode="select"
locale="id"
/>
</div>
{/* Status */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="all">Semua Status</option>
<option value="success">Berhasil</option>
<option value="failed">Gagal</option>
<option value="in_progress">Dalam Proses</option>
</select>
</div>
{/* Buttons */}
<div className="flex items-center space-x-2">
<button
onClick={() => {
setAppliedStartDate(startDateInput);
setAppliedEndDate(endDateInput);
setHasDateFiltered(Boolean(startDateInput || endDateInput));
setAppliedStatus(statusInput);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Filter
</button>
<button
onClick={() => {
const { startDate, endDate } = getDefaultDates();
setStartDateInput(startDate);
setEndDateInput(endDate);
setAppliedStartDate(startDate);
setAppliedEndDate(endDate);
setHasDateFiltered(true);
setStatusInput("all");
setAppliedStatus("all");
}}
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
Reset
</button>
</div>
</div>
</div>
</div>
{/* Sync Logs Table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Log Sinkronisasi BPJS
</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 flex items-center space-x-3">
<span>Halaman</span>
<input
type="number"
min={1}
max={Math.max(1, table.getPageCount())}
value={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = Number(e.target.value) - 1;
if (!Number.isNaN(page)) table.setPageIndex(page);
}}
className="w-16 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span>dari {table.getPageCount() || 1}</span>
</div>
<div className="flex items-center space-x-3">
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
« Pertama
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Sebelum
</button>
<select
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 30, 50].map((ps) => (
<option key={ps} value={ps}>
{ps}/page
</option>
))}
</select>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Selanjutnya
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Terakhir »
</button>
</div>
</div>
</div>
</div>
{/* Empty State */}
{filteredLogs.length === 0 && (
<div className="text-center py-12">
<AlertCircle className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada log sinkronisasi ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Tidak ada log yang sesuai dengan kriteria pencarian saat ini.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,745 @@
import { useState, useMemo } from "react";
import {
RefreshCw,
Calendar,
Filter,
CheckCircle,
XCircle,
Clock,
AlertCircle,
FileText,
} from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { registerLocale, setDefaultLocale } from "react-datepicker";
import { id } from "date-fns/locale";
interface MedicalRecordSyncLog {
id: string;
timestamp: string;
type: "sync";
status: "success" | "failed" | "in_progress";
recordsProcessed: number;
recordsSuccess: number;
recordsFailed: number;
source: string;
duration: number; // in seconds
errorMessage?: string;
details: {
patientsUpdated: number;
diagnosesAdded: number;
treatmentsAdded: number;
vitalsUpdated: number;
};
}
interface MedicalRecordSyncStats {
totalSyncs: number;
successfulSyncs: number;
failedSyncs: number;
lastSyncTime: string;
totalRecordsProcessed: number;
averageDuration: number;
}
// Register Indonesian locale for DatePicker
registerLocale("id", id);
setDefaultLocale("id");
export default function MedicalRecordSyncLogs() {
// Helper function to get default dates (1 month back)
const getDefaultDates = () => {
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
return { startDate, endDate };
};
const [statusInput, setStatusInput] = useState("all");
const [appliedStatus, setAppliedStatus] = useState("all");
const [isSyncing, setIsSyncing] = useState(false);
const { startDate: defaultStartDate, endDate: defaultEndDate } =
getDefaultDates();
const [startDateInput, setStartDateInput] = useState<Date | undefined>(
defaultStartDate
);
const [endDateInput, setEndDateInput] = useState<Date | undefined>(
defaultEndDate
);
const [appliedStartDate, setAppliedStartDate] = useState<Date | undefined>(
defaultStartDate
);
const [appliedEndDate, setAppliedEndDate] = useState<Date | undefined>(
defaultEndDate
);
const [hasDateFiltered, setHasDateFiltered] = useState(true);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getStatusColor = (status: string) => {
switch (status) {
case "success":
return "bg-green-100 text-green-800";
case "failed":
return "bg-red-100 text-red-800";
case "in_progress":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "success":
return <CheckCircle className="h-4 w-4" />;
case "failed":
return <XCircle className="h-4 w-4" />;
case "in_progress":
return <Clock className="h-4 w-4" />;
default:
return <AlertCircle className="h-4 w-4" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "success":
return "Berhasil";
case "failed":
return "Gagal";
case "in_progress":
return "Dalam Proses";
default:
return status;
}
};
// Sample sync logs data
const [syncLogs] = useState<MedicalRecordSyncLog[]>(() => {
const baseLogs: MedicalRecordSyncLog[] = [
{
id: "1",
timestamp: "2024-01-15T14:30:00Z",
type: "sync",
status: "success",
recordsProcessed: 1250,
recordsSuccess: 1245,
recordsFailed: 5,
source: "Hospital Management System API",
duration: 45,
details: {
patientsUpdated: 89,
diagnosesAdded: 156,
treatmentsAdded: 203,
vitalsUpdated: 797,
},
},
{
id: "2",
timestamp: "2024-01-15T10:15:00Z",
type: "sync",
status: "success",
recordsProcessed: 567,
recordsSuccess: 567,
recordsFailed: 0,
source: "External Lab System",
duration: 23,
details: {
patientsUpdated: 0,
diagnosesAdded: 234,
treatmentsAdded: 0,
vitalsUpdated: 333,
},
},
{
id: "3",
timestamp: "2024-01-14T16:45:00Z",
type: "sync",
status: "failed",
recordsProcessed: 0,
recordsSuccess: 0,
recordsFailed: 0,
source: "Radiology System API",
duration: 0,
errorMessage: "Koneksi timeout - Endpoint API tidak merespons",
details: {
patientsUpdated: 0,
diagnosesAdded: 0,
treatmentsAdded: 0,
vitalsUpdated: 0,
},
},
{
id: "4",
timestamp: "2024-01-14T09:30:00Z",
type: "sync",
status: "success",
recordsProcessed: 834,
recordsSuccess: 820,
recordsFailed: 14,
source: "Pharmacy System",
duration: 38,
details: {
patientsUpdated: 45,
diagnosesAdded: 67,
treatmentsAdded: 567,
vitalsUpdated: 155,
},
},
{
id: "5",
timestamp: "2024-01-13T13:20:00Z",
type: "sync",
status: "in_progress",
recordsProcessed: 423,
recordsSuccess: 423,
recordsFailed: 0,
source: "Emergency System API",
duration: 0,
details: {
patientsUpdated: 12,
diagnosesAdded: 89,
treatmentsAdded: 134,
vitalsUpdated: 188,
},
},
];
// Generate additional logs for pagination
const generated: MedicalRecordSyncLog[] = Array.from({ length: 15 }).map(
(_, idx) => {
const n = idx + 6;
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 30));
date.setHours(
Math.floor(Math.random() * 24),
Math.floor(Math.random() * 60)
);
const processed = Math.floor(Math.random() * 800) + 100;
const failed = Math.floor(Math.random() * (processed * 0.1)); // Max 10% failure
const success = processed - failed;
return {
id: String(n),
timestamp: date.toISOString(),
type: "sync",
status:
Math.random() > 0.8
? "failed"
: Math.random() > 0.1
? "success"
: "in_progress",
recordsProcessed: processed,
recordsSuccess: success,
recordsFailed: failed,
source: [
"Hospital Management System API",
"External Lab System",
"Radiology System API",
"Pharmacy System",
"Emergency System API",
][Math.floor(Math.random() * 5)],
duration: Math.floor(Math.random() * 80) + 15,
details: {
patientsUpdated: Math.floor(Math.random() * 100),
diagnosesAdded: Math.floor(Math.random() * 200),
treatmentsAdded: Math.floor(Math.random() * 300),
vitalsUpdated: Math.floor(Math.random() * 500),
},
...(Math.random() > 0.9 && { errorMessage: "Koneksi API gagal" }),
} as MedicalRecordSyncLog;
}
);
return [...baseLogs, ...generated];
});
// Calculate statistics
const stats: MedicalRecordSyncStats = {
totalSyncs: syncLogs.length,
successfulSyncs: syncLogs.filter((log) => log.status === "success").length,
failedSyncs: syncLogs.filter((log) => log.status === "failed").length,
lastSyncTime: syncLogs[0]?.timestamp || "",
totalRecordsProcessed: syncLogs.reduce(
(sum, log) => sum + log.recordsProcessed,
0
),
averageDuration:
syncLogs
.filter((log) => log.duration > 0)
.reduce((sum, log) => sum + log.duration, 0) /
syncLogs.filter((log) => log.duration > 0).length || 0,
};
// Filter logs based on date and status
const filteredLogs = useMemo(() => {
return syncLogs.filter((log) => {
const matchesStatus =
appliedStatus === "all" || log.status === appliedStatus;
let matchesDate = true;
if (hasDateFiltered && (appliedStartDate || appliedEndDate)) {
const logDate = new Date(log.timestamp);
const startOk = !appliedStartDate || logDate >= appliedStartDate;
const endOk = !appliedEndDate || logDate <= appliedEndDate;
matchesDate = startOk && endOk;
}
return matchesStatus && matchesDate;
});
}, [
syncLogs,
appliedStatus,
hasDateFiltered,
appliedStartDate,
appliedEndDate,
]);
const columnHelper = createColumnHelper<MedicalRecordSyncLog>();
const columns: ColumnDef<MedicalRecordSyncLog, unknown>[] = useMemo(
() => [
columnHelper.display({
id: "timeAndType",
header: "Waktu & Tipe",
cell: (info) => {
const log = info.row.original;
return (
<div>
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatDate(log.timestamp)}
</div>
<div className="text-sm font-medium text-gray-900 mt-1">
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">
Sync
</span>
</div>
</div>
);
},
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (info) => {
const log = info.row.original;
return (
<div className="flex items-center">
<span
className={`inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(
log.status
)}`}
>
{getStatusIcon(log.status)}
<span className="ml-1">{getStatusText(log.status)}</span>
</span>
</div>
);
},
}),
columnHelper.display({
id: "recordsProcessed",
header: "Rekam Medis Diproses",
cell: (info) => {
const log = info.row.original;
return (
<div>
<div className="text-sm text-gray-900">
{log.recordsProcessed.toLocaleString()}
</div>
<div className="text-sm text-gray-500">
{log.recordsSuccess > 0 && (
<span className="text-green-600"> {log.recordsSuccess}</span>
)}
{log.recordsFailed > 0 && (
<span className="text-red-600 ml-2">
{log.recordsFailed}
</span>
)}
</div>
</div>
);
},
}),
columnHelper.display({
id: "successRate",
header: "Tingkat Keberhasilan",
cell: (info) => {
const log = info.row.original;
const successRate =
log.recordsProcessed > 0
? Math.round((log.recordsSuccess / log.recordsProcessed) * 100)
: 0;
const displayRate = Math.min(100, successRate);
return (
<div>
<div className="text-sm text-gray-900">{successRate}%</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{ width: `${displayRate}%` }}
></div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "duration",
header: "Durasi",
cell: (info) => {
const log = info.row.original;
return (
<div className="text-sm text-gray-900">
{log.duration > 0 ? `${log.duration}s` : "-"}
</div>
);
},
}),
],
[columnHelper]
);
const table = useReactTable({
data: filteredLogs,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
enableRowSelection: false,
debugTable: false,
});
const handleSync = async () => {
setIsSyncing(true);
// Simulate sync process
setTimeout(() => {
setIsSyncing(false);
// Add new log entry here
}, 5000);
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Log Sinkronisasi Rekam Medis
</h1>
<p className="text-gray-600">
Riwayat dan log sinkronisasi data rekam medis dari sistem
eksternal
</p>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={handleSync}
disabled={isSyncing}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSyncing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span>
{isSyncing ? "Menyinkronkan..." : "Sinkronisasi Rekam Medis"}
</span>
</button>
</div>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Sinkronisasi</p>
<p className="text-2xl font-bold text-blue-600">
{stats.totalSyncs}
</p>
</div>
<div className="p-2 bg-blue-100 rounded-lg">
<RefreshCw className="h-6 w-6 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Berhasil</p>
<p className="text-2xl font-bold text-green-600">
{stats.successfulSyncs}
</p>
</div>
<div className="p-2 bg-green-100 rounded-lg">
<CheckCircle className="h-6 w-6 text-green-600" />
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Gagal</p>
<p className="text-2xl font-bold text-red-600">
{stats.failedSyncs}
</p>
</div>
<div className="p-2 bg-red-100 rounded-lg">
<XCircle className="h-6 w-6 text-red-600" />
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Durasi Rata-rata</p>
<p className="text-2xl font-bold text-orange-600">
{Math.round(stats.averageDuration)}s
</p>
</div>
<div className="p-2 bg-orange-100 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" />
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-3">
{/* Date range */}
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-400" />
<DatePicker
selected={startDateInput}
onChange={(date) => setStartDateInput(date || undefined)}
selectsStart
startDate={startDateInput}
endDate={endDateInput}
placeholderText="Tanggal Mulai"
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-44"
dateFormat="dd MMMM yyyy"
showYearDropdown
showMonthDropdown
dropdownMode="select"
locale="id"
/>
<span className="text-gray-400 text-sm">to</span>
<DatePicker
selected={endDateInput}
onChange={(date) => setEndDateInput(date || undefined)}
selectsEnd
startDate={startDateInput}
endDate={endDateInput}
minDate={startDateInput}
placeholderText="Tanggal Akhir"
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-44"
dateFormat="dd MMMM yyyy"
showYearDropdown
showMonthDropdown
dropdownMode="select"
locale="id"
/>
</div>
{/* Status */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="all">Semua Status</option>
<option value="success">Berhasil</option>
<option value="failed">Gagal</option>
<option value="in_progress">Dalam Proses</option>
</select>
</div>
{/* Buttons */}
<div className="flex items-center space-x-2">
<button
onClick={() => {
setAppliedStartDate(startDateInput);
setAppliedEndDate(endDateInput);
setHasDateFiltered(Boolean(startDateInput || endDateInput));
setAppliedStatus(statusInput);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Filter
</button>
<button
onClick={() => {
const { startDate, endDate } = getDefaultDates();
setStartDateInput(startDate);
setEndDateInput(endDate);
setAppliedStartDate(startDate);
setAppliedEndDate(endDate);
setHasDateFiltered(true);
setStatusInput("all");
setAppliedStatus("all");
}}
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
Reset
</button>
</div>
</div>
</div>
</div>
{/* Sync Logs Table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Log Sinkronisasi Rekam Medis
</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 flex items-center space-x-3">
<span>Halaman</span>
<input
type="number"
min={1}
max={Math.max(1, table.getPageCount())}
value={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = Number(e.target.value) - 1;
if (!Number.isNaN(page)) table.setPageIndex(page);
}}
className="w-16 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span>dari {table.getPageCount() || 1}</span>
</div>
<div className="flex items-center space-x-3">
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
« Pertama
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Sebelum
</button>
<select
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 30, 50].map((ps) => (
<option key={ps} value={ps}>
{ps}/page
</option>
))}
</select>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Selanjutnya
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Terakhir »
</button>
</div>
</div>
</div>
</div>
{/* Empty State */}
{filteredLogs.length === 0 && (
<div className="text-center py-12">
<AlertCircle className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada log sinkronisasi ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Tidak ada log yang sesuai dengan kriteria pencarian saat ini.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default as BPJSSyncLogs } from "./BPJSSyncLogs";
export { default as MedicalRecordSyncLogs } from "./MedicalRecordSyncLogs";

View File

@@ -0,0 +1,582 @@
import { useState } from "react";
import {
FileText,
Search,
User,
Calendar,
CreditCard,
Shield,
CheckCircle,
Clock,
AlertTriangle,
X,
} from "lucide-react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { registerLocale, setDefaultLocale } from "react-datepicker";
import { id } from "date-fns/locale";
interface PatientAdministrative {
name: string;
birthDate: string;
medicalRecordNumber: string;
bpjsNumber: string;
lastVisit: {
date: string;
department: string;
doctor: string;
visitType: string;
};
claimStatus: "approved" | "pending" | "rejected" | "under_review";
paymentStatus: "paid" | "pending" | "partial" | "overdue";
insuranceType: string;
totalCost: number;
paidAmount: number;
outstandingAmount: number;
admissionType: "outpatient" | "inpatient" | "emergency";
roomClass?: string;
}
// Register Indonesian locale for DatePicker
registerLocale("id", id);
setDefaultLocale("id");
export default function Administrative() {
const [searchQuery, setSearchQuery] = useState("");
const [birthDate, setBirthDate] = useState<Date | null>(null);
const [searchResults, setSearchResults] =
useState<PatientAdministrative | null>(null);
const [isSearching, setIsSearching] = useState(false);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(amount);
};
const calculateAge = (birthDate: string) => {
const birth = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birth.getDate())
) {
age--;
}
return age;
};
const getClaimStatusColor = (status: string) => {
switch (status) {
case "approved":
return "bg-green-100 text-green-800";
case "pending":
return "bg-yellow-100 text-yellow-800";
case "rejected":
return "bg-red-100 text-red-800";
case "under_review":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case "paid":
return "bg-green-100 text-green-800";
case "pending":
return "bg-yellow-100 text-yellow-800";
case "partial":
return "bg-orange-100 text-orange-800";
case "overdue":
return "bg-red-100 text-red-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getClaimStatusIcon = (status: string) => {
switch (status) {
case "approved":
return <CheckCircle className="h-4 w-4" />;
case "pending":
return <Clock className="h-4 w-4" />;
case "rejected":
return <X className="h-4 w-4" />;
case "under_review":
return <AlertTriangle className="h-4 w-4" />;
default:
return <Clock className="h-4 w-4" />;
}
};
const getClaimStatusText = (status: string) => {
switch (status) {
case "approved":
return "Disetujui";
case "pending":
return "Menunggu";
case "rejected":
return "Ditolak";
case "under_review":
return "Sedang Ditinjau";
default:
return status;
}
};
const getPaymentStatusText = (status: string) => {
switch (status) {
case "paid":
return "Lunas";
case "pending":
return "Menunggu";
case "partial":
return "Sebagian";
case "overdue":
return "Terlambat";
default:
return status;
}
};
// Mock administrative data
const mockAdministrativeData: PatientAdministrative[] = [
{
name: "Ahmad Budi Santoso",
birthDate: "1985-05-10",
medicalRecordNumber: "MRN2024001",
bpjsNumber: "0001234567890",
lastVisit: {
date: "2024-07-18",
department: "Poli Penyakit Dalam",
doctor: "Dr. Anya Wijaya",
visitType: "Kontrol Rutin",
},
claimStatus: "approved",
paymentStatus: "paid",
insuranceType: "BPJS Kesehatan",
totalCost: 2500000,
paidAmount: 2500000,
outstandingAmount: 0,
admissionType: "outpatient",
},
{
name: "Siti Aminah",
birthDate: "1990-11-22",
medicalRecordNumber: "MRN2024002",
bpjsNumber: "0009876543210",
lastVisit: {
date: "2024-07-19",
department: "Poli Paru",
doctor: "Dr. Bima Sakti",
visitType: "Kunjungan Baru",
},
claimStatus: "under_review",
paymentStatus: "pending",
insuranceType: "BPJS Kesehatan",
totalCost: 1800000,
paidAmount: 0,
outstandingAmount: 1800000,
admissionType: "outpatient",
},
{
name: "Roberto Silva",
birthDate: "1978-03-15",
medicalRecordNumber: "MRN2024003",
bpjsNumber: "0003456789012",
lastVisit: {
date: "2024-07-17",
department: "IGD",
doctor: "Dr. Linda Sari",
visitType: "Emergency",
},
claimStatus: "pending",
paymentStatus: "partial",
insuranceType: "BPJS Kesehatan",
totalCost: 5200000,
paidAmount: 3000000,
outstandingAmount: 2200000,
admissionType: "inpatient",
roomClass: "Kelas 2",
},
];
const handleSearch = () => {
setIsSearching(true);
setSearchResults(null);
setTimeout(() => {
const query = searchQuery.toLowerCase();
const foundPatient = mockAdministrativeData.find(
(patient) =>
patient.name.toLowerCase().includes(query) ||
patient.medicalRecordNumber.toLowerCase().includes(query) ||
patient.bpjsNumber.includes(query)
);
if (foundPatient) {
// Further filter by birth date if provided
if (birthDate) {
const patientBirthDate = new Date(foundPatient.birthDate);
if (
patientBirthDate.getDate() === birthDate.getDate() &&
patientBirthDate.getMonth() === birthDate.getMonth() &&
patientBirthDate.getFullYear() === birthDate.getFullYear()
) {
setSearchResults(foundPatient);
} else {
setSearchResults(null);
}
} else {
setSearchResults(foundPatient);
}
} else {
setSearchResults(null);
}
setIsSearching(false);
}, 1000);
};
const handleClear = () => {
setSearchQuery("");
setBirthDate(null);
setSearchResults(null);
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Rekam Medis Administratif
</h1>
<p className="text-gray-600">
Cari dan kelola informasi administrasi dan tagihan pasien
</p>
</div>
</div>
{/* Search Section */}
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-xl font-semibold text-gray-800 mb-4">
Cari Rekam Medis Administratif Pasien
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-end">
<div>
<label
htmlFor="search-input"
className="block text-sm font-medium text-gray-700 mb-1"
>
Nama Pasien, No. RM, atau No. BPJS
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<input
type="text"
id="search-input"
placeholder="Contoh: Ahmad Santoso, MRN2024001, 000123..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div>
<label
htmlFor="birthdate-picker"
className="block text-sm font-medium text-gray-700 mb-1"
>
Tanggal Lahir (Opsional)
</label>
<DatePicker
id="birthdate-picker"
selected={birthDate}
onChange={(date: Date | null) => setBirthDate(date)}
dateFormat="dd MMMM yyyy"
showYearDropdown
showMonthDropdown
dropdownMode="select"
placeholderText="Pilih tanggal lahir"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
locale="id"
/>
</div>
<div className="lg:col-span-2 flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3 justify-end">
<button
onClick={handleSearch}
disabled={isSearching}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSearching ? (
<svg
className="animate-spin h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<Search className="h-4 w-4" />
)}
<span>{isSearching ? "Mencari..." : "Cari Rekam Medis"}</span>
</button>
<button
onClick={handleClear}
className="flex items-center space-x-2 bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
<X className="h-4 w-4" />
<span>Bersihkan</span>
</button>
</div>
</div>
</div>
{/* Administrative Results Section */}
{searchResults ? (
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-xl font-semibold text-gray-800 mb-6">
Informasi Administratif untuk {searchResults.name}
</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 lg:gap-8">
{/* Patient Basic Information */}
<div className="space-y-6">
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
<h3 className="text-lg font-semibold text-blue-800 mb-3 flex items-center">
<User className="h-5 w-5 mr-2" /> Informasi Pasien
</h3>
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
Nama Pasien:
</span>
<p className="text-gray-900 font-semibold">
{searchResults.name}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Tanggal Lahir:
</span>
<p className="text-gray-900">
{formatDate(searchResults.birthDate)} (
{calculateAge(searchResults.birthDate)} tahun)
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Nomor Rekam Medis:
</span>
<p className="text-gray-900 font-semibold">
{searchResults.medicalRecordNumber}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Nomor BPJS:
</span>
<p className="text-gray-900 font-semibold">
{searchResults.bpjsNumber}
</p>
</div>
</div>
</div>
{/* Visit History */}
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
<h3 className="text-lg font-semibold text-green-800 mb-3 flex items-center">
<Calendar className="h-5 w-5 mr-2" /> Riwayat Kunjungan
Terakhir
</h3>
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
Tanggal Kunjungan:
</span>
<p className="text-gray-900">
{formatDate(searchResults.lastVisit.date)}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Departemen:
</span>
<p className="text-gray-900">
{searchResults.lastVisit.department}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Dokter:
</span>
<p className="text-gray-900">
{searchResults.lastVisit.doctor}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Tipe Kunjungan:
</span>
<p className="text-gray-900">
{searchResults.lastVisit.visitType}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Tipe Rawat:
</span>
<p className="text-gray-900 capitalize">
{searchResults.admissionType === "outpatient"
? "Rawat Jalan"
: searchResults.admissionType === "inpatient"
? "Rawat Inap"
: "Gawat Darurat"}
{searchResults.roomClass &&
` - ${searchResults.roomClass}`}
</p>
</div>
</div>
</div>
</div>
{/* Status and Financial Information */}
<div className="space-y-6">
{/* Claim Status */}
<div className="bg-purple-50 p-4 rounded-lg border border-purple-200">
<h3 className="text-lg font-semibold text-purple-800 mb-3 flex items-center">
<Shield className="h-5 w-5 mr-2" /> Status Klaim
</h3>
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
Jenis Asuransi:
</span>
<p className="text-gray-900 font-semibold">
{searchResults.insuranceType}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Status Klaim:
</span>
<div className="mt-1">
<span
className={`inline-flex items-center px-3 py-1 text-sm font-medium rounded-full ${getClaimStatusColor(
searchResults.claimStatus
)}`}
>
{getClaimStatusIcon(searchResults.claimStatus)}
<span className="ml-2">
{getClaimStatusText(searchResults.claimStatus)}
</span>
</span>
</div>
</div>
</div>
</div>
{/* Payment Status */}
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<h3 className="text-lg font-semibold text-yellow-800 mb-3 flex items-center">
<CreditCard className="h-5 w-5 mr-2" /> Status Pembayaran
</h3>
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
Total Biaya:
</span>
<p className="text-gray-900 font-bold text-lg">
{formatCurrency(searchResults.totalCost)}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Jumlah Dibayar:
</span>
<p className="text-green-600 font-semibold">
{formatCurrency(searchResults.paidAmount)}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Sisa Tagihan:
</span>
<p
className={`font-semibold ${
searchResults.outstandingAmount > 0
? "text-red-600"
: "text-green-600"
}`}
>
{formatCurrency(searchResults.outstandingAmount)}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Status Pembayaran:
</span>
<div className="mt-1">
<span
className={`inline-flex items-center px-3 py-1 text-sm font-medium rounded-full ${getPaymentStatusColor(
searchResults.paymentStatus
)}`}
>
{getPaymentStatusText(searchResults.paymentStatus)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="text-center py-12 bg-white rounded-lg shadow-sm border">
<FileText className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-lg font-medium text-gray-900">
{isSearching
? "Mencari..."
: "Rekam medis administratif tidak ditemukan"}
</h3>
<p className="mt-1 text-sm text-gray-500">
{isSearching
? "Mohon tunggu sementara kami mengambil informasi administratif."
: "Masukkan detail pasien di form pencarian di atas untuk menemukan rekam medis administratif."}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,520 @@
import { useState } from "react";
import {
Code,
AlertCircle,
Wand2,
ShieldCheck,
ClipboardList,
FileText,
} from "lucide-react";
interface AssistInput {
clinicalNotes: string;
labResults: string;
procedures: string;
}
interface ICD10Code {
code: string;
description: string;
reasoning: string;
}
interface ProcedureCode {
code: string;
description: string;
reasoning: string;
}
interface BPJSMapping {
ina_cbg_code: string;
description: string;
tariff: number;
}
interface AssistResult {
icd_10_codes: ICD10Code[];
procedure_codes: ProcedureCode[];
bpjs_mapping: BPJSMapping;
}
export default function BPJSCodeification() {
const [assistInput, setAssistInput] = useState<AssistInput>({
clinicalNotes: "",
labResults: "",
procedures: "",
});
const [assistLoading, setAssistLoading] = useState(false);
const [assistError, setAssistError] = useState<string | null>(null);
const [assistResult, setAssistResult] = useState<AssistResult | null>(null);
const formatCurrency = (amount: number) =>
new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(amount);
// Mock pipeline with improved JSON output format
const preprocessInput = (input: AssistInput): AssistInput => {
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
return {
clinicalNotes: normalize(input.clinicalNotes),
labResults: normalize(input.labResults),
procedures: normalize(input.procedures),
};
};
const mockAssistPipeline = async (
input: AssistInput
): Promise<AssistResult> => {
const text =
`${input.clinicalNotes} ${input.labResults} ${input.procedures}`.toLowerCase();
const icd10Codes: ICD10Code[] = [];
const procedureCodes: ProcedureCode[] = [];
// ICD-10 Code Detection
if (
text.includes("pneumonia") ||
text.includes("infiltrat") ||
text.includes("demam") ||
text.includes("batuk") ||
text.includes("sesak")
) {
icd10Codes.push({
code: "J18.9",
description: "Pneumonia dengan kuman tidak spesifik",
reasoning:
"Gejala demam, batuk, sesak napas, dan hasil pemeriksaan rontgen mengarah pada pneumonia.",
});
}
if (
text.includes("hipertensi") ||
text.includes("bp 150/95") ||
text.includes("tekanan darah tinggi") ||
text.includes("hypertension")
) {
icd10Codes.push({
code: "I10",
description: "Hipertensi esensial (primer)",
reasoning:
"Temuan tekanan darah tinggi pada catatan klinis dan pemeriksaan fisik.",
});
}
if (
text.includes("diabetes") ||
text.includes("hba1c") ||
text.includes("glukosa") ||
text.includes("gula darah")
) {
icd10Codes.push({
code: "E11.9",
description: "Diabetes mellitus tipe 2 tanpa komplikasi",
reasoning:
"Hasil laboratorium menunjukkan peningkatan HbA1c dan glukosa darah yang mendukung diagnosis diabetes.",
});
}
if (
text.includes("gastritis") ||
text.includes("nyeri perut") ||
text.includes("mual") ||
text.includes("muntah")
) {
icd10Codes.push({
code: "K29.1",
description: "Gastritis akut lainnya",
reasoning:
"Gejala nyeri perut, mual, muntah mengarah pada gastritis akut.",
});
}
if (
text.includes("influenza") ||
text.includes("flu") ||
text.includes("demam") ||
text.includes("sakit kepala")
) {
icd10Codes.push({
code: "J11.1",
description:
"Influenza dengan virus tidak teridentifikasi disertai manifestasi respiratori",
reasoning:
"Gejala demam, sakit kepala, dan manifestasi respiratori mengarah pada influenza.",
});
}
// Procedure Code Detection
if (
text.includes("rontgen") ||
text.includes("x-ray") ||
text.includes("chest x-ray") ||
text.includes("foto thorax")
) {
procedureCodes.push({
code: "87.44",
description: "Foto Rontgen thorax, proyeksi tunggal",
reasoning: "Tindakan rontgen dada sesuai gejala respiratori.",
});
}
if (
text.includes("darah lengkap") ||
text.includes("complete blood count") ||
text.includes("cbc") ||
text.includes("lab darah")
) {
procedureCodes.push({
code: "90.59",
description: "Pemeriksaan darah lengkap",
reasoning: "Pemeriksaan darah lengkap untuk mendukung diagnosis.",
});
}
if (
text.includes("endoskopi") ||
text.includes("endoscopy") ||
text.includes("gastroscopy")
) {
procedureCodes.push({
code: "45.13",
description: "Esofagogastroduodenoskopi (EGD)",
reasoning: "Tindakan endoskopi lambung untuk evaluasi gastritis.",
});
}
if (
text.includes("ekg") ||
text.includes("ecg") ||
text.includes("elektrokardiogram")
) {
procedureCodes.push({
code: "89.52",
description: "Elektrokardiogram (EKG)",
reasoning: "Pemeriksaan EKG untuk evaluasi kardiovaskular.",
});
}
if (
text.includes("usg") ||
text.includes("ultrasound") ||
text.includes("ultrasonografi")
) {
procedureCodes.push({
code: "88.76",
description: "USG abdomen dan retroperitoneum diagnostik",
reasoning: "Pemeriksaan USG abdomen untuk evaluasi organ dalam.",
});
}
// Default codes if nothing specific detected
if (icd10Codes.length === 0) {
icd10Codes.push({
code: "R69",
description: "Penyakit tidak spesifik",
reasoning:
"Tidak ada gejala spesifik yang terdeteksi, memerlukan klarifikasi klinis lebih lanjut.",
});
}
if (procedureCodes.length === 0) {
procedureCodes.push({
code: "99213",
description: "Kunjungan rawat jalan",
reasoning: "Kunjungan rawat jalan untuk evaluasi medis.",
});
}
// BPJS Mapping based on primary diagnosis
let bpjsMapping: BPJSMapping;
const hasPneumonia = icd10Codes.some((code) => code.code.startsWith("J18"));
const hasDiabetes = icd10Codes.some((code) => code.code.startsWith("E11"));
const hasHypertension = icd10Codes.some((code) =>
code.code.startsWith("I10")
);
const hasGastritis = icd10Codes.some((code) => code.code.startsWith("K29"));
const hasInfluenza = icd10Codes.some((code) => code.code.startsWith("J11"));
if (hasPneumonia) {
bpjsMapping = {
ina_cbg_code: "B-4-13-I",
description: "Pneumonia tanpa komplikasi",
tariff: 3500000,
};
} else if (hasDiabetes) {
bpjsMapping = {
ina_cbg_code: "E-4-10-I",
description: "Diabetes Mellitus tanpa komplikasi",
tariff: 2100000,
};
} else if (hasHypertension) {
bpjsMapping = {
ina_cbg_code: "F-4-13-II",
description: "Hipertensi dengan komplikasi minor",
tariff: 1850000,
};
} else if (hasGastritis) {
bpjsMapping = {
ina_cbg_code: "G-4-10-I",
description: "Gastritis akut tanpa komplikasi",
tariff: 1200000,
};
} else if (hasInfluenza) {
bpjsMapping = {
ina_cbg_code: "H-4-11-I",
description: "Influenza tanpa komplikasi",
tariff: 950000,
};
} else {
bpjsMapping = {
ina_cbg_code: "Z-4-00-I",
description: "Kondisi tidak spesifik",
tariff: 750000,
};
}
await new Promise((resolve) => setTimeout(resolve, 1500)); // Simulate processing time
return {
icd_10_codes: icd10Codes.slice(0, 3), // Limit to top 3
procedure_codes: procedureCodes.slice(0, 3), // Limit to top 3
bpjs_mapping: bpjsMapping,
};
};
const runAssistPipeline = async () => {
setAssistLoading(true);
setAssistError(null);
setAssistResult(null);
try {
const preprocessed = preprocessInput(assistInput);
const result = await mockAssistPipeline(preprocessed);
setAssistResult(result);
} catch (e) {
console.error(e);
setAssistError(
"Gagal menjalankan BPJS Assist Coding. Silakan coba lagi."
);
} finally {
setAssistLoading(false);
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Code className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Asisten Kodefikasi BPJS
</h1>
<p className="text-gray-600">
Bantuan penentuan kode ICD-10, prosedur, dan mapping INA-CBGs untuk
klaim BPJS
</p>
</div>
</div>
{/* Form Bantuan Kodefikasi */}
<div className="bg-white rounded-lg shadow-sm border mb-6">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
<Wand2 className="h-5 w-5 mr-2 text-blue-600" />
Input Data Klinis
</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="text-sm font-medium text-gray-700 mb-1 flex items-center">
<ClipboardList className="h-4 w-4 mr-2 text-blue-600" />
Catatan Medis / Anamnesis
</label>
<textarea
rows={4}
value={assistInput.clinicalNotes}
onChange={(e) =>
setAssistInput((s) => ({
...s,
clinicalNotes: e.target.value,
}))
}
placeholder="Pasien mengeluh demam sejak 3 hari, batuk berdahak, sesak napas, nyeri dada..."
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent p-3"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Hasil Laboratorium
</label>
<textarea
rows={3}
value={assistInput.labResults}
onChange={(e) =>
setAssistInput((s) => ({
...s,
labResults: e.target.value,
}))
}
placeholder="Leukosit: 15.000/µL, CRP: 25 mg/L, HbA1c: 7.8%, Glukosa Puasa: 180 mg/dL..."
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent p-3"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tindakan / Prosedur yang Dilakukan
</label>
<input
type="text"
value={assistInput.procedures}
onChange={(e) =>
setAssistInput((s) => ({
...s,
procedures: e.target.value,
}))
}
placeholder="Rontgen thorax, darah lengkap, EKG, endoskopi lambung..."
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent p-3"
/>
</div>
<div className="flex items-center space-x-3">
<button
onClick={runAssistPipeline}
disabled={
assistLoading ||
(!assistInput.clinicalNotes &&
!assistInput.labResults &&
!assistInput.procedures)
}
className="flex items-center space-x-2 disabled:opacity-60 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg disabled:cursor-not-allowed"
>
<Wand2 className="h-4 w-4" />
<span>
{assistLoading ? "Memproses..." : "Generate Kode BPJS"}
</span>
</button>
</div>
{assistError && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3">
{assistError}
</div>
)}
</div>
</div>
{/* Results Section */}
<div className="p-6">
{!assistResult ? (
<div className="text-center py-12">
<AlertCircle className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Belum ada hasil
</h3>
<p className="mt-1 text-sm text-gray-500">
Isi input data klinis dan klik "Generate Kode BPJS" untuk
memulai.
</p>
</div>
) : (
<div className="space-y-6">
{/* ICD-10 Codes */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-blue-900 mb-3 flex items-center">
<ShieldCheck className="h-5 w-5 mr-2" />
Kode ICD-10 Diagnosis
</h4>
<div className="space-y-3">
{assistResult.icd_10_codes.map((icd, index) => (
<div
key={index}
className="bg-white border border-blue-200 rounded-md p-3"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-semibold text-blue-900">
{icd.code} - {icd.description}
</div>
<div className="text-sm text-blue-700 mt-1">
<strong>Alasan:</strong> {icd.reasoning}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Procedure Codes */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-green-900 mb-3 flex items-center">
<FileText className="h-5 w-5 mr-2" />
Kode Prosedur
</h4>
<div className="space-y-3">
{assistResult.procedure_codes.map((proc, index) => (
<div
key={index}
className="bg-white border border-green-200 rounded-md p-3"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-semibold text-green-900">
{proc.code} - {proc.description}
</div>
<div className="text-sm text-green-700 mt-1">
<strong>Alasan:</strong> {proc.reasoning}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* BPJS Mapping */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-purple-900 mb-3">
Mapping INA-CBGs BPJS
</h4>
<div className="bg-white border border-purple-200 rounded-md p-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div>
<span className="text-sm font-medium text-gray-600">
Kode INA-CBGs:
</span>
<p className="text-lg font-bold text-purple-900">
{assistResult.bpjs_mapping.ina_cbg_code}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Deskripsi:
</span>
<p className="text-gray-900">
{assistResult.bpjs_mapping.description}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Estimasi Tarif:
</span>
<p className="text-lg font-bold text-green-600">
{formatCurrency(assistResult.bpjs_mapping.tariff)}
</p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,984 @@
import { useState } from "react";
import {
User,
FileText,
Stethoscope,
Search,
Droplet,
MapPin,
Phone,
Mail,
Activity,
Pill,
ClipboardList,
TestTube,
X,
Eye,
Download,
} from "lucide-react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { registerLocale, setDefaultLocale } from "react-datepicker";
import { id } from "date-fns/locale";
interface PatientIdentity {
name: string;
medicalRecordNumber: string;
bpjsNumber: string;
birthDate: string;
gender: "Male" | "Female";
bloodType: string;
address: string;
phone: string;
email: string;
}
interface MedicalHistory {
previousDiseases: string[];
allergies: string[];
medications: string[];
surgeryHistory: string[];
familyHistory: string[];
}
interface PhysicalExamination {
vitalSigns: {
bloodPressure: string;
heartRate: string;
temperature: string;
respiratoryRate: string;
weight: string;
height: string;
};
generalCondition: string;
systemExamination: string[];
}
interface SupportingExamination {
laboratory: Array<{
testName: string;
result: string;
normalRange: string;
date: string;
}>;
radiology: Array<{
testType: string;
result: string;
date: string;
images?: string[];
}>;
}
interface DiagnosisICD {
primary: {
diagnosis: string;
icdCode: string;
};
secondary: Array<{
diagnosis: string;
icdCode: string;
}>;
}
interface TreatmentPlan {
medications: Array<{
name: string;
dosage: string;
frequency: string;
duration: string;
}>;
procedures: Array<{
name: string;
date: string;
notes: string;
}>;
recommendations: string[];
}
interface MedicalDocument {
id: string;
type: string;
name: string;
date: string;
url: string;
}
interface MedicalRecord {
id: string;
patientIdentity: PatientIdentity;
medicalHistory: MedicalHistory;
chiefComplaint: string;
anamnesis: string;
physicalExamination: PhysicalExamination;
supportingExamination: SupportingExamination;
diagnosisICD: DiagnosisICD;
treatmentPlan: TreatmentPlan;
prescriptionHistory: Array<{
date: string;
medications: string[];
doctor: string;
}>;
medicalDocuments: MedicalDocument[];
lastUpdated: string;
attendingDoctor: string;
}
// Register Indonesian locale for DatePicker
registerLocale("id", id);
setDefaultLocale("id");
export default function Clinical() {
const [searchQuery, setSearchQuery] = useState("");
const [birthDate, setBirthDate] = useState<Date | null>(null);
const [searchResults, setSearchResults] = useState<MedicalRecord | null>(
null
);
const [isSearching, setIsSearching] = useState(false);
// Mock medical record data
const mockMedicalRecord: MedicalRecord = {
id: "MR001",
patientIdentity: {
name: "John Doe",
medicalRecordNumber: "MR2024001",
bpjsNumber: "0001234567890",
birthDate: "1985-06-15",
gender: "Male",
bloodType: "O+",
address: "Jl. Sudirman No. 123, Jakarta Pusat, DKI Jakarta 10220",
phone: "+62 812-3456-7890",
email: "john.doe@email.com",
},
medicalHistory: {
previousDiseases: [
"Hypertension (2020)",
"Diabetes Mellitus Type 2 (2021)",
],
allergies: ["Penicillin", "Shellfish"],
medications: ["Metformin 500mg", "Lisinopril 10mg", "Aspirin 81mg"],
surgeryHistory: ["Appendectomy (2010)", "Gallbladder removal (2018)"],
familyHistory: [
"Father: Diabetes",
"Mother: Hypertension",
"Grandfather: Heart Disease",
],
},
chiefComplaint: "Chest pain and shortness of breath for 2 days",
anamnesis:
"Patient reports gradual onset of chest discomfort, described as pressure-like sensation, worsening with exertion. Associated with mild shortness of breath and fatigue. No radiation to arms or jaw. No recent trauma or stress.",
physicalExamination: {
vitalSigns: {
bloodPressure: "145/90 mmHg",
heartRate: "88 bpm",
temperature: "36.8°C",
respiratoryRate: "20/min",
weight: "75 kg",
height: "170 cm",
},
generalCondition: "Alert, cooperative, appears mildly distressed",
systemExamination: [
"Cardiovascular: Regular rate and rhythm, no murmurs",
"Respiratory: Clear to auscultation bilaterally",
"Abdomen: Soft, non-tender, no organomegaly",
"Neurological: Alert and oriented x3",
],
},
supportingExamination: {
laboratory: [
{
testName: "Troponin I",
result: "0.05 ng/mL",
normalRange: "<0.04 ng/mL",
date: "2024-01-15",
},
{
testName: "CK-MB",
result: "4.2 ng/mL",
normalRange: "0-6.3 ng/mL",
date: "2024-01-15",
},
{
testName: "HbA1c",
result: "7.2%",
normalRange: "<7.0%",
date: "2024-01-15",
},
],
radiology: [
{
testType: "Chest X-Ray",
result: "Normal heart size, clear lung fields, no acute findings",
date: "2024-01-15",
},
{
testType: "ECG",
result: "Normal sinus rhythm, no acute ST changes",
date: "2024-01-15",
},
],
},
diagnosisICD: {
primary: {
diagnosis: "Unstable Angina",
icdCode: "I20.0",
},
secondary: [
{
diagnosis: "Essential Hypertension",
icdCode: "I10",
},
{
diagnosis: "Type 2 Diabetes Mellitus",
icdCode: "E11.9",
},
],
},
treatmentPlan: {
medications: [
{
name: "Aspirin",
dosage: "81mg",
frequency: "Once daily",
duration: "Ongoing",
},
{
name: "Atorvastatin",
dosage: "40mg",
frequency: "Once daily at bedtime",
duration: "Ongoing",
},
{
name: "Metoprolol",
dosage: "25mg",
frequency: "Twice daily",
duration: "Ongoing",
},
],
procedures: [
{
name: "Cardiac Catheterization",
date: "2024-01-20",
notes: "Scheduled for coronary angiography",
},
],
recommendations: [
"Low sodium diet (<2g/day)",
"Regular exercise as tolerated",
"Weight management",
"Smoking cessation counseling",
"Follow-up in cardiology clinic in 1 week",
],
},
prescriptionHistory: [
{
date: "2024-01-15",
medications: ["Aspirin 81mg", "Atorvastatin 40mg", "Metoprolol 25mg"],
doctor: "Dr. Smith",
},
{
date: "2023-12-15",
medications: ["Metformin 500mg", "Lisinopril 10mg"],
doctor: "Dr. Johnson",
},
],
medicalDocuments: [
{
id: "DOC001",
type: "Lab Report",
name: "Blood Chemistry Panel",
date: "2024-01-15",
url: "/documents/lab-report-001.pdf",
},
{
id: "DOC002",
type: "Radiology",
name: "Chest X-Ray Report",
date: "2024-01-15",
url: "/documents/xray-001.pdf",
},
{
id: "DOC003",
type: "ECG",
name: "Electrocardiogram",
date: "2024-01-15",
url: "/documents/ecg-001.pdf",
},
],
lastUpdated: "2024-01-15T14:30:00Z",
attendingDoctor: "Dr. Michael Smith, MD",
};
const handleSearch = () => {
setIsSearching(true);
// Simulate API call
setTimeout(() => {
if (searchQuery.trim()) {
setSearchResults(mockMedicalRecord);
} else {
setSearchResults(null);
}
setIsSearching(false);
}, 1000);
};
const handleClearSearch = () => {
setSearchQuery("");
setBirthDate(null);
setSearchResults(null);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const calculateAge = (birthDate: string) => {
const today = new Date();
const birth = new Date(birthDate);
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birth.getDate())
) {
age--;
}
return age;
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Stethoscope className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Rekam Medis Klinis
</h1>
<p className="text-gray-600">
Cari dan lihat rekam medis pasien untuk diagnosis klinis
</p>
</div>
</div>
</div>
{/* Search Form */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Search className="h-5 w-5 mr-2 text-blue-600" />
Pencarian Pasien
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Nama Pasien / Nomor Rekam Medis / Nomor BPJS
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Masukkan nama pasien, nomor rekam medis, atau nomor BPJS..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tanggal Lahir (Opsional)
</label>
<DatePicker
selected={birthDate}
onChange={(date) => setBirthDate(date)}
placeholderText="Pilih tanggal lahir"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
dateFormat="dd MMMM yyyy"
showYearDropdown
showMonthDropdown
dropdownMode="select"
locale="id"
/>
</div>
</div>
<div className="lg:col-span-3 flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3 justify-end mt-4">
<button
onClick={handleSearch}
disabled={isSearching}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<Search className="h-4 w-4" />
<span>{isSearching ? "Mencari..." : "Cari Rekam Medis"}</span>
</button>
<button
onClick={handleClearSearch}
className="flex items-center space-x-2 bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
<X className="h-4 w-4" />
<span>Bersihkan</span>
</button>
</div>
</div>
{/* Search Results */}
{searchResults && (
<div className="space-y-6">
{/* Patient Identity */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<User className="h-5 w-5 mr-2 text-green-600" />
Identitas Pasien
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
Nama:
</span>
<p className="text-gray-900">
{searchResults.patientIdentity.name}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Nomor Rekam Medis:
</span>
<p className="text-gray-900">
{searchResults.patientIdentity.medicalRecordNumber}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
BPJS Number:
</span>
<p className="text-gray-900">
{searchResults.patientIdentity.bpjsNumber}
</p>
</div>
</div>
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
Tanggal Lahir:
</span>
<p className="text-gray-900">
{formatDate(searchResults.patientIdentity.birthDate)}(
{calculateAge(searchResults.patientIdentity.birthDate)}{" "}
tahun)
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Jenis Kelamin:
</span>
<p className="text-gray-900">
{searchResults.patientIdentity.gender}
</p>
</div>
<div className="flex items-center">
<Droplet className="h-4 w-4 mr-1 text-red-500" />
<span className="text-sm font-medium text-gray-600">
Golongan Darah:
</span>
<p className="text-gray-900 ml-1">
{searchResults.patientIdentity.bloodType}
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex items-start">
<MapPin className="h-4 w-4 mr-1 text-blue-500 mt-1" />
<div>
<span className="text-sm font-medium text-gray-600">
Alamat:
</span>
<p className="text-gray-900">
{searchResults.patientIdentity.address}
</p>
</div>
</div>
<div className="flex items-center">
<Phone className="h-4 w-4 mr-1 text-green-500" />
<span className="text-sm font-medium text-gray-600">
Telepon:
</span>
<p className="text-gray-900 ml-1">
{searchResults.patientIdentity.phone}
</p>
</div>
<div className="flex items-center">
<Mail className="h-4 w-4 mr-1 text-purple-500" />
<span className="text-sm font-medium text-gray-600">
Email:
</span>
<p className="text-gray-900 ml-1">
{searchResults.patientIdentity.email}
</p>
</div>
</div>
</div>
</div>
{/* Medical History */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<ClipboardList className="h-5 w-5 mr-2 text-orange-600" />
Riwayat Medis
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<h3 className="font-medium text-gray-900 mb-2">
Riwayat Penyakit
</h3>
<ul className="space-y-1">
{searchResults.medicalHistory.previousDiseases.map(
(disease, index) => (
<li key={index} className="text-sm text-gray-700">
{disease}
</li>
)
)}
</ul>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">Alergi</h3>
<ul className="space-y-1">
{searchResults.medicalHistory.allergies.map(
(allergy, index) => (
<li key={index} className="text-sm text-red-600">
{allergy}
</li>
)
)}
</ul>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">
Obat Saat Ini
</h3>
<ul className="space-y-1">
{searchResults.medicalHistory.medications.map(
(medication, index) => (
<li key={index} className="text-sm text-gray-700">
{medication}
</li>
)
)}
</ul>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">
Riwayat Operasi
</h3>
<ul className="space-y-1">
{searchResults.medicalHistory.surgeryHistory.map(
(surgery, index) => (
<li key={index} className="text-sm text-gray-700">
{surgery}
</li>
)
)}
</ul>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">
Riwayat Keluarga
</h3>
<ul className="space-y-1">
{searchResults.medicalHistory.familyHistory.map(
(family, index) => (
<li key={index} className="text-sm text-gray-700">
{family}
</li>
)
)}
</ul>
</div>
</div>
</div>
{/* Chief Complaint & Anamnesis */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<FileText className="h-5 w-5 mr-2 text-purple-600" />
Keluhan Utama & Anamnesis
</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium text-gray-900 mb-2">
Keluhan Utama
</h3>
<p className="text-gray-700 bg-gray-50 p-3 rounded-md">
{searchResults.chiefComplaint}
</p>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">Anamnesis</h3>
<p className="text-gray-700 bg-gray-50 p-3 rounded-md">
{searchResults.anamnesis}
</p>
</div>
</div>
</div>
{/* Physical Examination */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Activity className="h-5 w-5 mr-2 text-red-600" />
Pemeriksaan Fisik
</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium text-gray-900 mb-3">Tanda Vital</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="bg-red-50 p-3 rounded-md">
<span className="text-sm font-medium text-gray-600">
TD
</span>
<p className="text-red-700 font-semibold">
{
searchResults.physicalExamination.vitalSigns
.bloodPressure
}
</p>
</div>
<div className="bg-blue-50 p-3 rounded-md">
<span className="text-sm font-medium text-gray-600">
Nadi
</span>
<p className="text-blue-700 font-semibold">
{searchResults.physicalExamination.vitalSigns.heartRate}
</p>
</div>
<div className="bg-yellow-50 p-3 rounded-md">
<span className="text-sm font-medium text-gray-600">
Suhu
</span>
<p className="text-yellow-700 font-semibold">
{searchResults.physicalExamination.vitalSigns.temperature}
</p>
</div>
<div className="bg-green-50 p-3 rounded-md">
<span className="text-sm font-medium text-gray-600">
RR
</span>
<p className="text-green-700 font-semibold">
{
searchResults.physicalExamination.vitalSigns
.respiratoryRate
}
</p>
</div>
<div className="bg-purple-50 p-3 rounded-md">
<span className="text-sm font-medium text-gray-600">
Weight
</span>
<p className="text-purple-700 font-semibold">
{searchResults.physicalExamination.vitalSigns.weight}
</p>
</div>
<div className="bg-indigo-50 p-3 rounded-md">
<span className="text-sm font-medium text-gray-600">
Height
</span>
<p className="text-indigo-700 font-semibold">
{searchResults.physicalExamination.vitalSigns.height}
</p>
</div>
</div>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">
General Condition
</h3>
<p className="text-gray-700 bg-gray-50 p-3 rounded-md">
{searchResults.physicalExamination.generalCondition}
</p>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">
System Examination
</h3>
<ul className="space-y-1">
{searchResults.physicalExamination.systemExamination.map(
(exam, index) => (
<li
key={index}
className="text-sm text-gray-700 bg-gray-50 p-2 rounded-md"
>
{exam}
</li>
)
)}
</ul>
</div>
</div>
</div>
{/* Supporting Examination */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<TestTube className="h-5 w-5 mr-2 text-teal-600" />
Pemeriksaan Penunjang
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-gray-900 mb-3">
Laboratory Results
</h3>
<div className="space-y-2">
{searchResults.supportingExamination.laboratory.map(
(lab, index) => (
<div key={index} className="bg-gray-50 p-3 rounded-md">
<div className="flex justify-between items-start">
<div>
<span className="font-medium text-gray-900">
{lab.testName}
</span>
<p className="text-sm text-gray-600">
Normal: {lab.normalRange}
</p>
</div>
<div className="text-right">
<span
className={`font-semibold ${
lab.result > lab.normalRange.split("-")[1]
? "text-red-600"
: "text-green-600"
}`}
>
{lab.result}
</span>
<p className="text-xs text-gray-500">
{formatDate(lab.date)}
</p>
</div>
</div>
</div>
)
)}
</div>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-3">
Radiology Results
</h3>
<div className="space-y-2">
{searchResults.supportingExamination.radiology.map(
(radio, index) => (
<div key={index} className="bg-gray-50 p-3 rounded-md">
<div className="flex justify-between items-start">
<div>
<span className="font-medium text-gray-900">
{radio.testType}
</span>
<p className="text-sm text-gray-700 mt-1">
{radio.result}
</p>
</div>
<p className="text-xs text-gray-500">
{formatDate(radio.date)}
</p>
</div>
</div>
)
)}
</div>
</div>
</div>
</div>
{/* Diagnosis & ICD */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<ClipboardList className="h-5 w-5 mr-2 text-indigo-600" />
Diagnosis & Kode ICD
</h2>
<div className="space-y-4">
<div className="bg-blue-50 p-4 rounded-md">
<h3 className="font-medium text-blue-900 mb-2">
Primary Diagnosis
</h3>
<div className="flex justify-between items-center">
<span className="text-blue-800">
{searchResults.diagnosisICD.primary.diagnosis}
</span>
<span className="bg-blue-200 text-blue-800 px-2 py-1 rounded text-sm font-mono">
{searchResults.diagnosisICD.primary.icdCode}
</span>
</div>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">
Secondary Diagnoses
</h3>
<div className="space-y-2">
{searchResults.diagnosisICD.secondary.map((diag, index) => (
<div
key={index}
className="bg-gray-50 p-3 rounded-md flex justify-between items-center"
>
<span className="text-gray-800">{diag.diagnosis}</span>
<span className="bg-gray-200 text-gray-800 px-2 py-1 rounded text-sm font-mono">
{diag.icdCode}
</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* Treatment Plan */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Pill className="h-5 w-5 mr-2 text-green-600" />
Rencana Pengobatan
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-gray-900 mb-3">Medications</h3>
<div className="space-y-2">
{searchResults.treatmentPlan.medications.map((med, index) => (
<div key={index} className="bg-green-50 p-3 rounded-md">
<div className="font-medium text-green-900">
{med.name}
</div>
<div className="text-sm text-green-700">
{med.dosage} - {med.frequency} - {med.duration}
</div>
</div>
))}
</div>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-3">Procedures</h3>
<div className="space-y-2">
{searchResults.treatmentPlan.procedures.map((proc, index) => (
<div key={index} className="bg-blue-50 p-3 rounded-md">
<div className="font-medium text-blue-900">
{proc.name}
</div>
<div className="text-sm text-blue-700">
Scheduled: {formatDate(proc.date)}
</div>
<div className="text-sm text-blue-600">{proc.notes}</div>
</div>
))}
</div>
</div>
</div>
<div className="mt-4">
<h3 className="font-medium text-gray-900 mb-2">
Recommendations
</h3>
<ul className="space-y-1">
{searchResults.treatmentPlan.recommendations.map(
(rec, index) => (
<li
key={index}
className="text-sm text-gray-700 bg-gray-50 p-2 rounded-md"
>
{rec}
</li>
)
)}
</ul>
</div>
</div>
{/* Prescription History */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Pill className="h-5 w-5 mr-2 text-yellow-600" />
Riwayat Resep & Pengobatan
</h2>
<div className="space-y-3">
{searchResults.prescriptionHistory.map((prescription, index) => (
<div key={index} className="bg-yellow-50 p-4 rounded-md">
<div className="flex justify-between items-start mb-2">
<span className="font-medium text-yellow-900">
{formatDate(prescription.date)}
</span>
<span className="text-sm text-yellow-700">
by {prescription.doctor}
</span>
</div>
<div className="space-y-1">
{prescription.medications.map((med, medIndex) => (
<div key={medIndex} className="text-sm text-yellow-800">
{med}
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Medical Documents */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<FileText className="h-5 w-5 mr-2 text-purple-600" />
Dokumen Medis Terkait
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{searchResults.medicalDocuments.map((doc) => (
<div key={doc.id} className="bg-purple-50 p-4 rounded-md">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-purple-900">
{doc.type}
</span>
<div className="flex space-x-2">
<button className="text-purple-600 hover:text-purple-800">
<Eye className="h-4 w-4" />
</button>
<button className="text-purple-600 hover:text-purple-800">
<Download className="h-4 w-4" />
</button>
</div>
</div>
<div className="text-sm text-purple-800">{doc.name}</div>
<div className="text-xs text-purple-600 mt-1">
{formatDate(doc.date)}
</div>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="bg-white p-6 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">
Last Updated: {formatDate(searchResults.lastUpdated)}
</p>
<p className="text-sm text-gray-600">
Attending Doctor: {searchResults.attendingDoctor}
</p>
</div>
</div>
</div>
</div>
)}
{/* No Results */}
{!searchResults && !isSearching && (
<div className="bg-white p-12 rounded-lg border border-gray-200 text-center">
<Stethoscope className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Cari Rekam Medis Pasien
</h3>
<p className="text-gray-600">
Masukkan informasi pasien pada form pencarian di atas untuk melihat
rekam medis detail
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,665 @@
import { useState } from "react";
import {
TrendingUp,
FileText,
Stethoscope,
AlertTriangle,
Calendar,
Code,
DollarSign,
Clock,
Search,
Download,
AlertCircle,
} from "lucide-react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { registerLocale, setDefaultLocale } from "react-datepicker";
import { id } from "date-fns/locale";
interface PatientRecord {
mrn: string;
name: string;
birthDate: string;
bpjsNumber: string;
lastVisit: {
date: string;
department: string;
doctor: string;
};
vitalSigns: {
bloodPressure: string;
heartRate: number;
temperature: number;
};
labResults: {
hemoglobin: number;
platelets: number;
unit: string;
};
}
interface CodeMapping {
icd10: {
code: string;
description: string;
};
icd9cm: {
code: string;
description: string;
};
inaCbg: {
code: string;
tariff: number;
roomClass: string;
};
}
interface AIAnalysis {
potentialOverclaim: {
detected: boolean;
reason: string;
suggestion: string;
};
intervalControl: {
daysSinceLastVisit: number;
warning: string;
};
}
interface CostRecommendationResult {
patientRecord: PatientRecord;
codeMapping: CodeMapping;
aiAnalysis: AIAnalysis;
}
// Register Indonesian locale for DatePicker
registerLocale("id", id);
setDefaultLocale("id");
export default function CostRecommendation() {
const [clinicalDiagnosis, setClinicalDiagnosis] = useState("");
const [procedures, setProcedures] = useState("");
const [lastVisitDate, setLastVisitDate] = useState<Date | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<CostRecommendationResult | null>(null);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const calculateDaysBetween = (
lastVisit: Date,
currentDate: Date = new Date()
) => {
const diffTime = Math.abs(currentDate.getTime() - lastVisit.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
// Mock AI analysis based on diagnosis and procedures
const mockAnalyzeCostRecommendation = async (
diagnosis: string,
procedure: string,
lastVisit: Date
): Promise<CostRecommendationResult> => {
const text = `${diagnosis} ${procedure}`.toLowerCase();
// Mock patient record
const patientRecord: PatientRecord = {
mrn: "00012345",
name: "Budi Santoso",
birthDate: "1985-04-12",
bpjsNumber: "000987654321",
lastVisit: {
date: lastVisit.toISOString().split("T")[0],
department: "Poli Penyakit Dalam",
doctor: "dr. Andi",
},
vitalSigns: {
bloodPressure: "110/70",
heartRate: 88,
temperature: 37.8,
},
labResults: {
hemoglobin: 8.5,
platelets: 95000,
unit: "g/dL",
},
};
// Determine ICD codes based on diagnosis
let icd10Code = "R69";
let icd10Description = "Penyakit tidak spesifik";
let icd9cmCode = "99.04";
let icd9cmDescription = "Transfusi packed red cells";
let inaCbgCode = "Z-01-01";
let inaCbgTariff = 2500000;
if (
text.includes("dengue") ||
text.includes("dbd") ||
text.includes("demam berdarah")
) {
icd10Code = "A91";
icd10Description = "Demam Berdarah Dengue tanpa tanda peringatan";
inaCbgCode = "X-01-01";
inaCbgTariff = 5500000;
} else if (text.includes("pneumonia")) {
icd10Code = "J18.9";
icd10Description = "Pneumonia dengan kuman tidak spesifik";
inaCbgCode = "B-4-13-I";
inaCbgTariff = 3500000;
} else if (text.includes("diabetes")) {
icd10Code = "E11.9";
icd10Description = "Diabetes mellitus tipe 2 tanpa komplikasi";
inaCbgCode = "E-4-10-I";
inaCbgTariff = 2100000;
} else if (text.includes("hipertensi") || text.includes("hypertension")) {
icd10Code = "I10";
icd10Description = "Hipertensi esensial (primer)";
inaCbgCode = "F-4-13-II";
inaCbgTariff = 1850000;
}
// Update procedure code based on procedures
if (text.includes("transfusi") || text.includes("transfusion")) {
icd9cmCode = "99.04";
icd9cmDescription = "Transfusi packed red cells";
} else if (text.includes("rontgen") || text.includes("x-ray")) {
icd9cmCode = "87.44";
icd9cmDescription = "Foto Rontgen thorax, proyeksi tunggal";
} else if (text.includes("endoskopi")) {
icd9cmCode = "45.13";
icd9cmDescription = "Esofagogastroduodenoskopi (EGD)";
}
const codeMapping: CodeMapping = {
icd10: {
code: icd10Code,
description: icd10Description,
},
icd9cm: {
code: icd9cmCode,
description: icd9cmDescription,
},
inaCbg: {
code: inaCbgCode,
tariff: inaCbgTariff,
roomClass: "Kelas 2",
},
};
// AI Analysis
const daysSinceLastVisit = calculateDaysBetween(lastVisit);
let overclaimDetected = false;
let overclaimReason = "";
let overclaimSuggestion = "";
// Check for potential overclaim
if (
icd10Code === "A91" &&
text.includes("transfusi") &&
patientRecord.labResults.hemoglobin > 7
) {
overclaimDetected = true;
overclaimReason =
"Diagnosis A91 umumnya tidak memerlukan transfusi darah kecuali Hb < 7 g/dL. Prosedur ini akan menaikkan tarif klaim secara signifikan.";
overclaimSuggestion = "Pastikan ada justifikasi medis di catatan RM.";
}
let intervalWarning = "";
if (daysSinceLastVisit < 30) {
intervalWarning = "Klaim baru mungkin dianggap kontrol.";
}
const aiAnalysis: AIAnalysis = {
potentialOverclaim: {
detected: overclaimDetected,
reason: overclaimReason,
suggestion: overclaimSuggestion,
},
intervalControl: {
daysSinceLastVisit: daysSinceLastVisit,
warning: intervalWarning,
},
};
await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate processing time
return {
patientRecord,
codeMapping,
aiAnalysis,
};
};
const handleAnalyze = async () => {
if (!clinicalDiagnosis.trim() || !procedures.trim() || !lastVisitDate) {
setError("Harap lengkapi semua field yang diperlukan.");
return;
}
setIsLoading(true);
setError(null);
setResult(null);
try {
const analysisResult = await mockAnalyzeCostRecommendation(
clinicalDiagnosis,
procedures,
lastVisitDate
);
setResult(analysisResult);
} catch (e) {
console.error(e);
setError(
"Gagal melakukan analisis rekomendasi biaya. Silakan coba lagi."
);
} finally {
setIsLoading(false);
}
};
const clearForm = () => {
setClinicalDiagnosis("");
setProcedures("");
setLastVisitDate(null);
setResult(null);
setError(null);
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<TrendingUp className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Asisten Rekomendasi Biaya
</h1>
<p className="text-gray-600">
Analisis rekomendasi biaya perawatan dan deteksi potensi overclaim
berdasarkan AI
</p>
</div>
</div>
<div className="flex space-x-3">
<button className="flex items-center space-x-2 bg-white text-blue-600 border border-blue-600 px-4 py-2 rounded-lg hover:bg-blue-50 transition-colors">
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Ekspor Laporan</span>
<span className="sm:hidden">Ekspor</span>
</button>
</div>
</div>
{/* Input Form */}
<div className="bg-white rounded-lg shadow-sm border mb-6">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
<Search className="h-5 w-5 mr-2 text-blue-600" />
Input Data Analisis
</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="text-sm font-medium text-gray-700 mb-1 flex items-center">
<FileText className="h-4 w-4 mr-2 text-blue-600" />
Diagnosis Klinis
</label>
<textarea
rows={4}
value={clinicalDiagnosis}
onChange={(e) => setClinicalDiagnosis(e.target.value)}
placeholder="Contoh: Pasien dengan demam berdarah dengue, Hb 8.5 g/dL, trombosit 95.000/mm³..."
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent p-3"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 mb-1 flex items-center">
<Stethoscope className="h-4 w-4 mr-2 text-green-600" />
Prosedur / Tindakan
</label>
<textarea
rows={3}
value={procedures}
onChange={(e) => setProcedures(e.target.value)}
placeholder="Contoh: Transfusi packed red cells, monitoring vital sign, pemeriksaan lab darah lengkap..."
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent p-3"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 mb-1 flex items-center">
<Calendar className="h-4 w-4 mr-2 text-purple-600" />
Tanggal Kunjungan Terakhir
</label>
<DatePicker
selected={lastVisitDate}
onChange={(date: Date | null) => setLastVisitDate(date)}
dateFormat="dd MMMM yyyy"
showYearDropdown
showMonthDropdown
dropdownMode="select"
maxDate={new Date()}
placeholderText="Pilih tanggal kunjungan terakhir"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
locale="id"
/>
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleAnalyze}
disabled={
isLoading ||
!clinicalDiagnosis.trim() ||
!procedures.trim() ||
!lastVisitDate
}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{isLoading ? (
<svg
className="animate-spin h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<TrendingUp className="h-4 w-4" />
)}
<span>
{isLoading ? "Menganalisis..." : "Analisis Rekomendasi"}
</span>
</button>
<button
onClick={clearForm}
className="flex items-center space-x-2 bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
<span>Bersihkan</span>
</button>
</div>
{error && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3">
{error}
</div>
)}
</div>
</div>
</div>
{/* Results Section */}
{result ? (
<div className="space-y-6">
{/* Patient Record Section */}
<div className="bg-white rounded-lg shadow-sm border">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
🩺 <span className="ml-2">Rekam Medis Pasien</span>
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 lg:gap-6">
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
No. RM:
</span>
<p className="text-gray-900 font-semibold">
{result.patientRecord.mrn}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Nama:
</span>
<p className="text-gray-900 font-semibold">
{result.patientRecord.name}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Lahir:
</span>
<p className="text-gray-900">
{formatDate(result.patientRecord.birthDate)}
</p>
</div>
</div>
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
BPJS:
</span>
<p className="text-gray-900 font-semibold">
{result.patientRecord.bpjsNumber}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Riwayat Kunjungan Terakhir:
</span>
<p className="text-gray-900">
{formatDate(result.patientRecord.lastVisit.date)} (
{result.patientRecord.lastVisit.department},{" "}
{result.patientRecord.lastVisit.doctor})
</p>
</div>
</div>
<div className="space-y-3">
<div>
<span className="text-sm font-medium text-gray-600">
Vital Sign Terakhir:
</span>
<p className="text-gray-900">
TD {result.patientRecord.vitalSigns.bloodPressure}, Nadi{" "}
{result.patientRecord.vitalSigns.heartRate}, Suhu{" "}
{result.patientRecord.vitalSigns.temperature}°C
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">
Hasil Lab Terbaru:
</span>
<p className="text-gray-900">
Hb {result.patientRecord.labResults.hemoglobin}{" "}
{result.patientRecord.labResults.unit}, Trombosit{" "}
{result.patientRecord.labResults.platelets.toLocaleString()}
/mm³
</p>
</div>
</div>
</div>
</div>
</div>
{/* Code Mapping Section */}
<div className="bg-white rounded-lg shadow-sm border">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
📄 <span className="ml-2">Mapping Kode</span>
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center mb-2">
<Code className="h-5 w-5 text-blue-600 mr-2" />
<span className="font-medium text-blue-900">ICD-10:</span>
</div>
<p className="text-blue-800 font-semibold">
{result.codeMapping.icd10.code} (
{result.codeMapping.icd10.description})
</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center mb-2">
<Stethoscope className="h-5 w-5 text-green-600 mr-2" />
<span className="font-medium text-green-900">
ICD-9-CM:
</span>
</div>
<p className="text-green-800 font-semibold">
{result.codeMapping.icd9cm.code} (
{result.codeMapping.icd9cm.description})
</p>
</div>
</div>
<div className="space-y-4">
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center mb-2">
<DollarSign className="h-5 w-5 text-purple-600 mr-2" />
<span className="font-medium text-purple-900">
INA-CBG:
</span>
</div>
<p className="text-purple-800 font-semibold">
{result.codeMapping.inaCbg.code}
</p>
<p className="text-sm text-purple-700 mt-1">
Tarif INA-CBG ({result.codeMapping.inaCbg.roomClass}):{" "}
{formatCurrency(result.codeMapping.inaCbg.tariff)}
</p>
</div>
</div>
</div>
</div>
</div>
{/* AI Analysis Section */}
<div className="bg-white rounded-lg shadow-sm border">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<span className="ml-2">Analisis AI</span>
</h3>
</div>
<div className="p-6">
<div className="space-y-6">
{/* Potential Overclaim Analysis */}
<div>
<h4 className="font-medium text-gray-900 mb-3 flex items-center">
<AlertTriangle className="h-5 w-5 text-orange-600 mr-2" />
Potensi Overclaim:
</h4>
{result.aiAnalysis.potentialOverclaim.detected ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800 mb-2">
{result.aiAnalysis.potentialOverclaim.reason}
</p>
<p className="text-red-700 text-sm">
<strong>Saran:</strong>{" "}
{result.aiAnalysis.potentialOverclaim.suggestion}
</p>
</div>
) : (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-800">
Tidak terdeteksi potensi overclaim pada kasus ini.
</p>
</div>
)}
</div>
{/* Analisis Kontrol Interval */}
<div>
<h4 className="font-medium text-gray-900 mb-3 flex items-center">
<Clock className="h-5 w-5 text-blue-600 mr-2" />
Interval Kontrol:
</h4>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800">
Hari sejak kunjungan terakhir:{" "}
<strong>
{result.aiAnalysis.intervalControl.daysSinceLastVisit}{" "}
hari
</strong>
{result.aiAnalysis.intervalControl.daysSinceLastVisit <
30 && " (< 30 hari)"}
</p>
{result.aiAnalysis.intervalControl.warning && (
<p className="text-blue-700 text-sm mt-2">
<strong>Peringatan:</strong>{" "}
{result.aiAnalysis.intervalControl.warning}
</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
) : !isLoading ? (
<div className="text-center py-12 bg-white rounded-lg shadow-sm border">
<AlertCircle className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Belum ada hasil analisis
</h3>
<p className="mt-1 text-sm text-gray-500">
Isi form di atas dan klik "Analisis Rekomendasi" untuk memulai.
</p>
</div>
) : (
<div className="text-center py-12 bg-white rounded-lg shadow-sm border">
<div className="animate-spin mx-auto h-12 w-12 text-blue-600">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<h3 className="mt-2 text-sm font-medium text-gray-900">
Sedang menganalisis...
</h3>
<p className="mt-1 text-sm text-gray-500">
Mohon tunggu, sistem sedang melakukan analisis AI.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { default as Clinical } from "./Clinical";
export { default as Administrative } from "./Administrative";
export { default as CostRecommendation } from "./CostRecommendation";
export { default as BPJSCodeification } from "./BPJSCodeification";

View File

@@ -0,0 +1,611 @@
import { useState, useMemo, useCallback } from "react";
import {
Shield,
Plus,
Edit,
Trash2,
Search,
Filter,
Users,
XCircle,
Calendar,
Settings,
} from "lucide-react";
import { sampleRoles, MODULES, ACTIONS } from "../../types/roles";
import type { IRole } from "../../types/roles";
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
// Helper function to get proper module display names
const getModuleDisplayName = (module: string) => {
const moduleNames: Record<string, string> = {
[MODULES.DASHBOARD]: "Dashboard",
[MODULES.COST_RECOMMENDATION]: "Rekomendasi Biaya",
[MODULES.INTEGRASI_DATA_BPJS]: "Integrasi Data - BPJS",
[MODULES.INTEGRASI_DATA_MEDICAL_RECORD]: "Integrasi Data - Rekam Medis",
[MODULES.PASIEN_MANAJEMEN]: "Pasien - Manajemen Pasien",
[MODULES.PASIEN_MEDICAL_RECORD]: "Pasien - Rekam Medis",
[MODULES.PASIEN_BPJS_CODE]: "Pasien - Kode BPJS",
[MODULES.USER_MANAGEMENT]: "Administrasi Sistem - Manajemen Pengguna",
[MODULES.ROLE_MANAGEMENT]: "Administrasi Sistem - Manajemen Peran",
};
return moduleNames[module] || module.replace(/_/g, " ");
};
export default function Role() {
// Seed more roles once so pagination appears
const [roles, setRoles] = useState<IRole[]>(() => {
const generated: IRole[] = Array.from({ length: 25 }).map((_, idx) => {
const base = sampleRoles[idx % sampleRoles.length];
const n = idx + 6; // continue after existing 5 sample roles
return {
id: String(n),
name: `Role ${n}`,
description: `Description for role ${n} - ${base.description.slice(
0,
30
)}...`,
permissions: base.permissions.slice(
0,
Math.floor(Math.random() * 8) + 2
), // Random 2-10 permissions
isActive: n % 3 !== 0, // Mix of active/inactive
createdAt: new Date(
Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000
).toISOString(),
updatedAt: new Date(
Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000
).toISOString(),
} as IRole;
});
return [...sampleRoles, ...generated];
});
const [searchInput, setSearchInput] = useState("");
const [selectedRole, setSelectedRole] = useState<IRole | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [statusInput, setStatusInput] = useState("all");
const [permissionFilter] = useState("all");
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedStatus, setAppliedStatus] = useState("all");
const [hasFiltered, setHasFiltered] = useState(false);
const filteredRoles = useMemo(() => {
return roles.filter((role) => {
const matchesSearch = !hasFiltered
? true
: !appliedSearch ||
role.name.toLowerCase().includes(appliedSearch.toLowerCase()) ||
role.description.toLowerCase().includes(appliedSearch.toLowerCase());
const statusToUse = hasFiltered ? appliedStatus : "all";
const matchesStatus =
statusToUse === "all" ||
(statusToUse === "active" && role.isActive) ||
(statusToUse === "inactive" && !role.isActive);
const matchesPermission =
permissionFilter === "all" ||
(permissionFilter === "high" && role.permissions.length >= 8) ||
(permissionFilter === "medium" &&
role.permissions.length >= 4 &&
role.permissions.length <= 7) ||
(permissionFilter === "low" && role.permissions.length <= 3);
return matchesSearch && matchesStatus && matchesPermission;
});
}, [roles, hasFiltered, appliedSearch, appliedStatus, permissionFilter]);
const handleEditRole = useCallback((role: IRole) => {
setSelectedRole(role);
setModalMode("edit");
setIsModalOpen(true);
}, []);
const handleDeleteRole = useCallback((roleId: string) => {
if (confirm("Apakah Anda yakin ingin menghapus peran ini?")) {
setRoles((prev) => prev.filter((role) => role.id !== roleId));
}
}, []);
const handleToggleStatus = useCallback((roleId: string) => {
setRoles((prev) =>
prev.map((role) =>
role.id === roleId ? { ...role, isActive: !role.isActive } : role
)
);
}, []);
const columnHelper = createColumnHelper<IRole>();
const columns: ColumnDef<IRole, unknown>[] = useMemo(
() => [
columnHelper.display({
id: "role",
header: "Peran",
cell: (info) => {
const role = info.row.original;
return (
<div className="flex items-center">
<div className="bg-blue-100 p-2 rounded-lg mr-3">
<Shield className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm font-medium text-gray-900">
{role.name}
</div>
<div className="text-xs text-gray-500">
Dibuat {formatDate(role.createdAt)}
</div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "description",
header: "Deskripsi",
cell: (info) => (
<div className="text-sm text-gray-900 max-w-xs">
<p className="truncate">{info.row.original.description}</p>
</div>
),
}),
columnHelper.display({
id: "permissions",
header: "Izin",
cell: (info) => {
const role = info.row.original;
const permissionLevel =
role.permissions.length >= 8
? "Tinggi"
: role.permissions.length >= 4
? "Sedang"
: "Rendah";
const levelColor =
permissionLevel === "Tinggi"
? "text-red-600 bg-red-100"
: permissionLevel === "Sedang"
? "text-yellow-600 bg-yellow-100"
: "text-green-600 bg-green-100";
return (
<div className="flex items-center space-x-2">
<div className="flex items-center">
<Users className="h-4 w-4 text-gray-400 mr-1" />
<span className="text-sm font-medium text-gray-900">
{role.permissions.length}
</span>
</div>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${levelColor}`}
>
{permissionLevel}
</span>
</div>
);
},
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (info) => {
const role = info.row.original;
return (
<button
onClick={() => handleToggleStatus(role.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
role.isActive
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{role.isActive ? "Active" : "Inactive"}
</button>
);
},
}),
columnHelper.display({
id: "lastUpdated",
header: "Last Updated",
cell: (info) => (
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatDate(info.row.original.updatedAt)}
</div>
),
}),
columnHelper.display({
id: "actions",
header: "Aksi",
cell: (info) => {
const role = info.row.original;
return (
<div className="flex items-center space-x-2">
<button
onClick={() => handleEditRole(role)}
className="text-blue-600 hover:text-blue-900 p-1 hover:bg-blue-50 rounded"
title="Edit Role"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteRole(role.id)}
className="text-red-600 hover:text-red-900 p-1 hover:bg-red-50 rounded"
title="Delete Role"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
},
}),
],
[columnHelper, handleToggleStatus, handleEditRole, handleDeleteRole]
);
const table = useReactTable({
data: filteredRoles,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
enableRowSelection: false,
debugTable: false,
});
const handleCreateRole = () => {
setSelectedRole(null);
setModalMode("create");
setIsModalOpen(true);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
});
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Shield className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Manajemen Peran
</h1>
<p className="text-gray-600">
Kelola peran dan izin untuk sistem rumah sakit
</p>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={handleCreateRole}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4" />
<span>Tambah Peran</span>
</button>
</div>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Cari nama peran atau deskripsi..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent w-80"
/>
</div>
{/* Status */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">Semua Status</option>
<option value="active">Aktif</option>
<option value="inactive">Tidak Aktif</option>
</select>
</div>
{/* Buttons */}
<div className="flex items-center space-x-2">
<button
onClick={() => {
setAppliedSearch(searchInput.trim());
setAppliedStatus(statusInput);
setHasFiltered(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Filter
</button>
<button
onClick={() => {
setSearchInput("");
setStatusInput("all");
setAppliedSearch("");
setAppliedStatus("all");
setHasFiltered(false);
}}
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
Reset
</button>
</div>
</div>
</div>
</div>
{/* Roles Table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">System Roles</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 flex items-center space-x-3">
<span>Page</span>
<input
type="number"
min={1}
max={Math.max(1, table.getPageCount())}
value={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = Number(e.target.value) - 1;
if (!Number.isNaN(page)) table.setPageIndex(page);
}}
className="w-16 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span>of {table.getPageCount() || 1}</span>
</div>
<div className="flex items-center space-x-3">
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
« Pertama
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Sebelum
</button>
<select
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 30, 50].map((ps) => (
<option key={ps} value={ps}>
{ps}/page
</option>
))}
</select>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Selanjutnya
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Terakhir »
</button>
</div>
</div>
</div>
</div>
{/* Empty State */}
{filteredRoles.length === 0 && (
<div className="text-center py-12">
<Shield className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada peran ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Mulai dengan membuat peran baru untuk sistem rumah sakit.
</p>
<div className="mt-6">
<button
onClick={handleCreateRole}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors mx-auto"
>
<Plus className="h-4 w-4" />
<span>Tambah Peran</span>
</button>
</div>
</div>
)}
{/* Role Modal - Create/Edit */}
{isModalOpen && (
<div className="fixed inset-0 bg-gray-200 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-2/3 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">
{modalMode === "create" ? "Tambah Peran Baru" : "Edit Peran"}
</h3>
<button
onClick={() => setIsModalOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nama Peran
</label>
<input
type="text"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Masukkan nama peran"
defaultValue={selectedRole?.name || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
defaultValue={selectedRole?.isActive ? "true" : "false"}
>
<option value="true">Aktif</option>
<option value="false">Tidak Aktif</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Deskripsi
</label>
<textarea
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent h-20"
placeholder="Deskripsi peran"
defaultValue={selectedRole?.description || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-4">
Izin (Operasi CRUD)
</label>
<div className="bg-gray-50 rounded-lg p-4 max-h-96 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{Object.values(MODULES).map((module) => (
<div
key={module}
className="bg-white border rounded-lg p-4 shadow-sm"
>
<div className="flex items-center mb-3">
<div className="bg-blue-100 p-2 rounded-lg mr-3">
<Settings className="h-4 w-4 text-blue-600" />
</div>
<h4 className="font-semibold text-gray-900 text-sm">
{getModuleDisplayName(module)}
</h4>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.values(ACTIONS).map((action) => (
<label
key={`${module}-${action}`}
className="flex items-center p-2 rounded-md hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
defaultChecked={
selectedRole?.permissions.some(
(p) =>
p.module === module &&
p.action === action
) || false
}
/>
<span className="ml-2 text-sm text-gray-700 capitalize font-medium">
{action}
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-3 mt-6 pt-4 border-t">
<button
onClick={() => setIsModalOpen(false)}
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
Batal
</button>
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
{modalMode === "create" ? "Buat Peran" : "Perbarui Peran"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,581 @@
import { useState, useMemo, useCallback } from "react";
import {
Users,
Plus,
Edit,
Trash2,
Search,
Filter,
Mail,
Phone,
XCircle,
Shield,
Calendar,
UserCheck,
} from "lucide-react";
import { sampleUsers, sampleRoles } from "../../types/roles";
import type { IUser } from "../../types/roles";
import type { ColumnDef } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
export default function User() {
// Seed more users once so pagination appears
const [users, setUsers] = useState<IUser[]>(() => {
const generated: IUser[] = Array.from({ length: 30 }).map((_, idx) => {
const base = sampleUsers[idx % sampleUsers.length];
const role = sampleRoles[(idx + 1) % sampleRoles.length];
const n = idx + 6; // continue after existing 5 sample users
return {
id: String(n),
name: `User ${n}`,
email: `user${n}@claimguard.com`,
phone: `+62 81${(200000000 + n).toString()}`,
role,
isActive: n % 2 === 0,
lastLogin: base.lastLogin,
createdAt: base.createdAt,
updatedAt: base.updatedAt,
} as IUser;
});
return [...sampleUsers, ...generated];
});
const [searchInput, setSearchInput] = useState("");
const [selectedUser, setSelectedUser] = useState<IUser | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [statusInput, setStatusInput] = useState("");
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedStatus, setAppliedStatus] = useState("");
const [hasFiltered, setHasFiltered] = useState(false);
const filteredUsers = useMemo(() => {
return users.filter((user) => {
const matchesSearch = !hasFiltered
? true
: !appliedSearch ||
user.name.toLowerCase().includes(appliedSearch.toLowerCase()) ||
user.email.toLowerCase().includes(appliedSearch.toLowerCase()) ||
user.role.name.toLowerCase().includes(appliedSearch.toLowerCase());
const statusToUse = hasFiltered ? appliedStatus : "";
const matchesStatus =
!statusToUse ||
(statusToUse === "active" && user.isActive) ||
(statusToUse === "inactive" && !user.isActive);
return matchesSearch && matchesStatus;
});
}, [users, hasFiltered, appliedSearch, appliedStatus]);
const handleEditUser = useCallback((user: IUser) => {
setSelectedUser(user);
setModalMode("edit");
setIsModalOpen(true);
}, []);
const handleDeleteUser = useCallback((userId: string) => {
if (confirm("Apakah Anda yakin ingin menghapus pengguna ini?")) {
setUsers((prev) => prev.filter((user) => user.id !== userId));
}
}, []);
const handleToggleStatus = useCallback((userId: string) => {
setUsers((prev) =>
prev.map((user) =>
user.id === userId ? { ...user, isActive: !user.isActive } : user
)
);
}, []);
const columnHelper = createColumnHelper<IUser>();
const columns: ColumnDef<IUser, unknown>[] = useMemo(
() => [
columnHelper.display({
id: "user",
header: "Pengguna",
cell: (info) => {
const u = info.row.original;
return (
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<UserCheck className="h-5 w-5 text-gray-600" />
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{u.name}
</div>
<div className="text-sm text-gray-500 flex items-center">
<Mail className="h-3 w-3 mr-1" />
{u.email}
</div>
<div className="text-sm text-gray-500 flex items-center">
<Phone className="h-3 w-3 mr-1" />
{u.phone}
</div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "role",
header: "Peran & Departemen",
cell: (info) => {
const u = info.row.original;
return (
<div className="flex items-center">
<Shield className="h-4 w-4 text-purple-500 mr-2" />
<div>
<div className="text-sm font-medium text-gray-900">
{u.role.name}
</div>
<div className="text-xs text-gray-500">
{u.role.permissions.length} izin
</div>
</div>
</div>
);
},
}),
columnHelper.display({
id: "status",
header: "Status",
cell: (info) => {
const u = info.row.original;
return (
<button
onClick={() => handleToggleStatus(u.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
u.isActive
)}`}
>
{u.isActive ? "Aktif" : "Tidak Aktif"}
</button>
);
},
}),
columnHelper.display({
id: "lastLogin",
header: "Login Terakhir",
cell: (info) => (
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatLastLogin(info.row.original.lastLogin)}
</div>
),
}),
columnHelper.display({
id: "actions",
header: "Aksi",
cell: (info) => {
const u = info.row.original;
return (
<div className="flex items-center space-x-2">
<button
onClick={() => handleEditUser(u)}
className="text-blue-600 hover:text-blue-900 p-1 hover:bg-blue-50 rounded"
title="Edit User"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteUser(u.id)}
className="text-red-600 hover:text-red-900 p-1 hover:bg-red-50 rounded"
title="Delete User"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
},
}),
],
[columnHelper, handleToggleStatus, handleEditUser, handleDeleteUser]
);
const table = useReactTable({
data: filteredUsers,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
enableRowSelection: false,
debugTable: false,
});
const handleCreateUser = () => {
setSelectedUser(null);
setModalMode("create");
setIsModalOpen(true);
};
const getStatusColor = (isActive: boolean) => {
return isActive ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800";
};
const formatLastLogin = (lastLogin?: string) => {
if (!lastLogin) return "Belum pernah login";
const date = new Date(lastLogin);
return date.toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Users className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Manajemen Pengguna
</h1>
<p className="text-gray-600">
Kelola pengguna sistem dan izin akses mereka
</p>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={handleCreateUser}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4" />
<span>Tambah Pengguna</span>
</button>
</div>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div className="flex items-center space-x-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Cari berdasarkan nama, email, atau peran..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent w-80"
/>
</div>
{/* Status */}
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-gray-400" />
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Semua Status</option>
<option value="active">Aktif</option>
<option value="inactive">Tidak Aktif</option>
</select>
</div>
{/* Buttons */}
<div className="flex items-center space-x-2">
<button
onClick={() => {
setAppliedSearch(searchInput.trim());
setAppliedStatus(statusInput);
setHasFiltered(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Filter
</button>
<button
onClick={() => {
setSearchInput("");
setStatusInput("");
setAppliedSearch("");
setAppliedStatus("");
setHasFiltered(false);
}}
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
Reset
</button>
</div>
</div>
</div>
</div>
{/* Users Table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">System Users</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination Controls */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 flex items-center space-x-3">
<span>Page</span>
<input
type="number"
min={1}
max={Math.max(1, table.getPageCount())}
value={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = Number(e.target.value) - 1;
if (!Number.isNaN(page)) table.setPageIndex(page);
}}
className="w-16 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span>of {table.getPageCount() || 1}</span>
</div>
<div className="flex items-center space-x-3">
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
« Pertama
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Sebelum
</button>
<select
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 30, 50].map((ps) => (
<option key={ps} value={ps}>
{ps}/page
</option>
))}
</select>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Selanjutnya
</button>
<button
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Terakhir »
</button>
</div>
</div>
</div>
</div>
{/* Empty State */}
{filteredUsers.length === 0 && (
<div className="text-center py-12">
<Users className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
Tidak ada pengguna ditemukan
</h3>
<p className="mt-1 text-sm text-gray-500">
Mulai dengan menambahkan pengguna baru ke sistem.
</p>
<div className="mt-6">
<button
onClick={handleCreateUser}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors mx-auto"
>
<Plus className="h-4 w-4" />
<span>Tambah Pengguna</span>
</button>
</div>
</div>
)}
{/* User Modal - Create/Edit */}
{isModalOpen && (
<div className="fixed inset-0 bg-gray-200 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">
{modalMode === "create"
? "Tambah Pengguna Baru"
: "Edit Pengguna"}
</h3>
<button
onClick={() => setIsModalOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="h-6 w-6" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nama Lengkap
</label>
<input
type="text"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Masukkan nama lengkap"
defaultValue={selectedUser?.name || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Alamat Email
</label>
<input
type="email"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="user@claimguard.com"
defaultValue={selectedUser?.email || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nomor Telepon
</label>
<input
type="tel"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="+62 812-3456-7890"
defaultValue={selectedUser?.phone || ""}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Peran
</label>
<select
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
defaultValue={selectedUser?.role.id || ""}
>
<option value="">Pilih Peran</option>
{sampleRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
defaultValue={selectedUser?.isActive ? "true" : "false"}
>
<option value="true">Aktif</option>
<option value="false">Tidak Aktif</option>
</select>
</div>
{modalMode === "create" && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kata Sandi
</label>
<input
type="password"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Masukkan kata sandi"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Konfirmasi Kata Sandi
</label>
<input
type="password"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Konfirmasi kata sandi"
/>
</div>
</>
)}
</div>
<div className="flex items-center justify-end space-x-3 mt-6 pt-4 border-t">
<button
onClick={() => setIsModalOpen(false)}
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
>
Batal
</button>
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
{modalMode === "create"
? "Buat Pengguna"
: "Perbarui Pengguna"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { default as User } from "./User";
export { default as Role } from "./Role";

View File

@@ -22,7 +22,6 @@ export interface IUser {
email: string;
phone: string;
role: IRole;
department: string;
isActive: boolean;
lastLogin?: string;
createdAt: string;
@@ -343,7 +342,7 @@ export const sampleUsers: IUser[] = [
email: "admin@claimguard.com",
phone: "+62 812-3456-7890",
role: sampleRoles[0],
department: "IT & Administration",
isActive: true,
lastLogin: "2024-01-15T08:30:00Z",
createdAt: "2024-01-01T00:00:00Z",
@@ -355,7 +354,7 @@ export const sampleUsers: IUser[] = [
email: "siti.admin@claimguard.com",
phone: "+62 813-4567-8901",
role: sampleRoles[1],
department: "Administration",
isActive: true,
lastLogin: "2024-01-15T09:15:00Z",
createdAt: "2024-01-02T00:00:00Z",
@@ -367,7 +366,7 @@ export const sampleUsers: IUser[] = [
email: "ahmad.rizki@claimguard.com",
phone: "+62 814-5678-9012",
role: sampleRoles[2],
department: "Cardiology",
isActive: true,
lastLogin: "2024-01-15T07:45:00Z",
createdAt: "2024-01-03T00:00:00Z",
@@ -379,7 +378,7 @@ export const sampleUsers: IUser[] = [
email: "maria.lopez@claimguard.com",
phone: "+62 815-6789-0123",
role: sampleRoles[3],
department: "Emergency",
isActive: true,
lastLogin: "2024-01-15T06:30:00Z",
createdAt: "2024-01-04T00:00:00Z",
@@ -391,7 +390,7 @@ export const sampleUsers: IUser[] = [
email: "budi.finance@claimguard.com",
phone: "+62 816-7890-1234",
role: sampleRoles[4],
department: "Finance",
isActive: true,
lastLogin: "2024-01-15T08:00:00Z",
createdAt: "2024-01-05T00:00:00Z",