From e1ce7edd9eddbb5a5bb7c2649d7a95a52697d6ef Mon Sep 17 00:00:00 2001 From: arifal Date: Wed, 13 Aug 2025 20:14:43 +0700 Subject: [PATCH] fix revision add datatable and date picker --- package-lock.json | 159 ++++++- package.json | 13 +- src/pages/BPJSSync.tsx | 661 +++++++++++++++++++---------- src/pages/CostRecommendation.tsx | 53 ++- src/pages/MedicalRecord.tsx | 576 ++++++++++--------------- src/pages/MedicalRecordSync.tsx | 692 ++++++++++++++++++++----------- src/pages/RoleManagement.tsx | 406 ++++++++++++------ src/pages/UserManagement.tsx | 385 ++++++++++++----- 8 files changed, 1854 insertions(+), 1091 deletions(-) diff --git a/package-lock.json b/package-lock.json index dfe8e82..f40b95e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "claim-guard-fe", "version": "0.0.0", "dependencies": { + "@tanstack/react-table": "^8.21.3", + "@types/react-datepicker": "^6.2.0", "clsx": "^2.1.1", "lucide-react": "^0.469.0", "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" @@ -937,6 +940,59 @@ "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": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1405,6 +1461,39 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1468,20 +1557,29 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", "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": { "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", @@ -2184,9 +2282,18 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "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": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3583,6 +3690,46 @@ "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": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -4014,6 +4161,12 @@ "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": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", diff --git a/package.json b/package.json index 3624faa..07e09e7 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,15 @@ "preview": "vite preview" }, "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.30.0", - "react-hook-form": "^7.54.2", + "@tanstack/react-table": "^8.21.3", + "@types/react-datepicker": "^6.2.0", + "clsx": "^2.1.1", "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": { "@eslint/js": "^9.33.0", diff --git a/src/pages/BPJSSync.tsx b/src/pages/BPJSSync.tsx index cc43765..5aa5782 100644 --- a/src/pages/BPJSSync.tsx +++ b/src/pages/BPJSSync.tsx @@ -1,6 +1,5 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { - Shield, Calendar, Filter, AlertCircle, @@ -10,7 +9,18 @@ import { Upload, Database, Building2, + XCircle, } from "lucide-react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; interface BPJSSyncLog { id: string; @@ -35,15 +45,34 @@ interface BPJSSyncStats { } export default function BPJSSync() { + // Helper function to get default dates (1 month back) + const getDefaultDates = () => { + const endDate = new Date(); + const startDate = new Date(); + startDate.setMonth(startDate.getMonth() - 1); + return { startDate, endDate }; + }; + const [statusInput, setStatusInput] = useState("all"); const [appliedStatus, setAppliedStatus] = useState("all"); const [isImporting, setIsImporting] = useState(false); const [isSyncing, setIsSyncing] = useState(false); - const [startDateInput, setStartDateInput] = useState(""); - const [endDateInput, setEndDateInput] = useState(""); - const [appliedStartDate, setAppliedStartDate] = useState(null); - const [appliedEndDate, setAppliedEndDate] = useState(null); - const [hasDateFiltered, setHasDateFiltered] = useState(false); + + const { startDate: defaultStartDate, endDate: defaultEndDate } = + getDefaultDates(); + const [startDateInput, setStartDateInput] = useState( + defaultStartDate + ); + const [endDateInput, setEndDateInput] = useState( + defaultEndDate + ); + const [appliedStartDate, setAppliedStartDate] = useState( + defaultStartDate + ); + const [appliedEndDate, setAppliedEndDate] = useState( + defaultEndDate + ); + const [hasDateFiltered, setHasDateFiltered] = useState(true); // Start with default filter applied const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -56,65 +85,105 @@ export default function BPJSSync() { }); }; - // Sample BPJS sync logs data - const [syncLogs] = useState([ - { - id: "1", - timestamp: "2024-01-15T14:30:00Z", - type: "sync", - status: "success", - claimsProcessed: 234, - claimsSuccess: 230, - claimsFailed: 4, - source: "BPJS Kesehatan API", - duration: 32, - }, - { - id: "2", - timestamp: "2024-01-15T10:15:00Z", - type: "import", - status: "success", - claimsProcessed: 89, - claimsSuccess: 89, - claimsFailed: 0, - source: "Hospital Billing System", - duration: 15, - }, - { - id: "3", - timestamp: "2024-01-14T16:45:00Z", - type: "sync", - status: "failed", - claimsProcessed: 0, - claimsSuccess: 0, - claimsFailed: 0, - source: "BPJS Kesehatan API", - duration: 0, - errorMessage: "API rate limit exceeded", - }, - { - id: "4", - timestamp: "2024-01-14T09:30:00Z", - type: "import", - status: "success", - claimsProcessed: 156, - claimsSuccess: 150, - claimsFailed: 6, - source: "External Claims System", - duration: 28, - }, - { - id: "5", - timestamp: "2024-01-13T13:20:00Z", - type: "sync", - status: "in_progress", - claimsProcessed: 45, - claimsSuccess: 45, - claimsFailed: 0, - source: "BPJS Kesehatan API", - duration: 0, - }, - ]); + // Sample BPJS sync logs data with additional entries for pagination + const [syncLogs] = useState(() => { + const baseLogs: BPJSSyncLog[] = [ + { + id: "1", + timestamp: "2024-01-15T14:30:00Z", + type: "sync", + status: "success", + claimsProcessed: 234, + claimsSuccess: 230, + claimsFailed: 4, + source: "BPJS Kesehatan API", + duration: 32, + }, + { + id: "2", + timestamp: "2024-01-15T10:15:00Z", + type: "import", + status: "success", + claimsProcessed: 89, + claimsSuccess: 89, + claimsFailed: 0, + source: "Hospital Billing System", + duration: 15, + }, + { + id: "3", + timestamp: "2024-01-14T16:45:00Z", + type: "sync", + status: "failed", + claimsProcessed: 0, + claimsSuccess: 0, + claimsFailed: 0, + source: "BPJS Kesehatan API", + duration: 0, + errorMessage: "API rate limit exceeded", + }, + { + id: "4", + timestamp: "2024-01-14T09:30:00Z", + type: "import", + status: "success", + claimsProcessed: 156, + claimsSuccess: 150, + claimsFailed: 6, + source: "External Claims System", + duration: 28, + }, + { + id: "5", + timestamp: "2024-01-13T13:20:00Z", + type: "sync", + status: "in_progress", + claimsProcessed: 45, + claimsSuccess: 45, + claimsFailed: 0, + source: "BPJS Kesehatan API", + duration: 0, + }, + ]; + + // Generate additional logs for pagination + const generated: BPJSSyncLog[] = Array.from({ length: 20 }).map( + (_, idx) => { + const n = idx + 6; + const date = new Date(); + date.setDate(date.getDate() - Math.floor(Math.random() * 30)); + date.setHours( + Math.floor(Math.random() * 24), + Math.floor(Math.random() * 60) + ); + + return { + id: String(n), + timestamp: date.toISOString(), + type: Math.random() > 0.5 ? "sync" : "import", + status: + Math.random() > 0.8 + ? "failed" + : Math.random() > 0.1 + ? "success" + : "in_progress", + claimsProcessed: Math.floor(Math.random() * 500) + 50, + claimsSuccess: Math.floor(Math.random() * 450) + 40, + claimsFailed: Math.floor(Math.random() * 20), + source: [ + "BPJS Kesehatan API", + "Hospital Billing System", + "External Claims System", + "Pharmacy System", + ][Math.floor(Math.random() * 4)], + duration: Math.floor(Math.random() * 60) + 10, + ...(Math.random() > 0.9 && { errorMessage: "Connection timeout" }), + } as BPJSSyncLog; + } + ); + + return [...baseLogs, ...generated]; + }); // Calculate statistics const stats: BPJSSyncStats = { @@ -134,22 +203,185 @@ export default function BPJSSync() { }; // Filter sync logs based on date and status (status applied via button) - const filteredLogs = syncLogs.filter((log) => { - const matchesStatus = - appliedStatus === "all" || log.status === appliedStatus; - let matchesDate = true; - 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; - } + const filteredLogs = useMemo(() => { + return syncLogs.filter((log) => { + const matchesStatus = + appliedStatus === "all" || log.status === appliedStatus; - 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(); + const columns: ColumnDef[] = useMemo( + () => [ + columnHelper.display({ + id: "timeAndType", + header: "Waktu & Type", + cell: (info) => { + const log = info.row.original; + return ( +
+
+ + {formatDate(log.timestamp)} +
+
+ + {log.type === "import" ? "Import" : "Sync"} + +
+
+ ); + }, + }), + columnHelper.display({ + id: "source", + header: "Source System", + cell: (info) => { + const log = info.row.original; + return ( +
+ +
+
+ {log.source} +
+ {log.errorMessage && ( +
+ {log.errorMessage} +
+ )} +
+
+ ); + }, + }), + columnHelper.display({ + id: "status", + header: "Status", + cell: (info) => { + const log = info.row.original; + return ( +
+ + {log.status === "success" ? ( + + ) : log.status === "failed" ? ( + + ) : ( + + )} + {log.status === "success" + ? "Berhasil" + : log.status === "failed" + ? "Gagal" + : "Berlangsung"} + +
+ ); + }, + }), + columnHelper.display({ + id: "claimsProcessed", + header: "Claims Processed", + cell: (info) => { + const log = info.row.original; + return ( +
+
+ {log.claimsProcessed.toLocaleString()} +
+
+ {log.claimsSuccess > 0 && ( + ✓ {log.claimsSuccess} + )} + {log.claimsFailed > 0 && ( + + ✗ {log.claimsFailed} + + )} +
+
+ ); + }, + }), + 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 ( +
+
{successRate}%
+
+
+
+
+ ); + }, + }), + columnHelper.display({ + id: "duration", + header: "Duration", + cell: (info) => { + const log = info.row.original; + return ( +
+ {log.duration > 0 ? `${log.duration}s` : "-"} +
+ ); + }, + }), + ], + [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 () => { @@ -247,15 +479,13 @@ export default function BPJSSync() {
-

- Total Claims -

-

- {stats.totalClaimsProcessed.toLocaleString()} +

Total Gagal

+

+ {stats.failedSyncs}

-
- +
+
@@ -284,18 +514,27 @@ export default function BPJSSync() { {/* Date range */}
- setStartDateInput(e.target.value)} - className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + setStartDateInput(date || undefined)} + selectsStart + startDate={startDateInput} + endDate={endDateInput} + placeholderText="Start Date" + className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-36" + dateFormat="dd MMM yyyy" /> s/d - setEndDateInput(e.target.value)} - className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + setEndDateInput(date || undefined)} + selectsEnd + startDate={startDateInput} + endDate={endDateInput} + minDate={startDateInput} + placeholderText="End Date" + className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-36" + dateFormat="dd MMM yyyy" />
{/* Status */} @@ -316,8 +555,8 @@ export default function BPJSSync() {
+ + + + +
+
+ {/* Empty State */} diff --git a/src/pages/CostRecommendation.tsx b/src/pages/CostRecommendation.tsx index 65e6c90..844eeb4 100644 --- a/src/pages/CostRecommendation.tsx +++ b/src/pages/CostRecommendation.tsx @@ -15,6 +15,8 @@ import { Code, Stethoscope, } from "lucide-react"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; interface CostRecommendation { id: string; @@ -795,9 +797,11 @@ export default function CostRecommendation() { const [error, setError] = useState(null); const [results, setResults] = useState([]); const [visitType, setVisitType] = useState("Kontrol Rutin"); - const [lastVisitDate, setLastVisitDate] = useState(""); - const [currentVisitDate, setCurrentVisitDate] = useState(() => - new Date().toISOString().slice(0, 10) + const [lastVisitDate, setLastVisitDate] = useState( + undefined + ); + const [currentVisitDate, setCurrentVisitDate] = useState( + new Date() ); type BpjsMapping = { @@ -857,12 +861,11 @@ export default function CostRecommendation() { mapping: BpjsMapping; }[]; - const getDaysBetween = (a: string, b: string) => { + const getDaysBetween = (a: Date | undefined, b: Date | undefined) => { try { - const da = new Date(a + "T00:00:00"); - const db = new Date(b + "T00:00:00"); + if (!a || !b) return null; 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); } catch { @@ -1038,24 +1041,33 @@ export default function CostRecommendation() {
- setLastVisitDate(e.target.value)} + setLastVisitDate(date || undefined)} + 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" + dateFormat="dd MMM yyyy" + maxDate={new Date()} + required />
- setCurrentVisitDate(e.target.value)} + setCurrentVisitDate(date || undefined)} + 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" + dateFormat="dd MMM yyyy" + maxDate={new Date()} + minDate={lastVisitDate} + required />
@@ -1075,7 +1087,12 @@ export default function CostRecommendation() {
- {/* Medical Records Table */} -
-
- - - - - - - - - - - - - {filteredRecords.map((record) => ( - - - - - - - - - ))} - -
- Pasien - - Diagnosa & ICD - - Vital Signs - - Dokter & Dept - - Tanggal - - Detail -
-
-
-
- {record.patientName} -
-
- - {record.patientAge} tahun • {record.patientGender} -
-
- ID: {record.patientId} -
-
-
-
-
-
- {record.diagnosis} -
-
- {record.icdCode} -
-
- {record.treatment} -
-
-
-
-
- - {record.vital.bloodPressure} mmHg -
-
- - {record.vital.heartRate} bpm -
-
- - {record.vital.temperature}°C -
-
- - {record.vital.weight} kg -
-
-
-
- -
-
- {record.doctor} -
-
-
-
-
- - {formatDate(record.recordDate)} -
-
-
- -
-
-
-
- - {/* Detail Modal */} - {detailId && ( -
-
-
-
- Detail Rekam Medis -
- + {/* Results as Card Sections */} + {hasSearched && ( +
+ {filteredRecords.length === 0 ? ( +
+ +

+ Tidak ada medical record ditemukan +

+

+ Coba gunakan nama/ID pasien yang berbeda. +

- {(() => { - const rec = records.find((r) => r.id === detailId); - if (!rec) - return ( -
Data tidak ditemukan.
- ); - - // Dummy detail menyerupai rekam medis standar RS (hasil olahan LLM) + ) : ( + filteredRecords.map((rec) => { const details = (() => { switch (rec.icdCode) { case "I10": @@ -508,209 +358,233 @@ export default function MedicalRecord() { })(); return ( -
-
-
-
- Identitas Pasien & Kunjungan -
-
-
-
- Nama / Jenis Kelamin / Umur +
+
+
+
+
+
+ Identitas Pasien
-
- {rec.patientName} ({rec.patientGender},{" "} - {rec.patientAge} th) -
-
- ID Pasien: {rec.patientId} +
+
+ {rec.patientName} +
+
+ {rec.patientGender}, {rec.patientAge} th +
+
+ ID Pasien: {rec.patientId} +
-
-
- Tanggal / Dokter / Dept +
+
+ Kunjungan
-
- {formatDate(rec.recordDate)} -
-
- {rec.doctor} • {rec.department} +
+
+ {formatDate(rec.recordDate)} +
+
{rec.doctor}
-
-
-
- Anamnesis -
-
-
-
+
+
+
Keluhan Utama
-
+
{details.chiefComplaint}
-
-
+
+
Riwayat Penyakit Sekarang
-
+
{details.hpi}
-
-
-
- Riwayat / Obat / Alergi -
-
-
-
- Riwayat Penyakit Dahulu +
+
+
+ Riwayat, Obat & Alergi
-
    - {details.pmh.map((x, i) => ( -
  • {x}
  • - ))} -
-
-
-
- Obat +
+
+ +
+
+ {getActiveSection(rec.id) === "riwayat" && ( +
+
+ Riwayat Penyakit Dahulu +
+
    + {details.pmh.map((x, i) => ( +
  • {x}
  • + ))} +
+
+ )} + {getActiveSection(rec.id) === "obat" && ( +
+
+ Daftar Obat Saat Ini +
+
    + {details.meds.map((x, i) => ( +
  • {x}
  • + ))} +
+
+ )} + {getActiveSection(rec.id) === "alergi" && ( +
+
+ Alergi +
+
    + {details.allergies.map((x, i) => ( +
  • {x}
  • + ))} +
+
+ )} +
-
    - {details.meds.map((x, i) => ( -
  • {x}
  • - ))} -
-
-
- Alergi -
-
    - {details.allergies.map((x, i) => ( -
  • {x}
  • - ))} -
-
-
-
- -
-
- Penunjang -
-
-
-
+
+
Laboratorium
-
    - {details.labs.map((x, i) => ( -
  • {x}
  • - ))} -
+
+
    + {details.labs.map((x, i) => ( +
  • {x}
  • + ))} +
+
-
-
+
+ +
+
+
Imaging
-
    - {details.imaging.map((x, i) => ( -
  • {x}
  • - ))} -
+
+
    + {details.imaging.map((x, i) => ( +
  • {x}
  • + ))} +
+
+
+
+
+ Ringkasan Pulang +
+
+ {details.discharge} +
-
-
-
- Diagnosis & Tindakan -
-
-
-
+
+
+
Diagnosa & ICD
-
{rec.diagnosis}
-
- {rec.icdCode} +
+
+ {rec.diagnosis} +
+
+ {rec.icdCode} +
-
-
+
+
Prosedur/Tindakan
-
    - {details.procedures.map((x, i) => ( -
  • {x}
  • - ))} -
+
+
    + {details.procedures.map((x, i) => ( +
  • {x}
  • + ))} +
+
-
-
-
- Tanda Vital & Rencana -
-
-
-
    -
  • TD: {rec.vital.bloodPressure} mmHg
  • -
  • Nadi: {rec.vital.heartRate} bpm
  • -
  • Suhu: {rec.vital.temperature}°C
  • -
  • Berat: {rec.vital.weight} kg
  • -
+
+
+
+ Tanda Vital +
+
+
    +
  • TD: {rec.vital.bloodPressure} mmHg
  • +
  • Nadi: {rec.vital.heartRate} bpm
  • +
  • Suhu: {rec.vital.temperature}°C
  • +
  • Berat: {rec.vital.weight} kg
  • +
+
-
-
    - {details.plan.map((x, i) => ( -
  • {x}
  • - ))} -
+
+
+ Rencana +
+
+
    + {details.plan.map((x, i) => ( +
  • {x}
  • + ))} +
+
- -
- - Status: {getStatusText(rec.status)} - -
- Ringkasan Pulang: {details.discharge} -
-
); - })()} -
-
- )} - - {/* Empty State */} - {hasSearched && filteredRecords.length === 0 && ( -
- -

- Tidak ada medical record ditemukan -

-

- Mulai dengan menambahkan medical record baru. -

+ }) + )}
)}
diff --git a/src/pages/MedicalRecordSync.tsx b/src/pages/MedicalRecordSync.tsx index eadeb4b..49d487f 100644 --- a/src/pages/MedicalRecordSync.tsx +++ b/src/pages/MedicalRecordSync.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Database, RefreshCw, @@ -8,12 +8,19 @@ import { XCircle, Clock, AlertCircle, - FileText, - Activity, - Users, Building2, Upload, } from "lucide-react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; interface SyncLog { id: string; @@ -44,15 +51,34 @@ interface SyncStats { } export default function MedicalRecordSync() { + // Helper function to get default dates (1 month back) + const getDefaultDates = () => { + const endDate = new Date(); + const startDate = new Date(); + startDate.setMonth(startDate.getMonth() - 1); + return { startDate, endDate }; + }; + const [statusInput, setStatusInput] = useState("all"); const [appliedStatus, setAppliedStatus] = useState("all"); const [isImporting, setIsImporting] = useState(false); const [isSyncing, setIsSyncing] = useState(false); - const [startDateInput, setStartDateInput] = useState(""); - const [endDateInput, setEndDateInput] = useState(""); - const [appliedStartDate, setAppliedStartDate] = useState(null); - const [appliedEndDate, setAppliedEndDate] = useState(null); - const [hasDateFiltered, setHasDateFiltered] = useState(false); + + const { startDate: defaultStartDate, endDate: defaultEndDate } = + getDefaultDates(); + const [startDateInput, setStartDateInput] = useState( + defaultStartDate + ); + const [endDateInput, setEndDateInput] = useState( + defaultEndDate + ); + const [appliedStartDate, setAppliedStartDate] = useState( + defaultStartDate + ); + const [appliedEndDate, setAppliedEndDate] = useState( + defaultEndDate + ); + const [hasDateFiltered, setHasDateFiltered] = useState(true); // Start with default filter applied const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -104,95 +130,140 @@ export default function MedicalRecordSync() { } }; - // Sample sync logs data - const [syncLogs] = useState([ - { - id: "1", - timestamp: "2024-01-15T14:30:00Z", - type: "sync", - status: "success", - recordsProcessed: 1250, - recordsSuccess: 1245, - recordsFailed: 5, - source: "Hospital Management System API", - duration: 45, - details: { - patientsUpdated: 89, - diagnosesAdded: 156, - treatmentsAdded: 203, - vitalsUpdated: 797, + // Sample sync logs data with additional entries for pagination + const [syncLogs] = useState(() => { + const baseLogs: SyncLog[] = [ + { + id: "1", + timestamp: "2024-01-15T14:30:00Z", + type: "sync", + status: "success", + recordsProcessed: 1250, + recordsSuccess: 1245, + recordsFailed: 5, + source: "Hospital Management System API", + duration: 45, + details: { + patientsUpdated: 89, + diagnosesAdded: 156, + treatmentsAdded: 203, + vitalsUpdated: 797, + }, }, - }, - { - id: "2", - timestamp: "2024-01-15T10:15:00Z", - type: "import", - status: "success", - recordsProcessed: 567, - recordsSuccess: 567, - recordsFailed: 0, - source: "External Lab System", - duration: 23, - details: { - patientsUpdated: 0, - diagnosesAdded: 234, - treatmentsAdded: 0, - vitalsUpdated: 333, + { + id: "2", + timestamp: "2024-01-15T10:15:00Z", + type: "import", + status: "success", + recordsProcessed: 567, + recordsSuccess: 567, + recordsFailed: 0, + source: "External Lab System", + duration: 23, + details: { + patientsUpdated: 0, + diagnosesAdded: 234, + treatmentsAdded: 0, + vitalsUpdated: 333, + }, }, - }, - { - id: "3", - timestamp: "2024-01-14T16:45:00Z", - type: "sync", - status: "failed", - recordsProcessed: 0, - recordsSuccess: 0, - recordsFailed: 0, - source: "Radiology System API", - duration: 0, - errorMessage: "Connection timeout - API endpoint tidak merespons", - details: { - patientsUpdated: 0, - diagnosesAdded: 0, - treatmentsAdded: 0, - vitalsUpdated: 0, + { + id: "3", + timestamp: "2024-01-14T16:45:00Z", + type: "sync", + status: "failed", + recordsProcessed: 0, + recordsSuccess: 0, + recordsFailed: 0, + source: "Radiology System API", + duration: 0, + errorMessage: "Connection timeout - API endpoint tidak merespons", + details: { + patientsUpdated: 0, + diagnosesAdded: 0, + treatmentsAdded: 0, + vitalsUpdated: 0, + }, }, - }, - { - id: "4", - timestamp: "2024-01-14T09:30:00Z", - type: "import", - status: "success", - recordsProcessed: 834, - recordsSuccess: 820, - recordsFailed: 14, - source: "Pharmacy System", - duration: 38, - details: { - patientsUpdated: 45, - diagnosesAdded: 67, - treatmentsAdded: 567, - vitalsUpdated: 155, + { + id: "4", + timestamp: "2024-01-14T09:30:00Z", + type: "import", + status: "success", + recordsProcessed: 834, + recordsSuccess: 820, + recordsFailed: 14, + source: "Pharmacy System", + duration: 38, + details: { + patientsUpdated: 45, + diagnosesAdded: 67, + treatmentsAdded: 567, + vitalsUpdated: 155, + }, }, - }, - { - id: "5", - timestamp: "2024-01-13T13:20:00Z", - type: "sync", - status: "in_progress", - recordsProcessed: 423, - recordsSuccess: 423, - recordsFailed: 0, - source: "Emergency System API", - duration: 0, - details: { - patientsUpdated: 12, - diagnosesAdded: 89, - treatmentsAdded: 134, - vitalsUpdated: 188, + { + id: "5", + timestamp: "2024-01-13T13:20:00Z", + type: "sync", + status: "in_progress", + recordsProcessed: 423, + recordsSuccess: 423, + recordsFailed: 0, + source: "Emergency System API", + duration: 0, + details: { + patientsUpdated: 12, + diagnosesAdded: 89, + treatmentsAdded: 134, + vitalsUpdated: 188, + }, }, - }, - ]); + ]; + + // Generate additional logs for pagination + const generated: SyncLog[] = Array.from({ length: 20 }).map((_, idx) => { + const n = idx + 6; + const date = new Date(); + date.setDate(date.getDate() - Math.floor(Math.random() * 30)); + date.setHours( + Math.floor(Math.random() * 24), + Math.floor(Math.random() * 60) + ); + + return { + id: String(n), + timestamp: date.toISOString(), + type: Math.random() > 0.5 ? "sync" : "import", + status: + Math.random() > 0.8 + ? "failed" + : Math.random() > 0.1 + ? "success" + : "in_progress", + recordsProcessed: Math.floor(Math.random() * 800) + 100, + recordsSuccess: Math.floor(Math.random() * 750) + 90, + recordsFailed: Math.floor(Math.random() * 30), + source: [ + "Hospital Management System API", + "External Lab System", + "Radiology System API", + "Pharmacy System", + "Emergency System API", + ][Math.floor(Math.random() * 5)], + duration: Math.floor(Math.random() * 80) + 15, + details: { + patientsUpdated: Math.floor(Math.random() * 100), + diagnosesAdded: Math.floor(Math.random() * 200), + treatmentsAdded: Math.floor(Math.random() * 300), + vitalsUpdated: Math.floor(Math.random() * 500), + }, + ...(Math.random() > 0.9 && { errorMessage: "API connection failed" }), + } as SyncLog; + }); + + return [...baseLogs, ...generated]; + }); // Calculate statistics const stats: SyncStats = { @@ -212,22 +283,171 @@ export default function MedicalRecordSync() { }; // Filter logs based on date and status (status applied via button) - const filteredLogs = syncLogs.filter((log) => { - const matchesStatus = - appliedStatus === "all" || log.status === appliedStatus; - let matchesDate = true; - 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; - } + const filteredLogs = useMemo(() => { + return syncLogs.filter((log) => { + const matchesStatus = + appliedStatus === "all" || log.status === appliedStatus; - 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(); + const columns: ColumnDef[] = useMemo( + () => [ + columnHelper.display({ + id: "timeAndType", + header: "Waktu & Type", + cell: (info) => { + const log = info.row.original; + return ( +
+
+ + {formatDate(log.timestamp)} +
+
+ + {log.type === "import" ? "Import" : "Sync"} + +
+
+ ); + }, + }), + columnHelper.display({ + id: "source", + header: "Source System", + cell: (info) => { + const log = info.row.original; + return ( +
+ +
+
+ {log.source} +
+ {log.errorMessage && ( +
+ {log.errorMessage} +
+ )} +
+
+ ); + }, + }), + columnHelper.display({ + id: "status", + header: "Status", + cell: (info) => { + const log = info.row.original; + return ( +
+ + {getStatusIcon(log.status)} + {getStatusText(log.status)} + +
+ ); + }, + }), + columnHelper.display({ + id: "recordsProcessed", + header: "Records Processed", + cell: (info) => { + const log = info.row.original; + return ( +
+
+ {log.recordsProcessed.toLocaleString()} +
+
+ {log.recordsSuccess > 0 && ( + ✓ {log.recordsSuccess} + )} + {log.recordsFailed > 0 && ( + + ✗ {log.recordsFailed} + + )} +
+
+ ); + }, + }), + 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 ( +
+
{successRate}%
+
+
+
+
+ ); + }, + }), + columnHelper.display({ + id: "duration", + header: "Duration", + cell: (info) => { + const log = info.row.original; + return ( +
+ {log.duration > 0 ? `${log.duration}s` : "-"} +
+ ); + }, + }), + ], + [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 () => { @@ -326,15 +546,13 @@ export default function MedicalRecordSync() {
-

- Total Records -

-

- {stats.totalRecordsProcessed.toLocaleString()} +

Total Gagal

+

+ {stats.failedSyncs}

-
- +
+
@@ -363,18 +581,27 @@ export default function MedicalRecordSync() { {/* Date range */}
- setStartDateInput(e.target.value)} - className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + setStartDateInput(date || undefined)} + selectsStart + startDate={startDateInput} + endDate={endDateInput} + placeholderText="Start Date" + className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-36" + dateFormat="dd MMM yyyy" /> s/d - setEndDateInput(e.target.value)} - className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + setEndDateInput(date || undefined)} + selectsEnd + startDate={startDateInput} + endDate={endDateInput} + minDate={startDateInput} + placeholderText="End Date" + className="border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent w-36" + dateFormat="dd MMM yyyy" />
{/* Status */} @@ -395,8 +622,8 @@ export default function MedicalRecordSync() {
+ + + + +
+
+
{/* Empty State */} diff --git a/src/pages/RoleManagement.tsx b/src/pages/RoleManagement.tsx index 4a9eb2a..bae247f 100644 --- a/src/pages/RoleManagement.tsx +++ b/src/pages/RoleManagement.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo, useCallback } from "react"; import { Shield, Plus, @@ -12,6 +12,14 @@ import { } from "lucide-react"; import { sampleRoles, MODULES, ACTIONS } from "../types/roles"; import type { IRole } from "../types/roles"; +import type { ColumnDef } from "@tanstack/react-table"; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; // Helper function to get proper module display names const getModuleDisplayName = (module: string) => { @@ -30,7 +38,33 @@ const getModuleDisplayName = (module: string) => { }; export default function RoleManagement() { - const [roles, setRoles] = useState(sampleRoles); + // Seed more roles once so pagination appears + const [roles, setRoles] = useState(() => { + const generated: IRole[] = Array.from({ length: 25 }).map((_, idx) => { + const base = sampleRoles[idx % sampleRoles.length]; + const n = idx + 6; // continue after existing 5 sample roles + return { + id: String(n), + name: `Role ${n}`, + description: `Deskripsi untuk role ${n} - ${base.description.slice( + 0, + 30 + )}...`, + permissions: base.permissions.slice( + 0, + Math.floor(Math.random() * 8) + 2 + ), // Random 2-10 permissions + isActive: n % 3 !== 0, // Mix of active/inactive + createdAt: new Date( + Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000 + ).toISOString(), + updatedAt: new Date( + Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000 + ).toISOString(), + } as IRole; + }); + return [...sampleRoles, ...generated]; + }); const [searchInput, setSearchInput] = useState(""); const [selectedRole, setSelectedRole] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); @@ -41,28 +75,164 @@ export default function RoleManagement() { const [appliedStatus, setAppliedStatus] = useState("all"); const [hasFiltered, setHasFiltered] = useState(false); - const filteredRoles = roles.filter((role) => { - const matchesSearch = !hasFiltered - ? true - : !appliedSearch || - role.name.toLowerCase().includes(appliedSearch.toLowerCase()) || - role.description.toLowerCase().includes(appliedSearch.toLowerCase()); + const filteredRoles = useMemo(() => { + return roles.filter((role) => { + const matchesSearch = !hasFiltered + ? true + : !appliedSearch || + role.name.toLowerCase().includes(appliedSearch.toLowerCase()) || + role.description.toLowerCase().includes(appliedSearch.toLowerCase()); - const statusToUse = hasFiltered ? appliedStatus : "all"; - const matchesStatus = - statusToUse === "all" || - (statusToUse === "active" && role.isActive) || - (statusToUse === "inactive" && !role.isActive); + const statusToUse = hasFiltered ? appliedStatus : "all"; + const matchesStatus = + statusToUse === "all" || + (statusToUse === "active" && role.isActive) || + (statusToUse === "inactive" && !role.isActive); - const matchesPermission = - permissionFilter === "all" || - (permissionFilter === "high" && role.permissions.length >= 8) || - (permissionFilter === "medium" && - role.permissions.length >= 4 && - role.permissions.length <= 7) || - (permissionFilter === "low" && role.permissions.length <= 3); + const matchesPermission = + permissionFilter === "all" || + (permissionFilter === "high" && role.permissions.length >= 8) || + (permissionFilter === "medium" && + role.permissions.length >= 4 && + role.permissions.length <= 7) || + (permissionFilter === "low" && role.permissions.length <= 3); - return matchesSearch && matchesStatus && matchesPermission; + 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(); + const columns: ColumnDef[] = useMemo( + () => [ + columnHelper.display({ + id: "role", + header: "Role", + cell: (info) => { + const role = info.row.original; + return ( +
+
+ +
+
+
+ {role.name} +
+
+
+ ); + }, + }), + columnHelper.display({ + id: "description", + header: "Deskripsi", + cell: (info) => ( +
+ {info.row.original.description} +
+ ), + }), + columnHelper.display({ + id: "permissions", + header: "Permissions", + cell: (info) => { + const role = info.row.original; + return ( +
+ + + {role.permissions.length} + + permissions +
+ ); + }, + }), + columnHelper.display({ + id: "status", + header: "Status", + cell: (info) => { + const role = info.row.original; + return ( + + ); + }, + }), + columnHelper.display({ + id: "createdAt", + header: "Tanggal Dibuat", + cell: (info) => ( +
+ + {formatDate(info.row.original.createdAt)} +
+ ), + }), + columnHelper.display({ + id: "actions", + header: "Aksi", + cell: (info) => { + const role = info.row.original; + return ( +
+ + +
+ ); + }, + }), + ], + [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 = () => { @@ -71,26 +241,6 @@ export default function RoleManagement() { 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 date = new Date(dateString); return date.toLocaleDateString("id-ID", { @@ -195,97 +345,107 @@ export default function RoleManagement() {
- - - - - - - - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} - {filteredRoles.map((role) => ( - - - - - + {row.getVisibleCells().map((cell) => ( + - - + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} ))}
- Role - - Deskripsi - - Permissions - - Status - - Tanggal Dibuat - - Aksi -
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
-
-
- -
-
-
- {role.name} -
-
-
-
-
- {role.description} -
-
-
- - - {role.permissions.length} - - - permissions - -
-
-
- {role.isActive ? "Active" : "Inactive"} - - -
- - {formatDate(role.createdAt)} -
-
-
- - -
-
+ + {/* Pagination Controls */} +
+
+
+ Page + { + 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" + /> + of {table.getPageCount() || 1} +
+
+ + + + + +
+
+
{/* Empty State */} diff --git a/src/pages/UserManagement.tsx b/src/pages/UserManagement.tsx index e03320b..eacc95e 100644 --- a/src/pages/UserManagement.tsx +++ b/src/pages/UserManagement.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo, useCallback } from "react"; import { Users, Plus, @@ -14,9 +14,37 @@ import { } from "lucide-react"; import { sampleUsers, sampleRoles } from "../types/roles"; import type { IUser } from "../types/roles"; +import type { ColumnDef } from "@tanstack/react-table"; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; export default function UserManagement() { - const [users, setUsers] = useState(sampleUsers); + // Seed more users once so pagination appears + const [users, setUsers] = useState(() => { + const generated: IUser[] = Array.from({ length: 30 }).map((_, idx) => { + const base = sampleUsers[idx % sampleUsers.length]; + const role = sampleRoles[(idx + 1) % sampleRoles.length]; + const n = idx + 6; // continue after existing 5 sample users + return { + id: String(n), + name: `User ${n}`, + email: `user${n}@claimguard.com`, + phone: `+62 81${(200000000 + n).toString()}`, + role, + department: role.name, + isActive: n % 2 === 0, + lastLogin: base.lastLogin, + createdAt: base.createdAt, + updatedAt: base.updatedAt, + } as IUser; + }); + return [...sampleUsers, ...generated]; + }); const [searchInput, setSearchInput] = useState(""); const [selectedUser, setSelectedUser] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); @@ -26,21 +54,155 @@ export default function UserManagement() { const [appliedStatus, setAppliedStatus] = useState(""); const [hasFiltered, setHasFiltered] = useState(false); - const filteredUsers = users.filter((user) => { - const matchesSearch = !hasFiltered - ? true - : !appliedSearch || - user.name.toLowerCase().includes(appliedSearch.toLowerCase()) || - user.email.toLowerCase().includes(appliedSearch.toLowerCase()) || - user.role.name.toLowerCase().includes(appliedSearch.toLowerCase()); + const filteredUsers = useMemo(() => { + return users.filter((user) => { + const matchesSearch = !hasFiltered + ? true + : !appliedSearch || + user.name.toLowerCase().includes(appliedSearch.toLowerCase()) || + user.email.toLowerCase().includes(appliedSearch.toLowerCase()) || + user.role.name.toLowerCase().includes(appliedSearch.toLowerCase()); - const statusToUse = hasFiltered ? appliedStatus : ""; - const matchesStatus = - !statusToUse || - (statusToUse === "active" && user.isActive) || - (statusToUse === "inactive" && !user.isActive); + const statusToUse = hasFiltered ? appliedStatus : ""; + const matchesStatus = + !statusToUse || + (statusToUse === "active" && 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(); + const columns: ColumnDef[] = useMemo( + () => [ + columnHelper.display({ + id: "user", + header: "User", + cell: (info) => { + const u = info.row.original; + return ( +
+
+
+ {u.name} +
+
+ + {u.email} +
+
+ + {u.phone} +
+
+
+ ); + }, + }), + columnHelper.display({ + id: "role", + header: "Role", + cell: (info) => { + const u = info.row.original; + return ( +
+ +
+
+ {u.role.name} +
+
+ {u.role.permissions.length} permissions +
+
+
+ ); + }, + }), + columnHelper.display({ + id: "status", + header: "Status", + cell: (info) => { + const u = info.row.original; + return ( + + ); + }, + }), + columnHelper.display({ + id: "lastLogin", + header: "Login Terakhir", + cell: (info) => ( +
+ + {formatLastLogin(info.row.original.lastLogin)} +
+ ), + }), + columnHelper.display({ + id: "actions", + header: "Aksi", + cell: (info) => { + const u = info.row.original; + return ( +
+ + +
+ ); + }, + }), + ], + [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 = () => { @@ -49,26 +211,6 @@ export default function UserManagement() { 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) => { return isActive ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"; }; @@ -178,94 +320,107 @@ export default function UserManagement() {
- - - - - - - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} - {filteredUsers.map((user) => ( - - - - + {row.getVisibleCells().map((cell) => ( + - - + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} ))}
- User - - Role - - Status - - Login Terakhir - - Aksi -
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
-
-
-
- {user.name} -
-
- - {user.email} -
-
- - {user.phone} -
-
-
-
-
- -
-
- {user.role.name} -
-
- {user.role.permissions.length} permissions -
-
-
-
-
- {user.isActive ? "Active" : "Inactive"} - - -
- - {formatLastLogin(user.lastLogin)} -
-
-
- - -
-
+ + {/* Pagination Controls */} +
+
+
+ Page + { + 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" + /> + of {table.getPageCount() || 1} +
+
+ + + + + +
+
+
{/* Empty State */}