fix new schema and new view and new theme blue and white
This commit is contained in:
86
src/App.tsx
86
src/App.tsx
@@ -6,14 +6,21 @@ import {
|
|||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import MedicalRecord from "./pages/MedicalRecord";
|
|
||||||
|
|
||||||
import CostRecommendation from "./pages/CostRecommendation";
|
// Medical Records Components
|
||||||
import BPJSSync from "./pages/BPJSSync";
|
import {
|
||||||
import BPJSCode from "./pages/BPJSCode";
|
Clinical,
|
||||||
import MedicalRecordSync from "./pages/MedicalRecordSync";
|
Administrative,
|
||||||
import UserManagement from "./pages/UserManagement";
|
CostRecommendation,
|
||||||
import RoleManagement from "./pages/RoleManagement";
|
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 NotFound from "./pages/NotFound";
|
||||||
import NotFoundProtected from "./pages/NotFoundProtected";
|
import NotFoundProtected from "./pages/NotFoundProtected";
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
@@ -33,18 +40,67 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<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 />} />
|
{/* Medical Records Routes */}
|
||||||
<Route path="integration/bpjs" element={<BPJSSync />} />
|
<Route path="medical-records/clinical" element={<Clinical />} />
|
||||||
<Route
|
<Route
|
||||||
path="integration/medical-record"
|
path="medical-records/administrative"
|
||||||
element={<MedicalRecordSync />}
|
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 />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
{/* Protected 404 - untuk route di dalam layout sidebar */}
|
{/* Protected 404 - untuk route di dalam layout sidebar */}
|
||||||
<Route path="*" element={<NotFoundProtected />} />
|
<Route path="*" element={<NotFoundProtected />} />
|
||||||
|
|||||||
@@ -33,52 +33,64 @@ export default function Sidebar({
|
|||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
color: "text-blue-600",
|
color: "text-blue-600",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Cost Recommendation",
|
|
||||||
icon: TrendingUp,
|
|
||||||
path: "/cost-recommendation",
|
|
||||||
color: "text-green-600",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Integrasi Data",
|
title: "Integrasi Data",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
color: "text-purple-600",
|
color: "text-blue-600",
|
||||||
submenu: [
|
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,
|
icon: FileText,
|
||||||
path: "/integration/medical-record",
|
path: "/integration/medical-record-sync",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pasien",
|
title: "Rekam Medis",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
color: "text-orange-600",
|
color: "text-blue-600",
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
title: "Medical Record Pasien",
|
title: "Klinis",
|
||||||
icon: FileText,
|
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,
|
icon: UserCog,
|
||||||
color: "text-red-700",
|
color: "text-blue-600",
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
title: "Manajemen User",
|
title: "Pengguna",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
path: "/admin/users",
|
path: "/system-administration/user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Manajemen Role",
|
title: "Peran",
|
||||||
icon: Lock,
|
icon: Lock,
|
||||||
path: "/admin/roles",
|
path: "/system-administration/role",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -127,7 +139,7 @@ export default function Sidebar({
|
|||||||
{onToggleCollapse && (
|
{onToggleCollapse && (
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
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"
|
title="Collapse Sidebar"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4 text-gray-500" />
|
<ChevronLeft className="h-4 w-4 text-gray-500" />
|
||||||
@@ -138,7 +150,7 @@ export default function Sidebar({
|
|||||||
<div className="w-full flex justify-center">
|
<div className="w-full flex justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
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"
|
title="ClaimGuard - Expand Sidebar"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -174,7 +186,7 @@ export default function Sidebar({
|
|||||||
}
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full flex items-center py-2 rounded-lg text-sm font-medium transition-colors",
|
"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"
|
isCollapsed ? "px-2 justify-center" : "px-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -212,8 +224,8 @@ export default function Sidebar({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center px-3 py-2 rounded-lg text-sm transition-colors",
|
"flex items-center px-3 py-2 rounded-lg text-sm transition-colors",
|
||||||
isActive(subItem.path)
|
isActive(subItem.path)
|
||||||
? "bg-green-50 text-green-700 border-r-2 border-green-600"
|
? "bg-blue-50 text-blue-700 border-r-2 border-blue-600"
|
||||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
: "text-gray-600 hover:bg-blue-50 hover:text-blue-700"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<subItem.icon className="h-4 w-4 mr-3" />
|
<subItem.icon className="h-4 w-4 mr-3" />
|
||||||
@@ -230,8 +242,8 @@ export default function Sidebar({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center py-2 rounded-lg text-sm font-medium transition-colors",
|
"flex items-center py-2 rounded-lg text-sm font-medium transition-colors",
|
||||||
isActive(item.path)
|
isActive(item.path)
|
||||||
? "bg-green-50 text-green-700 border-r-2 border-green-600"
|
? "bg-blue-50 text-blue-700 border-r-2 border-blue-600"
|
||||||
: "text-gray-700 hover:bg-gray-50",
|
: "text-gray-700 hover:bg-blue-50 hover:text-blue-700",
|
||||||
isCollapsed ? "px-2 justify-center" : "px-3"
|
isCollapsed ? "px-2 justify-center" : "px-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
675
src/pages/integration/BPJSSyncLogs.tsx
Normal file
675
src/pages/integration/BPJSSyncLogs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
745
src/pages/integration/MedicalRecordSyncLogs.tsx
Normal file
745
src/pages/integration/MedicalRecordSyncLogs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/pages/integration/index.ts
Normal file
2
src/pages/integration/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as BPJSSyncLogs } from "./BPJSSyncLogs";
|
||||||
|
export { default as MedicalRecordSyncLogs } from "./MedicalRecordSyncLogs";
|
||||||
582
src/pages/medical-records/Administrative.tsx
Normal file
582
src/pages/medical-records/Administrative.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
520
src/pages/medical-records/BPJSCodeification.tsx
Normal file
520
src/pages/medical-records/BPJSCodeification.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
984
src/pages/medical-records/Clinical.tsx
Normal file
984
src/pages/medical-records/Clinical.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
665
src/pages/medical-records/CostRecommendation.tsx
Normal file
665
src/pages/medical-records/CostRecommendation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/pages/medical-records/index.ts
Normal file
4
src/pages/medical-records/index.ts
Normal 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";
|
||||||
611
src/pages/system-administration/Role.tsx
Normal file
611
src/pages/system-administration/Role.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
581
src/pages/system-administration/User.tsx
Normal file
581
src/pages/system-administration/User.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/pages/system-administration/index.ts
Normal file
2
src/pages/system-administration/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as User } from "./User";
|
||||||
|
export { default as Role } from "./Role";
|
||||||
@@ -22,7 +22,6 @@ export interface IUser {
|
|||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
role: IRole;
|
role: IRole;
|
||||||
department: string;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
lastLogin?: string;
|
lastLogin?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -343,7 +342,7 @@ export const sampleUsers: IUser[] = [
|
|||||||
email: "admin@claimguard.com",
|
email: "admin@claimguard.com",
|
||||||
phone: "+62 812-3456-7890",
|
phone: "+62 812-3456-7890",
|
||||||
role: sampleRoles[0],
|
role: sampleRoles[0],
|
||||||
department: "IT & Administration",
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
lastLogin: "2024-01-15T08:30:00Z",
|
lastLogin: "2024-01-15T08:30:00Z",
|
||||||
createdAt: "2024-01-01T00:00:00Z",
|
createdAt: "2024-01-01T00:00:00Z",
|
||||||
@@ -355,7 +354,7 @@ export const sampleUsers: IUser[] = [
|
|||||||
email: "siti.admin@claimguard.com",
|
email: "siti.admin@claimguard.com",
|
||||||
phone: "+62 813-4567-8901",
|
phone: "+62 813-4567-8901",
|
||||||
role: sampleRoles[1],
|
role: sampleRoles[1],
|
||||||
department: "Administration",
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
lastLogin: "2024-01-15T09:15:00Z",
|
lastLogin: "2024-01-15T09:15:00Z",
|
||||||
createdAt: "2024-01-02T00:00:00Z",
|
createdAt: "2024-01-02T00:00:00Z",
|
||||||
@@ -367,7 +366,7 @@ export const sampleUsers: IUser[] = [
|
|||||||
email: "ahmad.rizki@claimguard.com",
|
email: "ahmad.rizki@claimguard.com",
|
||||||
phone: "+62 814-5678-9012",
|
phone: "+62 814-5678-9012",
|
||||||
role: sampleRoles[2],
|
role: sampleRoles[2],
|
||||||
department: "Cardiology",
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
lastLogin: "2024-01-15T07:45:00Z",
|
lastLogin: "2024-01-15T07:45:00Z",
|
||||||
createdAt: "2024-01-03T00:00:00Z",
|
createdAt: "2024-01-03T00:00:00Z",
|
||||||
@@ -379,7 +378,7 @@ export const sampleUsers: IUser[] = [
|
|||||||
email: "maria.lopez@claimguard.com",
|
email: "maria.lopez@claimguard.com",
|
||||||
phone: "+62 815-6789-0123",
|
phone: "+62 815-6789-0123",
|
||||||
role: sampleRoles[3],
|
role: sampleRoles[3],
|
||||||
department: "Emergency",
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
lastLogin: "2024-01-15T06:30:00Z",
|
lastLogin: "2024-01-15T06:30:00Z",
|
||||||
createdAt: "2024-01-04T00:00:00Z",
|
createdAt: "2024-01-04T00:00:00Z",
|
||||||
@@ -391,7 +390,7 @@ export const sampleUsers: IUser[] = [
|
|||||||
email: "budi.finance@claimguard.com",
|
email: "budi.finance@claimguard.com",
|
||||||
phone: "+62 816-7890-1234",
|
phone: "+62 816-7890-1234",
|
||||||
role: sampleRoles[4],
|
role: sampleRoles[4],
|
||||||
department: "Finance",
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
lastLogin: "2024-01-15T08:00:00Z",
|
lastLogin: "2024-01-15T08:00:00Z",
|
||||||
createdAt: "2024-01-05T00:00:00Z",
|
createdAt: "2024-01-05T00:00:00Z",
|
||||||
|
|||||||
Reference in New Issue
Block a user