Files
claim-guard-fe/src/pages/BPJSSync.tsx

709 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}