fix revision add datatable and date picker

This commit is contained in:
2025-08-13 20:14:43 +07:00
parent 39af7d1692
commit e1ce7edd9e
8 changed files with 1854 additions and 1091 deletions

159
package-lock.json generated
View File

@@ -8,9 +8,12 @@
"name": "claim-guard-fe", "name": "claim-guard-fe",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-table": "^8.21.3",
"@types/react-datepicker": "^6.2.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-datepicker": "^8.4.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-router-dom": "^6.30.0" "react-router-dom": "^6.30.0"
@@ -937,6 +940,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz",
"integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.3"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1405,6 +1461,39 @@
"win32" "win32"
] ]
}, },
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1468,20 +1557,29 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.23", "version": "18.3.23",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/@types/react-datepicker": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-6.2.0.tgz",
"integrity": "sha512-+JtO4Fm97WLkJTH8j8/v3Ldh7JCNRwjMYjRaKh4KHH0M3jJoXtwiD3JBCsdlg3tsFIw9eQSqyAPeVDN2H2oM9Q==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.2",
"@types/react": "*",
"date-fns": "^3.3.1"
}
},
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "18.3.7", "version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
@@ -2184,9 +2282,18 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -3583,6 +3690,46 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-datepicker": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.4.0.tgz",
"integrity": "sha512-6nPDnj8vektWCIOy9ArS3avus9Ndsyz5XgFCJ7nBxXASSpBdSL6lG9jzNNmViPOAOPh6T5oJyGaXuMirBLECag==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/react-datepicker/node_modules/@floating-ui/react": {
"version": "0.27.15",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.15.tgz",
"integrity": "sha512-0LGxhBi3BB1DwuSNQAmuaSuertFzNAerlMdPbotjTVnvPtdOs7CkrHLaev5NIXemhzDXNC0tFzuseut7cWA5mw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.5",
"@floating-ui/utils": "^0.2.10",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/react-datepicker/node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -4014,6 +4161,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.17", "version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",

View File

@@ -10,12 +10,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "@tanstack/react-table": "^8.21.3",
"react-dom": "^18.3.1", "@types/react-datepicker": "^6.2.0",
"react-router-dom": "^6.30.0", "clsx": "^2.1.1",
"react-hook-form": "^7.54.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"clsx": "^2.1.1" "react": "^18.3.1",
"react-datepicker": "^8.4.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.30.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",

View File

@@ -1,6 +1,5 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { import {
Shield,
Calendar, Calendar,
Filter, Filter,
AlertCircle, AlertCircle,
@@ -10,7 +9,18 @@ import {
Upload, Upload,
Database, Database,
Building2, Building2,
XCircle,
} from "lucide-react"; } 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 { interface BPJSSyncLog {
id: string; id: string;
@@ -35,15 +45,34 @@ interface BPJSSyncStats {
} }
export default function BPJSSync() { 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 [statusInput, setStatusInput] = useState("all");
const [appliedStatus, setAppliedStatus] = useState("all"); const [appliedStatus, setAppliedStatus] = useState("all");
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [startDateInput, setStartDateInput] = useState("");
const [endDateInput, setEndDateInput] = useState(""); const { startDate: defaultStartDate, endDate: defaultEndDate } =
const [appliedStartDate, setAppliedStartDate] = useState<string | null>(null); getDefaultDates();
const [appliedEndDate, setAppliedEndDate] = useState<string | null>(null); const [startDateInput, setStartDateInput] = useState<Date | undefined>(
const [hasDateFiltered, setHasDateFiltered] = useState(false); 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 formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
@@ -56,65 +85,105 @@ export default function BPJSSync() {
}); });
}; };
// Sample BPJS sync logs data // Sample BPJS sync logs data with additional entries for pagination
const [syncLogs] = useState<BPJSSyncLog[]>([ const [syncLogs] = useState<BPJSSyncLog[]>(() => {
{ const baseLogs: BPJSSyncLog[] = [
id: "1", {
timestamp: "2024-01-15T14:30:00Z", id: "1",
type: "sync", timestamp: "2024-01-15T14:30:00Z",
status: "success", type: "sync",
claimsProcessed: 234, status: "success",
claimsSuccess: 230, claimsProcessed: 234,
claimsFailed: 4, claimsSuccess: 230,
source: "BPJS Kesehatan API", claimsFailed: 4,
duration: 32, source: "BPJS Kesehatan API",
}, duration: 32,
{ },
id: "2", {
timestamp: "2024-01-15T10:15:00Z", id: "2",
type: "import", timestamp: "2024-01-15T10:15:00Z",
status: "success", type: "import",
claimsProcessed: 89, status: "success",
claimsSuccess: 89, claimsProcessed: 89,
claimsFailed: 0, claimsSuccess: 89,
source: "Hospital Billing System", claimsFailed: 0,
duration: 15, source: "Hospital Billing System",
}, duration: 15,
{ },
id: "3", {
timestamp: "2024-01-14T16:45:00Z", id: "3",
type: "sync", timestamp: "2024-01-14T16:45:00Z",
status: "failed", type: "sync",
claimsProcessed: 0, status: "failed",
claimsSuccess: 0, claimsProcessed: 0,
claimsFailed: 0, claimsSuccess: 0,
source: "BPJS Kesehatan API", claimsFailed: 0,
duration: 0, source: "BPJS Kesehatan API",
errorMessage: "API rate limit exceeded", duration: 0,
}, errorMessage: "API rate limit exceeded",
{ },
id: "4", {
timestamp: "2024-01-14T09:30:00Z", id: "4",
type: "import", timestamp: "2024-01-14T09:30:00Z",
status: "success", type: "import",
claimsProcessed: 156, status: "success",
claimsSuccess: 150, claimsProcessed: 156,
claimsFailed: 6, claimsSuccess: 150,
source: "External Claims System", claimsFailed: 6,
duration: 28, source: "External Claims System",
}, duration: 28,
{ },
id: "5", {
timestamp: "2024-01-13T13:20:00Z", id: "5",
type: "sync", timestamp: "2024-01-13T13:20:00Z",
status: "in_progress", type: "sync",
claimsProcessed: 45, status: "in_progress",
claimsSuccess: 45, claimsProcessed: 45,
claimsFailed: 0, claimsSuccess: 45,
source: "BPJS Kesehatan API", claimsFailed: 0,
duration: 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 // Calculate statistics
const stats: BPJSSyncStats = { const stats: BPJSSyncStats = {
@@ -134,22 +203,185 @@ export default function BPJSSync() {
}; };
// Filter sync logs based on date and status (status applied via button) // Filter sync logs based on date and status (status applied via button)
const filteredLogs = syncLogs.filter((log) => { const filteredLogs = useMemo(() => {
const matchesStatus = return syncLogs.filter((log) => {
appliedStatus === "all" || log.status === appliedStatus; const matchesStatus =
let matchesDate = true; appliedStatus === "all" || log.status === appliedStatus;
if (hasDateFiltered) {
const ts = new Date(log.timestamp).getTime();
const startOk =
!appliedStartDate ||
ts >= new Date(appliedStartDate + "T00:00:00").getTime();
const endOk =
!appliedEndDate ||
ts <= new Date(appliedEndDate + "T23:59:59").getTime();
matchesDate = startOk && endOk;
}
return matchesStatus && matchesDate; 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 () => { const handleImport = async () => {
@@ -247,15 +479,13 @@ export default function BPJSSync() {
<div className="bg-white p-6 rounded-lg shadow-sm border"> <div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600"> <p className="text-sm font-medium text-gray-600">Total Gagal</p>
Total Claims <p className="text-2xl font-bold text-red-600">
</p> {stats.failedSyncs}
<p className="text-2xl font-bold text-purple-600">
{stats.totalClaimsProcessed.toLocaleString()}
</p> </p>
</div> </div>
<div className="p-3 bg-purple-100 rounded-lg"> <div className="p-3 bg-red-100 rounded-lg">
<Shield className="h-6 w-6 text-purple-600" /> <XCircle className="h-6 w-6 text-red-600" />
</div> </div>
</div> </div>
</div> </div>
@@ -284,18 +514,27 @@ export default function BPJSSync() {
{/* Date range */} {/* Date range */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-400" /> <Calendar className="h-4 w-4 text-gray-400" />
<input <DatePicker
type="date" selected={startDateInput}
value={startDateInput} onChange={(date) => setStartDateInput(date || undefined)}
onChange={(e) => setStartDateInput(e.target.value)} selectsStart
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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> <span className="text-gray-400 text-sm">s/d</span>
<input <DatePicker
type="date" selected={endDateInput}
value={endDateInput} onChange={(date) => setEndDateInput(date || undefined)}
onChange={(e) => setEndDateInput(e.target.value)} selectsEnd
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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> </div>
{/* Status */} {/* Status */}
@@ -316,8 +555,8 @@ export default function BPJSSync() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <button
onClick={() => { onClick={() => {
setAppliedStartDate(startDateInput || null); setAppliedStartDate(startDateInput);
setAppliedEndDate(endDateInput || null); setAppliedEndDate(endDateInput);
setHasDateFiltered(Boolean(startDateInput || endDateInput)); setHasDateFiltered(Boolean(startDateInput || endDateInput));
setAppliedStatus(statusInput); setAppliedStatus(statusInput);
}} }}
@@ -327,11 +566,12 @@ export default function BPJSSync() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
setStartDateInput(""); const { startDate, endDate } = getDefaultDates();
setEndDateInput(""); setStartDateInput(startDate);
setAppliedStartDate(null); setEndDateInput(endDate);
setAppliedEndDate(null); setAppliedStartDate(startDate);
setHasDateFiltered(false); setAppliedEndDate(endDate);
setHasDateFiltered(true);
setStatusInput("all"); setStatusInput("all");
setAppliedStatus("all"); setAppliedStatus("all");
}} }}
@@ -354,139 +594,100 @@ export default function BPJSSync() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> {table.getHeaderGroups().map((headerGroup) => (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <tr key={headerGroup.id}>
Waktu & Type {headerGroup.headers.map((header) => (
</th> <th
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> key={header.id}
Source System className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
</th> >
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> {flexRender(
Status header.column.columnDef.header,
</th> header.getContext()
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> )}
Claims Processed </th>
</th> ))}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </tr>
Success Rate ))}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
</tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{filteredLogs.map((log) => ( {table.getRowModel().rows.map((row) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={row.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap"> {row.getVisibleCells().map((cell) => (
<div> <td key={cell.id} className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-500"> {flexRender(
<Calendar className="h-4 w-4 mr-1" /> cell.column.columnDef.cell,
{formatDate(log.timestamp)} cell.getContext()
</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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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 && ( </td>
<span className="text-red-600 ml-2"> ))}
{log.claimsFailed}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{log.claimsProcessed > 0
? Math.round(
(log.claimsSuccess / log.claimsProcessed) * 100
)
: 0}
%
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{
width:
log.claimsProcessed > 0
? `${
(log.claimsSuccess / log.claimsProcessed) *
100
}%`
: "0%",
}}
></div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.duration > 0 ? `${log.duration}s` : "-"}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </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> </div>
{/* Empty State */} {/* Empty State */}

View File

@@ -15,6 +15,8 @@ import {
Code, Code,
Stethoscope, Stethoscope,
} from "lucide-react"; } from "lucide-react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
interface CostRecommendation { interface CostRecommendation {
id: string; id: string;
@@ -795,9 +797,11 @@ export default function CostRecommendation() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<ICDRecommendation[]>([]); const [results, setResults] = useState<ICDRecommendation[]>([]);
const [visitType, setVisitType] = useState("Kontrol Rutin"); const [visitType, setVisitType] = useState("Kontrol Rutin");
const [lastVisitDate, setLastVisitDate] = useState(""); const [lastVisitDate, setLastVisitDate] = useState<Date | undefined>(
const [currentVisitDate, setCurrentVisitDate] = useState(() => undefined
new Date().toISOString().slice(0, 10) );
const [currentVisitDate, setCurrentVisitDate] = useState<Date | undefined>(
new Date()
); );
type BpjsMapping = { type BpjsMapping = {
@@ -857,12 +861,11 @@ export default function CostRecommendation() {
mapping: BpjsMapping; mapping: BpjsMapping;
}[]; }[];
const getDaysBetween = (a: string, b: string) => { const getDaysBetween = (a: Date | undefined, b: Date | undefined) => {
try { try {
const da = new Date(a + "T00:00:00"); if (!a || !b) return null;
const db = new Date(b + "T00:00:00");
const diff = Math.floor( const diff = Math.floor(
(db.getTime() - da.getTime()) / (1000 * 60 * 60 * 24) (b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)
); );
return isNaN(diff) ? null : Math.max(diff, 0); return isNaN(diff) ? null : Math.max(diff, 0);
} catch { } catch {
@@ -1038,24 +1041,33 @@ export default function CostRecommendation() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Tanggal Kunjungan Terakhir Tanggal Kunjungan Terakhir{" "}
<span className="text-red-500">*</span>
</label> </label>
<input <DatePicker
type="date" selected={lastVisitDate}
value={lastVisitDate} onChange={(date) => setLastVisitDate(date || undefined)}
onChange={(e) => setLastVisitDate(e.target.value)} placeholderText="Pilih tanggal kunjungan terakhir"
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3" className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3"
dateFormat="dd MMM yyyy"
maxDate={new Date()}
required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Tanggal Kunjungan Saat Ini Tanggal Kunjungan Saat Ini{" "}
<span className="text-red-500">*</span>
</label> </label>
<input <DatePicker
type="date" selected={currentVisitDate}
value={currentVisitDate} onChange={(date) => setCurrentVisitDate(date || undefined)}
onChange={(e) => setCurrentVisitDate(e.target.value)} placeholderText="Pilih tanggal kunjungan saat ini"
className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3" className="w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent p-3"
dateFormat="dd MMM yyyy"
maxDate={new Date()}
minDate={lastVisitDate}
required
/> />
</div> </div>
</div> </div>
@@ -1075,7 +1087,12 @@ export default function CostRecommendation() {
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={isLoading || (!diagnosis && !procedure)} disabled={
isLoading ||
(!diagnosis && !procedure) ||
!lastVisitDate ||
!currentVisitDate
}
className="btn-primary flex items-center space-x-2 disabled:opacity-60" className="btn-primary flex items-center space-x-2 disabled:opacity-60"
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />

View File

@@ -1,14 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { import { FileText, Search } from "lucide-react";
FileText,
Search,
Eye,
Calendar,
User,
Stethoscope,
Heart,
Activity,
} from "lucide-react";
interface MedicalRecord { interface MedicalRecord {
id: string; id: string;
@@ -21,8 +12,6 @@ interface MedicalRecord {
icdCode: string; icdCode: string;
treatment: string; treatment: string;
doctor: string; doctor: string;
department: string;
status: "draft" | "completed" | "reviewed";
vital: { vital: {
bloodPressure: string; bloodPressure: string;
heartRate: number; heartRate: number;
@@ -51,8 +40,6 @@ const sampleMedicalRecords: MedicalRecord[] = [
icdCode: "I10", icdCode: "I10",
treatment: "Amlodipine 10mg 1x1, Diet rendah garam", treatment: "Amlodipine 10mg 1x1, Diet rendah garam",
doctor: "Dr. Siti Nurhaliza", doctor: "Dr. Siti Nurhaliza",
department: "Cardiology",
status: "completed",
vital: { vital: {
bloodPressure: "160/100", bloodPressure: "160/100",
heartRate: 88, heartRate: 88,
@@ -71,8 +58,6 @@ const sampleMedicalRecords: MedicalRecord[] = [
icdCode: "K29.0", icdCode: "K29.0",
treatment: "Omeprazole 20mg 2x1, Antasida 3x1", treatment: "Omeprazole 20mg 2x1, Antasida 3x1",
doctor: "Dr. Budi Santoso", doctor: "Dr. Budi Santoso",
department: "Internal Medicine",
status: "reviewed",
vital: { vital: {
bloodPressure: "120/80", bloodPressure: "120/80",
heartRate: 76, heartRate: 76,
@@ -91,8 +76,6 @@ const sampleMedicalRecords: MedicalRecord[] = [
icdCode: "Z34.0", icdCode: "Z34.0",
treatment: "Asam folat 1x1, Vitamin prenatal", treatment: "Asam folat 1x1, Vitamin prenatal",
doctor: "Dr. Ahmad Rizki", doctor: "Dr. Ahmad Rizki",
department: "Obstetrics & Gynecology",
status: "completed",
vital: { vital: {
bloodPressure: "110/70", bloodPressure: "110/70",
heartRate: 82, heartRate: 82,
@@ -103,7 +86,7 @@ const sampleMedicalRecords: MedicalRecord[] = [
{ {
id: "MR004", id: "MR004",
patientId: "P004", patientId: "P004",
patientName: "Joko Widodo", patientName: "Jamal",
patientAge: 56, patientAge: 56,
patientGender: "Laki-laki", patientGender: "Laki-laki",
recordDate: dateDaysAgo(28, 16, 20), recordDate: dateDaysAgo(28, 16, 20),
@@ -111,8 +94,6 @@ const sampleMedicalRecords: MedicalRecord[] = [
icdCode: "E11.9", icdCode: "E11.9",
treatment: "Metformin 500mg 2x1, Diet DM", treatment: "Metformin 500mg 2x1, Diet DM",
doctor: "Dr. Siti Nurhaliza", doctor: "Dr. Siti Nurhaliza",
department: "Endocrinology",
status: "draft",
vital: { vital: {
bloodPressure: "140/90", bloodPressure: "140/90",
heartRate: 92, heartRate: 92,
@@ -123,7 +104,7 @@ const sampleMedicalRecords: MedicalRecord[] = [
{ {
id: "MR005", id: "MR005",
patientId: "P004", patientId: "P004",
patientName: "Joko Widodo", patientName: "Jamal",
patientAge: 56, patientAge: 56,
patientGender: "Laki-laki", patientGender: "Laki-laki",
recordDate: dateDaysAgo(7, 11, 10), recordDate: dateDaysAgo(7, 11, 10),
@@ -131,8 +112,6 @@ const sampleMedicalRecords: MedicalRecord[] = [
icdCode: "E11.9", icdCode: "E11.9",
treatment: "Metformin 850mg 2x1, Edukasi diet & aktivitas", treatment: "Metformin 850mg 2x1, Edukasi diet & aktivitas",
doctor: "Dr. Budi Santoso", doctor: "Dr. Budi Santoso",
department: "Endocrinology",
status: "reviewed",
vital: { vital: {
bloodPressure: "130/85", bloodPressure: "130/85",
heartRate: 78, heartRate: 78,
@@ -149,7 +128,14 @@ export default function MedicalRecord() {
const [appliedName, setAppliedName] = useState(""); const [appliedName, setAppliedName] = useState("");
const [appliedId, setAppliedId] = useState(""); const [appliedId, setAppliedId] = useState("");
const [hasSearched, setHasSearched] = useState(false); const [hasSearched, setHasSearched] = useState(false);
const [detailId, setDetailId] = useState<string | null>(null); 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) // departments removed (unused)
@@ -167,44 +153,28 @@ export default function MedicalRecord() {
setHasSearched(false); setHasSearched(false);
}; };
const filteredRecords = records.filter((record) => { const filteredRecords = (() => {
if (!hasSearched) return [] as MedicalRecord[];
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const inLast30Days = new Date(record.recordDate) >= thirtyDaysAgo; const matches = records.filter((record) => {
if (!hasSearched) return inLast30Days; const inLast30Days = new Date(record.recordDate) >= thirtyDaysAgo;
const matchName = const matchName =
!appliedName || !!appliedName &&
record.patientName.toLowerCase().includes(appliedName.toLowerCase()); record.patientName.toLowerCase().includes(appliedName.toLowerCase());
const matchId = const matchId =
!appliedId || record.patientId.toLowerCase() === appliedId.toLowerCase(); !!appliedId &&
return inLast30Days && matchName && matchId; 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];
})();
const getStatusColor = (status: string) => { // status removed
switch (status) {
case "completed":
return "bg-green-100 text-green-800";
case "reviewed":
return "bg-blue-100 text-blue-800";
case "draft":
return "bg-yellow-100 text-yellow-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getStatusText = (status: string) => {
switch (status) {
case "completed":
return "Selesai";
case "reviewed":
return "Direview";
case "draft":
return "Draft";
default:
return status;
}
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
@@ -276,7 +246,7 @@ export default function MedicalRecord() {
<div className="flex items-end"> <div className="flex items-end">
<button <button
onClick={handleSearch} onClick={handleSearch}
disabled={!nameInput && !idInput} disabled={!nameInput || !idInput}
className="btn-primary flex items-center space-x-2 disabled:opacity-60" className="btn-primary flex items-center space-x-2 disabled:opacity-60"
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
@@ -295,141 +265,21 @@ export default function MedicalRecord() {
</div> </div>
</div> </div>
{/* Medical Records Table */} {/* Results as Card Sections */}
<div className="card"> {hasSearched && (
<div className="overflow-x-auto"> <div className="space-y-6">
<table className="min-w-full divide-y divide-gray-200"> {filteredRecords.length === 0 ? (
<thead className="bg-gray-50"> <div className="text-center py-12">
<tr> <FileText className="mx-auto h-12 w-12 text-gray-400" />
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <h3 className="mt-2 text-sm font-medium text-gray-900">
Pasien Tidak ada medical record ditemukan
</th> </h3>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <p className="mt-1 text-sm text-gray-500">
Diagnosa & ICD Coba gunakan nama/ID pasien yang berbeda.
</th> </p>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Vital Signs
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dokter & Dept
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tanggal
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Detail
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredRecords.map((record) => (
<tr key={record.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{record.patientName}
</div>
<div className="text-sm text-gray-500 flex items-center">
<User className="h-3 w-3 mr-1" />
{record.patientAge} tahun {record.patientGender}
</div>
<div className="text-xs text-gray-400">
ID: {record.patientId}
</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900 mb-1">
{record.diagnosis}
</div>
<div className="text-xs font-mono bg-gray-100 px-2 py-1 rounded w-fit">
{record.icdCode}
</div>
<div className="text-xs text-gray-500 mt-1">
{record.treatment}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="space-y-1 text-xs">
<div className="flex items-center">
<Heart className="h-3 w-3 text-red-500 mr-1" />
<span>{record.vital.bloodPressure} mmHg</span>
</div>
<div className="flex items-center">
<Activity className="h-3 w-3 text-blue-500 mr-1" />
<span>{record.vital.heartRate} bpm</span>
</div>
<div className="flex items-center">
<span className="w-3 h-3 bg-orange-500 rounded-full mr-1"></span>
<span>{record.vital.temperature}°C</span>
</div>
<div className="flex items-center">
<span className="w-3 h-3 bg-purple-500 rounded-full mr-1"></span>
<span>{record.vital.weight} kg</span>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Stethoscope className="h-4 w-4 text-blue-500 mr-2" />
<div>
<div className="text-sm font-medium text-gray-900">
{record.doctor}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-500">
<Calendar className="h-4 w-4 mr-1" />
{formatDate(record.recordDate)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center space-x-2">
<button
onClick={() => setDetailId(record.id)}
className="text-blue-600 hover:text-blue-900"
title="Lihat detail rekam medis"
>
<Eye className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Detail Modal */}
{detailId && (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4">
<div className="bg-white w-full max-w-3xl rounded-lg shadow-lg">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div className="text-lg font-semibold text-gray-900">
Detail Rekam Medis
</div>
<button
onClick={() => setDetailId(null)}
className="text-gray-500 hover:text-gray-700 text-sm"
>
Tutup
</button>
</div> </div>
{(() => { ) : (
const rec = records.find((r) => r.id === detailId); filteredRecords.map((rec) => {
if (!rec)
return (
<div className="p-6 text-sm">Data tidak ditemukan.</div>
);
// Dummy detail menyerupai rekam medis standar RS (hasil olahan LLM)
const details = (() => { const details = (() => {
switch (rec.icdCode) { switch (rec.icdCode) {
case "I10": case "I10":
@@ -508,209 +358,233 @@ export default function MedicalRecord() {
})(); })();
return ( return (
<div className="p-6 text-sm text-gray-800 max-h-[75vh] overflow-y-auto"> <div key={rec.id} className="card">
<div className="space-y-6"> <div className="p-4 md:p-6 text-sm text-gray-800">
<div className="rounded-lg border border-gray-200"> <div className="space-y-4">
<div className="px-4 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-900"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
Identitas Pasien & Kunjungan <div className="rounded-md border border-gray-200">
</div> <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6"> Identitas Pasien
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-gray-500">
Nama / Jenis Kelamin / Umur
</div> </div>
<div className="text-gray-900 font-medium"> <div className="p-3 space-y-1 text-sm">
{rec.patientName} ({rec.patientGender},{" "} <div className="text-gray-900 font-medium">
{rec.patientAge} th) {rec.patientName}
</div> </div>
<div className="text-gray-500"> <div className="text-gray-500">
ID Pasien: {rec.patientId} {rec.patientGender}, {rec.patientAge} th
</div>
<div className="text-gray-500">
ID Pasien: {rec.patientId}
</div>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="rounded-md border border-gray-200">
<div className="text-xs uppercase tracking-wide text-gray-500"> <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Tanggal / Dokter / Dept Kunjungan
</div> </div>
<div className="text-gray-900 font-medium"> <div className="p-3 space-y-1 text-sm">
{formatDate(rec.recordDate)} <div className="text-gray-900 font-medium">
</div> {formatDate(rec.recordDate)}
<div className="text-gray-500"> </div>
{rec.doctor} {rec.department} <div className="text-gray-500">{rec.doctor}</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="rounded-lg border border-gray-200"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="px-4 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-900"> <div className="rounded-md border border-gray-200">
Anamnesis <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-xs uppercase tracking-wide text-gray-500">
Keluhan Utama Keluhan Utama
</div> </div>
<div className="mt-1 text-gray-900"> <div className="p-3 text-sm text-gray-900">
{details.chiefComplaint} {details.chiefComplaint}
</div> </div>
</div> </div>
<div> <div className="rounded-md border border-gray-200">
<div className="text-xs uppercase tracking-wide text-gray-500"> <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Riwayat Penyakit Sekarang Riwayat Penyakit Sekarang
</div> </div>
<div className="mt-1 text-gray-900 leading-relaxed"> <div className="p-3 text-sm text-gray-900 leading-relaxed">
{details.hpi} {details.hpi}
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="rounded-lg border border-gray-200"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="px-4 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-900"> <div className="rounded-md border border-gray-200">
Riwayat / Obat / Alergi <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
</div> Riwayat, Obat & Alergi
<div className="p-4 grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
Riwayat Penyakit Dahulu
</div> </div>
<ul className="list-disc pl-5 space-y-1"> <div className="p-3">
{details.pmh.map((x, i) => ( <div className="border-b border-gray-200 mb-3">
<li key={i}>{x}</li> <nav className="-mb-px flex space-x-4">
))} {(
</ul> [
</div> "riwayat",
<div> "obat",
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1"> "alergi",
Obat ] 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>
<ul className="list-disc pl-5 space-y-1">
{details.meds.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div> </div>
<div> <div className="rounded-md border border-gray-200">
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1"> <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Alergi
</div>
<ul className="list-disc pl-5 space-y-1">
{details.allergies.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200">
<div className="px-4 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-900">
Penunjang
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
Laboratorium Laboratorium
</div> </div>
<ul className="list-disc pl-5 space-y-1"> <div className="p-3">
{details.labs.map((x, i) => ( <ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
<li key={i}>{x}</li> {details.labs.map((x, i) => (
))} <li key={i}>{x}</li>
</ul> ))}
</ul>
</div>
</div> </div>
<div> </div>
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
<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 Imaging
</div> </div>
<ul className="list-disc pl-5 space-y-1"> <div className="p-3">
{details.imaging.map((x, i) => ( <ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
<li key={i}>{x}</li> {details.imaging.map((x, i) => (
))} <li key={i}>{x}</li>
</ul> ))}
</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> </div>
</div>
<div className="rounded-lg border border-gray-200"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="px-4 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-900"> <div className="rounded-md border border-gray-200">
Diagnosis & Tindakan <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
Diagnosa & ICD Diagnosa & ICD
</div> </div>
<div className="text-gray-900">{rec.diagnosis}</div> <div className="p-3 text-sm">
<div className="text-xs font-mono inline-block mt-1 bg-gray-100 px-2 py-1 rounded"> <div className="text-gray-900">
{rec.icdCode} {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> </div>
<div> <div className="rounded-md border border-gray-200">
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1"> <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
Prosedur/Tindakan Prosedur/Tindakan
</div> </div>
<ul className="list-disc pl-5 space-y-1"> <div className="p-3">
{details.procedures.map((x, i) => ( <ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
<li key={i}>{x}</li> {details.procedures.map((x, i) => (
))} <li key={i}>{x}</li>
</ul> ))}
</ul>
</div>
</div> </div>
</div> </div>
</div>
<div className="rounded-lg border border-gray-200"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="px-4 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-900"> <div className="rounded-md border border-gray-200">
Tanda Vital & Rencana <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
</div> Tanda Vital
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6"> </div>
<div> <div className="p-3">
<ul className="mt-1 list-disc pl-5 space-y-1"> <ul className="mt-1 list-disc pl-5 space-y-1 text-sm text-gray-800">
<li>TD: {rec.vital.bloodPressure} mmHg</li> <li>TD: {rec.vital.bloodPressure} mmHg</li>
<li>Nadi: {rec.vital.heartRate} bpm</li> <li>Nadi: {rec.vital.heartRate} bpm</li>
<li>Suhu: {rec.vital.temperature}°C</li> <li>Suhu: {rec.vital.temperature}°C</li>
<li>Berat: {rec.vital.weight} kg</li> <li>Berat: {rec.vital.weight} kg</li>
</ul> </ul>
</div>
</div> </div>
<div> <div className="rounded-md border border-gray-200">
<ul className="list-disc pl-5 space-y-1"> <div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
{details.plan.map((x, i) => ( Rencana
<li key={i}>{x}</li> </div>
))} <div className="p-3">
</ul> <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 className="flex items-center justify-between">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
rec.status
)}`}
>
Status: {getStatusText(rec.status)}
</span>
<div className="text-xs text-gray-600">
Ringkasan Pulang: {details.discharge}
</div>
</div>
</div> </div>
</div> </div>
); );
})()} })
</div> )}
</div>
)}
{/* Empty State */}
{hasSearched && 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">
Mulai dengan menambahkan medical record baru.
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { import {
Database, Database,
RefreshCw, RefreshCw,
@@ -8,12 +8,19 @@ import {
XCircle, XCircle,
Clock, Clock,
AlertCircle, AlertCircle,
FileText,
Activity,
Users,
Building2, Building2,
Upload, Upload,
} from "lucide-react"; } 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 { interface SyncLog {
id: string; id: string;
@@ -44,15 +51,34 @@ interface SyncStats {
} }
export default function MedicalRecordSync() { 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 [statusInput, setStatusInput] = useState("all");
const [appliedStatus, setAppliedStatus] = useState("all"); const [appliedStatus, setAppliedStatus] = useState("all");
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [startDateInput, setStartDateInput] = useState("");
const [endDateInput, setEndDateInput] = useState(""); const { startDate: defaultStartDate, endDate: defaultEndDate } =
const [appliedStartDate, setAppliedStartDate] = useState<string | null>(null); getDefaultDates();
const [appliedEndDate, setAppliedEndDate] = useState<string | null>(null); const [startDateInput, setStartDateInput] = useState<Date | undefined>(
const [hasDateFiltered, setHasDateFiltered] = useState(false); 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 formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
@@ -104,95 +130,140 @@ export default function MedicalRecordSync() {
} }
}; };
// Sample sync logs data // Sample sync logs data with additional entries for pagination
const [syncLogs] = useState<SyncLog[]>([ const [syncLogs] = useState<SyncLog[]>(() => {
{ const baseLogs: SyncLog[] = [
id: "1", {
timestamp: "2024-01-15T14:30:00Z", id: "1",
type: "sync", timestamp: "2024-01-15T14:30:00Z",
status: "success", type: "sync",
recordsProcessed: 1250, status: "success",
recordsSuccess: 1245, recordsProcessed: 1250,
recordsFailed: 5, recordsSuccess: 1245,
source: "Hospital Management System API", recordsFailed: 5,
duration: 45, source: "Hospital Management System API",
details: { duration: 45,
patientsUpdated: 89, details: {
diagnosesAdded: 156, patientsUpdated: 89,
treatmentsAdded: 203, diagnosesAdded: 156,
vitalsUpdated: 797, treatmentsAdded: 203,
vitalsUpdated: 797,
},
}, },
}, {
{ id: "2",
id: "2", timestamp: "2024-01-15T10:15:00Z",
timestamp: "2024-01-15T10:15:00Z", type: "import",
type: "import", status: "success",
status: "success", recordsProcessed: 567,
recordsProcessed: 567, recordsSuccess: 567,
recordsSuccess: 567, recordsFailed: 0,
recordsFailed: 0, source: "External Lab System",
source: "External Lab System", duration: 23,
duration: 23, details: {
details: { patientsUpdated: 0,
patientsUpdated: 0, diagnosesAdded: 234,
diagnosesAdded: 234, treatmentsAdded: 0,
treatmentsAdded: 0, vitalsUpdated: 333,
vitalsUpdated: 333, },
}, },
}, {
{ id: "3",
id: "3", timestamp: "2024-01-14T16:45:00Z",
timestamp: "2024-01-14T16:45:00Z", type: "sync",
type: "sync", status: "failed",
status: "failed", recordsProcessed: 0,
recordsProcessed: 0, recordsSuccess: 0,
recordsSuccess: 0, recordsFailed: 0,
recordsFailed: 0, source: "Radiology System API",
source: "Radiology System API", duration: 0,
duration: 0, errorMessage: "Connection timeout - API endpoint tidak merespons",
errorMessage: "Connection timeout - API endpoint tidak merespons", details: {
details: { patientsUpdated: 0,
patientsUpdated: 0, diagnosesAdded: 0,
diagnosesAdded: 0, treatmentsAdded: 0,
treatmentsAdded: 0, vitalsUpdated: 0,
vitalsUpdated: 0, },
}, },
}, {
{ id: "4",
id: "4", timestamp: "2024-01-14T09:30:00Z",
timestamp: "2024-01-14T09:30:00Z", type: "import",
type: "import", status: "success",
status: "success", recordsProcessed: 834,
recordsProcessed: 834, recordsSuccess: 820,
recordsSuccess: 820, recordsFailed: 14,
recordsFailed: 14, source: "Pharmacy System",
source: "Pharmacy System", duration: 38,
duration: 38, details: {
details: { patientsUpdated: 45,
patientsUpdated: 45, diagnosesAdded: 67,
diagnosesAdded: 67, treatmentsAdded: 567,
treatmentsAdded: 567, vitalsUpdated: 155,
vitalsUpdated: 155, },
}, },
}, {
{ id: "5",
id: "5", timestamp: "2024-01-13T13:20:00Z",
timestamp: "2024-01-13T13:20:00Z", type: "sync",
type: "sync", status: "in_progress",
status: "in_progress", recordsProcessed: 423,
recordsProcessed: 423, recordsSuccess: 423,
recordsSuccess: 423, recordsFailed: 0,
recordsFailed: 0, source: "Emergency System API",
source: "Emergency System API", duration: 0,
duration: 0, details: {
details: { patientsUpdated: 12,
patientsUpdated: 12, diagnosesAdded: 89,
diagnosesAdded: 89, treatmentsAdded: 134,
treatmentsAdded: 134, vitalsUpdated: 188,
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 // Calculate statistics
const stats: SyncStats = { const stats: SyncStats = {
@@ -212,22 +283,171 @@ export default function MedicalRecordSync() {
}; };
// Filter logs based on date and status (status applied via button) // Filter logs based on date and status (status applied via button)
const filteredLogs = syncLogs.filter((log) => { const filteredLogs = useMemo(() => {
const matchesStatus = return syncLogs.filter((log) => {
appliedStatus === "all" || log.status === appliedStatus; const matchesStatus =
let matchesDate = true; appliedStatus === "all" || log.status === appliedStatus;
if (hasDateFiltered) {
const ts = new Date(log.timestamp).getTime();
const startOk =
!appliedStartDate ||
ts >= new Date(appliedStartDate + "T00:00:00").getTime();
const endOk =
!appliedEndDate ||
ts <= new Date(appliedEndDate + "T23:59:59").getTime();
matchesDate = startOk && endOk;
}
return matchesStatus && matchesDate; 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 () => { const handleImport = async () => {
@@ -326,15 +546,13 @@ export default function MedicalRecordSync() {
<div className="bg-white p-6 rounded-lg shadow-sm border"> <div className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600"> <p className="text-sm font-medium text-gray-600">Total Gagal</p>
Total Records <p className="text-2xl font-bold text-red-600">
</p> {stats.failedSyncs}
<p className="text-2xl font-bold text-purple-600">
{stats.totalRecordsProcessed.toLocaleString()}
</p> </p>
</div> </div>
<div className="p-3 bg-purple-100 rounded-lg"> <div className="p-3 bg-red-100 rounded-lg">
<FileText className="h-6 w-6 text-purple-600" /> <XCircle className="h-6 w-6 text-red-600" />
</div> </div>
</div> </div>
</div> </div>
@@ -363,18 +581,27 @@ export default function MedicalRecordSync() {
{/* Date range */} {/* Date range */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-gray-400" /> <Calendar className="h-4 w-4 text-gray-400" />
<input <DatePicker
type="date" selected={startDateInput}
value={startDateInput} onChange={(date) => setStartDateInput(date || undefined)}
onChange={(e) => setStartDateInput(e.target.value)} selectsStart
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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> <span className="text-gray-400 text-sm">s/d</span>
<input <DatePicker
type="date" selected={endDateInput}
value={endDateInput} onChange={(date) => setEndDateInput(date || undefined)}
onChange={(e) => setEndDateInput(e.target.value)} selectsEnd
className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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> </div>
{/* Status */} {/* Status */}
@@ -395,8 +622,8 @@ export default function MedicalRecordSync() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <button
onClick={() => { onClick={() => {
setAppliedStartDate(startDateInput || null); setAppliedStartDate(startDateInput);
setAppliedEndDate(endDateInput || null); setAppliedEndDate(endDateInput);
setHasDateFiltered(Boolean(startDateInput || endDateInput)); setHasDateFiltered(Boolean(startDateInput || endDateInput));
setAppliedStatus(statusInput); setAppliedStatus(statusInput);
}} }}
@@ -406,11 +633,12 @@ export default function MedicalRecordSync() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
setStartDateInput(""); const { startDate, endDate } = getDefaultDates();
setEndDateInput(""); setStartDateInput(startDate);
setAppliedStartDate(null); setEndDateInput(endDate);
setAppliedEndDate(null); setAppliedStartDate(startDate);
setHasDateFiltered(false); setAppliedEndDate(endDate);
setHasDateFiltered(true);
setStatusInput("all"); setStatusInput("all");
setAppliedStatus("all"); setAppliedStatus("all");
}} }}
@@ -433,128 +661,100 @@ export default function MedicalRecordSync() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> {table.getHeaderGroups().map((headerGroup) => (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <tr key={headerGroup.id}>
Waktu & Type {headerGroup.headers.map((header) => (
</th> <th
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> key={header.id}
Source System className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
</th> >
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> {flexRender(
Status header.column.columnDef.header,
</th> header.getContext()
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> )}
Records Processed </th>
</th> ))}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </tr>
Success Rate ))}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
</tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{filteredLogs.map((log) => ( {table.getRowModel().rows.map((row) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={row.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap"> {row.getVisibleCells().map((cell) => (
<div> <td key={cell.id} className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-500"> {flexRender(
<Calendar className="h-4 w-4 mr-1" /> cell.column.columnDef.cell,
{formatDate(log.timestamp)} cell.getContext()
</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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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 && ( </td>
<span className="text-red-600 ml-2"> ))}
{log.recordsFailed}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{log.recordsProcessed > 0
? Math.round(
(log.recordsSuccess / log.recordsProcessed) * 100
)
: 0}
%
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{
width:
log.recordsProcessed > 0
? `${
(log.recordsSuccess /
log.recordsProcessed) *
100
}%`
: "0%",
}}
></div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.duration > 0 ? `${log.duration}s` : "-"}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </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> </div>
{/* Empty State */} {/* Empty State */}

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useMemo, useCallback } from "react";
import { import {
Shield, Shield,
Plus, Plus,
@@ -12,6 +12,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { sampleRoles, MODULES, ACTIONS } from "../types/roles"; import { sampleRoles, MODULES, ACTIONS } from "../types/roles";
import type { IRole } 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 // Helper function to get proper module display names
const getModuleDisplayName = (module: string) => { const getModuleDisplayName = (module: string) => {
@@ -30,7 +38,33 @@ const getModuleDisplayName = (module: string) => {
}; };
export default function RoleManagement() { export default function RoleManagement() {
const [roles, setRoles] = useState<IRole[]>(sampleRoles); // 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 [searchInput, setSearchInput] = useState("");
const [selectedRole, setSelectedRole] = useState<IRole | null>(null); const [selectedRole, setSelectedRole] = useState<IRole | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -41,28 +75,164 @@ export default function RoleManagement() {
const [appliedStatus, setAppliedStatus] = useState("all"); const [appliedStatus, setAppliedStatus] = useState("all");
const [hasFiltered, setHasFiltered] = useState(false); const [hasFiltered, setHasFiltered] = useState(false);
const filteredRoles = roles.filter((role) => { const filteredRoles = useMemo(() => {
const matchesSearch = !hasFiltered return roles.filter((role) => {
? true const matchesSearch = !hasFiltered
: !appliedSearch || ? true
role.name.toLowerCase().includes(appliedSearch.toLowerCase()) || : !appliedSearch ||
role.description.toLowerCase().includes(appliedSearch.toLowerCase()); role.name.toLowerCase().includes(appliedSearch.toLowerCase()) ||
role.description.toLowerCase().includes(appliedSearch.toLowerCase());
const statusToUse = hasFiltered ? appliedStatus : "all"; const statusToUse = hasFiltered ? appliedStatus : "all";
const matchesStatus = const matchesStatus =
statusToUse === "all" || statusToUse === "all" ||
(statusToUse === "active" && role.isActive) || (statusToUse === "active" && role.isActive) ||
(statusToUse === "inactive" && !role.isActive); (statusToUse === "inactive" && !role.isActive);
const matchesPermission = const matchesPermission =
permissionFilter === "all" || permissionFilter === "all" ||
(permissionFilter === "high" && role.permissions.length >= 8) || (permissionFilter === "high" && role.permissions.length >= 8) ||
(permissionFilter === "medium" && (permissionFilter === "medium" &&
role.permissions.length >= 4 && role.permissions.length >= 4 &&
role.permissions.length <= 7) || role.permissions.length <= 7) ||
(permissionFilter === "low" && role.permissions.length <= 3); (permissionFilter === "low" && role.permissions.length <= 3);
return matchesSearch && matchesStatus && matchesPermission; 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 = () => { const handleCreateRole = () => {
@@ -71,26 +241,6 @@ export default function RoleManagement() {
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleEditRole = (role: IRole) => {
setSelectedRole(role);
setModalMode("edit");
setIsModalOpen(true);
};
const handleDeleteRole = (roleId: string) => {
if (confirm("Apakah Anda yakin ingin menghapus role ini?")) {
setRoles(roles.filter((role) => role.id !== roleId));
}
};
const handleToggleStatus = (roleId: string) => {
setRoles(
roles.map((role) =>
role.id === roleId ? { ...role, isActive: !role.isActive } : role
)
);
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString("id-ID", { return date.toLocaleDateString("id-ID", {
@@ -195,97 +345,107 @@ export default function RoleManagement() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> {table.getHeaderGroups().map((headerGroup) => (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <tr key={headerGroup.id}>
Role {headerGroup.headers.map((header) => (
</th> <th
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> key={header.id}
Deskripsi className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
</th> >
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> {flexRender(
Permissions header.column.columnDef.header,
</th> header.getContext()
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> )}
Status </th>
</th> ))}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </tr>
Tanggal Dibuat ))}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Aksi
</th>
</tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{filteredRoles.map((role) => ( {table.getRowModel().rows.map((row) => (
<tr key={role.id} className="hover:bg-gray-50"> <tr key={row.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap"> {row.getVisibleCells().map((cell) => (
<div className="flex items-center"> <td
<div className="bg-purple-100 p-2 rounded-lg mr-3"> key={cell.id}
<Shield className="h-5 w-5 text-purple-600" /> className={`px-6 py-4 whitespace-nowrap ${
</div> cell.column.id === "actions"
<div> ? "text-sm font-medium"
<div className="text-sm font-medium text-gray-900"> : ""
{role.name}
</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">
{role.description}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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"} {flexRender(
</button> cell.column.columnDef.cell,
</td> cell.getContext()
<td className="px-6 py-4 whitespace-nowrap"> )}
<div className="flex items-center text-sm text-gray-500"> </td>
<Calendar className="h-4 w-4 mr-1" /> ))}
{formatDate(role.createdAt)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<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>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </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> </div>
{/* Empty State */} {/* Empty State */}

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useMemo, useCallback } from "react";
import { import {
Users, Users,
Plus, Plus,
@@ -14,9 +14,37 @@ import {
} from "lucide-react"; } from "lucide-react";
import { sampleUsers, sampleRoles } from "../types/roles"; import { sampleUsers, sampleRoles } from "../types/roles";
import type { IUser } 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() { export default function UserManagement() {
const [users, setUsers] = useState<IUser[]>(sampleUsers); // 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 [searchInput, setSearchInput] = useState("");
const [selectedUser, setSelectedUser] = useState<IUser | null>(null); const [selectedUser, setSelectedUser] = useState<IUser | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -26,21 +54,155 @@ export default function UserManagement() {
const [appliedStatus, setAppliedStatus] = useState(""); const [appliedStatus, setAppliedStatus] = useState("");
const [hasFiltered, setHasFiltered] = useState(false); const [hasFiltered, setHasFiltered] = useState(false);
const filteredUsers = users.filter((user) => { const filteredUsers = useMemo(() => {
const matchesSearch = !hasFiltered return users.filter((user) => {
? true const matchesSearch = !hasFiltered
: !appliedSearch || ? true
user.name.toLowerCase().includes(appliedSearch.toLowerCase()) || : !appliedSearch ||
user.email.toLowerCase().includes(appliedSearch.toLowerCase()) || user.name.toLowerCase().includes(appliedSearch.toLowerCase()) ||
user.role.name.toLowerCase().includes(appliedSearch.toLowerCase()); user.email.toLowerCase().includes(appliedSearch.toLowerCase()) ||
user.role.name.toLowerCase().includes(appliedSearch.toLowerCase());
const statusToUse = hasFiltered ? appliedStatus : ""; const statusToUse = hasFiltered ? appliedStatus : "";
const matchesStatus = const matchesStatus =
!statusToUse || !statusToUse ||
(statusToUse === "active" && user.isActive) || (statusToUse === "active" && user.isActive) ||
(statusToUse === "inactive" && !user.isActive); (statusToUse === "inactive" && !user.isActive);
return matchesSearch && matchesStatus; 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 = () => { const handleCreateUser = () => {
@@ -49,26 +211,6 @@ export default function UserManagement() {
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleEditUser = (user: IUser) => {
setSelectedUser(user);
setModalMode("edit");
setIsModalOpen(true);
};
const handleDeleteUser = (userId: string) => {
if (confirm("Apakah Anda yakin ingin menghapus user ini?")) {
setUsers(users.filter((user) => user.id !== userId));
}
};
const handleToggleStatus = (userId: string) => {
setUsers(
users.map((user) =>
user.id === userId ? { ...user, isActive: !user.isActive } : user
)
);
};
const getStatusColor = (isActive: boolean) => { const getStatusColor = (isActive: boolean) => {
return isActive ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"; return isActive ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800";
}; };
@@ -178,94 +320,107 @@ export default function UserManagement() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> {table.getHeaderGroups().map((headerGroup) => (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <tr key={headerGroup.id}>
User {headerGroup.headers.map((header) => (
</th> <th
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> key={header.id}
Role className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
</th> >
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> {flexRender(
Status header.column.columnDef.header,
</th> header.getContext()
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> )}
Login Terakhir </th>
</th> ))}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </tr>
Aksi ))}
</th>
</tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{filteredUsers.map((user) => ( {table.getRowModel().rows.map((row) => (
<tr key={user.id} className="hover:bg-gray-50"> <tr key={row.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap"> {row.getVisibleCells().map((cell) => (
<div className="flex items-center"> <td
<div className="ml-4"> key={cell.id}
<div className="text-sm font-medium text-gray-900"> className={`px-6 py-4 whitespace-nowrap ${
{user.name} cell.column.id === "actions"
</div> ? "text-sm font-medium"
<div className="text-sm text-gray-500 flex items-center"> : ""
<Mail className="h-3 w-3 mr-1" /> }`}
{user.email}
</div>
<div className="text-sm text-gray-500 flex items-center">
<Phone className="h-3 w-3 mr-1" />
{user.phone}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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">
{user.role.name}
</div>
<div className="text-xs text-gray-500">
{user.role.permissions.length} permissions
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleStatus(user.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(
user.isActive
)}`}
> >
{user.isActive ? "Active" : "Inactive"} {flexRender(
</button> cell.column.columnDef.cell,
</td> cell.getContext()
<td className="px-6 py-4 whitespace-nowrap"> )}
<div className="flex items-center text-sm text-gray-500"> </td>
<Calendar className="h-4 w-4 mr-1" /> ))}
{formatLastLogin(user.lastLogin)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center space-x-2">
<button
onClick={() => handleEditUser(user)}
className="text-blue-600 hover:text-blue-900"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteUser(user.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </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> </div>
{/* Empty State */} {/* Empty State */}