fix revision add datatable and date picker
This commit is contained in:
159
package-lock.json
generated
159
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -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",
|
||||||
|
|||||||
@@ -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,8 +85,9 @@ 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",
|
id: "1",
|
||||||
timestamp: "2024-01-15T14:30:00Z",
|
timestamp: "2024-01-15T14:30:00Z",
|
||||||
@@ -114,7 +144,46 @@ export default function BPJSSync() {
|
|||||||
source: "BPJS Kesehatan API",
|
source: "BPJS Kesehatan API",
|
||||||
duration: 0,
|
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,23 +203,186 @@ 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(() => {
|
||||||
|
return syncLogs.filter((log) => {
|
||||||
const matchesStatus =
|
const matchesStatus =
|
||||||
appliedStatus === "all" || log.status === appliedStatus;
|
appliedStatus === "all" || log.status === appliedStatus;
|
||||||
|
|
||||||
let matchesDate = true;
|
let matchesDate = true;
|
||||||
if (hasDateFiltered) {
|
if (hasDateFiltered && (appliedStartDate || appliedEndDate)) {
|
||||||
const ts = new Date(log.timestamp).getTime();
|
const logDate = new Date(log.timestamp);
|
||||||
const startOk =
|
const startOk = !appliedStartDate || logDate >= appliedStartDate;
|
||||||
!appliedStartDate ||
|
const endOk = !appliedEndDate || logDate <= appliedEndDate;
|
||||||
ts >= new Date(appliedStartDate + "T00:00:00").getTime();
|
|
||||||
const endOk =
|
|
||||||
!appliedEndDate ||
|
|
||||||
ts <= new Date(appliedEndDate + "T23:59:59").getTime();
|
|
||||||
matchesDate = startOk && endOk;
|
matchesDate = startOk && endOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchesStatus && matchesDate;
|
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 () => {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
@@ -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 className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Success Rate
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Duration
|
|
||||||
</th>
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</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 && (
|
|
||||||
<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>
|
</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 */}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 matches = records.filter((record) => {
|
||||||
const inLast30Days = new Date(record.recordDate) >= thirtyDaysAgo;
|
const inLast30Days = new Date(record.recordDate) >= thirtyDaysAgo;
|
||||||
if (!hasSearched) return inLast30Days;
|
|
||||||
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 &&
|
||||||
|
record.patientId.toLowerCase() === appliedId.toLowerCase();
|
||||||
return inLast30Days && matchName && matchId;
|
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>
|
||||||
<div className="text-sm text-gray-500 flex items-center">
|
) : (
|
||||||
<User className="h-3 w-3 mr-1" />
|
filteredRecords.map((rec) => {
|
||||||
{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>
|
|
||||||
{(() => {
|
|
||||||
const rec = records.find((r) => r.id === detailId);
|
|
||||||
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,148 +358,192 @@ 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="p-3 space-y-1 text-sm">
|
||||||
<div className="text-gray-900 font-medium">
|
<div className="text-gray-900 font-medium">
|
||||||
{rec.patientName} ({rec.patientGender},{" "}
|
{rec.patientName}
|
||||||
{rec.patientAge} th)
|
</div>
|
||||||
|
<div className="text-gray-500">
|
||||||
|
{rec.patientGender}, {rec.patientAge} th
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
ID Pasien: {rec.patientId}
|
ID Pasien: {rec.patientId}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-gray-500">
|
|
||||||
Tanggal / Dokter / Dept
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
|
||||||
|
Kunjungan
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-1 text-sm">
|
||||||
<div className="text-gray-900 font-medium">
|
<div className="text-gray-900 font-medium">
|
||||||
{formatDate(rec.recordDate)}
|
{formatDate(rec.recordDate)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">{rec.doctor}</div>
|
||||||
{rec.doctor} • {rec.department}
|
|
||||||
</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">
|
||||||
|
Riwayat, Obat & Alergi
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="border-b border-gray-200 mb-3">
|
||||||
|
<nav className="-mb-px flex space-x-4">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
"riwayat",
|
||||||
|
"obat",
|
||||||
|
"alergi",
|
||||||
|
] as SectionTab[]
|
||||||
|
).map((tab) => {
|
||||||
|
const active =
|
||||||
|
getActiveSection(rec.id) === tab;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() =>
|
||||||
|
setActiveSection(rec.id, tab)
|
||||||
|
}
|
||||||
|
className={`whitespace-nowrap py-1.5 px-1 border-b-2 text-sm font-medium ${
|
||||||
|
active
|
||||||
|
? "border-green-600 text-green-700"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === "riwayat" && "Riwayat"}
|
||||||
|
{tab === "obat" && "Obat"}
|
||||||
|
{tab === "alergi" && "Alergi"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
|
{getActiveSection(rec.id) === "riwayat" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-gray-500">
|
||||||
Riwayat Penyakit Dahulu
|
Riwayat Penyakit Dahulu
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
|
||||||
{details.pmh.map((x, i) => (
|
{details.pmh.map((x, i) => (
|
||||||
<li key={i}>{x}</li>
|
<li key={i}>{x}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
|
{getActiveSection(rec.id) === "obat" && (
|
||||||
Obat
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
Daftar Obat Saat Ini
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
|
||||||
{details.meds.map((x, i) => (
|
{details.meds.map((x, i) => (
|
||||||
<li key={i}>{x}</li>
|
<li key={i}>{x}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
|
{getActiveSection(rec.id) === "alergi" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-gray-500">
|
||||||
Alergi
|
Alergi
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
|
||||||
{details.allergies.map((x, i) => (
|
{details.allergies.map((x, i) => (
|
||||||
<li key={i}>{x}</li>
|
<li key={i}>{x}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
|
||||||
|
Laboratorium
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
|
||||||
|
{details.labs.map((x, i) => (
|
||||||
|
<li key={i}>{x}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
||||||
Penunjang
|
<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">
|
|
||||||
Laboratorium
|
|
||||||
</div>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
{details.labs.map((x, i) => (
|
|
||||||
<li key={i}>{x}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
|
|
||||||
Imaging
|
Imaging
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<div className="p-3">
|
||||||
|
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
|
||||||
{details.imaging.map((x, i) => (
|
{details.imaging.map((x, i) => (
|
||||||
<li key={i}>{x}</li>
|
<li key={i}>{x}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
|
||||||
|
Ringkasan Pulang
|
||||||
|
</div>
|
||||||
|
<div className="p-3 text-sm text-gray-700">
|
||||||
|
{details.discharge}
|
||||||
|
</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-gray-900">
|
||||||
|
{rec.diagnosis}
|
||||||
|
</div>
|
||||||
<div className="text-xs font-mono inline-block mt-1 bg-gray-100 px-2 py-1 rounded">
|
<div className="text-xs font-mono inline-block mt-1 bg-gray-100 px-2 py-1 rounded">
|
||||||
{rec.icdCode}
|
{rec.icdCode}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
|
||||||
Prosedur/Tindakan
|
Prosedur/Tindakan
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<div className="p-3">
|
||||||
|
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
|
||||||
{details.procedures.map((x, i) => (
|
{details.procedures.map((x, i) => (
|
||||||
<li key={i}>{x}</li>
|
<li key={i}>{x}</li>
|
||||||
))}
|
))}
|
||||||
@@ -658,21 +552,26 @@ export default function MedicalRecord() {
|
|||||||
</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">
|
||||||
|
Tanda Vital
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="p-3">
|
||||||
<div>
|
<ul className="mt-1 list-disc pl-5 space-y-1 text-sm text-gray-800">
|
||||||
<ul className="mt-1 list-disc pl-5 space-y-1">
|
|
||||||
<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>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 text-xs font-semibold text-gray-900">
|
||||||
|
Rencana
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-800">
|
||||||
{details.plan.map((x, i) => (
|
{details.plan.map((x, i) => (
|
||||||
<li key={i}>{x}</li>
|
<li key={i}>{x}</li>
|
||||||
))}
|
))}
|
||||||
@@ -680,37 +579,12 @@ export default function MedicalRecord() {
|
|||||||
</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>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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,8 +130,9 @@ 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",
|
id: "1",
|
||||||
timestamp: "2024-01-15T14:30:00Z",
|
timestamp: "2024-01-15T14:30:00Z",
|
||||||
@@ -192,7 +219,51 @@ export default function MedicalRecordSync() {
|
|||||||
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,23 +283,172 @@ 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(() => {
|
||||||
|
return syncLogs.filter((log) => {
|
||||||
const matchesStatus =
|
const matchesStatus =
|
||||||
appliedStatus === "all" || log.status === appliedStatus;
|
appliedStatus === "all" || log.status === appliedStatus;
|
||||||
|
|
||||||
let matchesDate = true;
|
let matchesDate = true;
|
||||||
if (hasDateFiltered) {
|
if (hasDateFiltered && (appliedStartDate || appliedEndDate)) {
|
||||||
const ts = new Date(log.timestamp).getTime();
|
const logDate = new Date(log.timestamp);
|
||||||
const startOk =
|
const startOk = !appliedStartDate || logDate >= appliedStartDate;
|
||||||
!appliedStartDate ||
|
const endOk = !appliedEndDate || logDate <= appliedEndDate;
|
||||||
ts >= new Date(appliedStartDate + "T00:00:00").getTime();
|
|
||||||
const endOk =
|
|
||||||
!appliedEndDate ||
|
|
||||||
ts <= new Date(appliedEndDate + "T23:59:59").getTime();
|
|
||||||
matchesDate = startOk && endOk;
|
matchesDate = startOk && endOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchesStatus && matchesDate;
|
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 () => {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
@@ -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 className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Success Rate
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Duration
|
|
||||||
</th>
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</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 && (
|
|
||||||
<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>
|
</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 */}
|
||||||
|
|||||||
@@ -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,7 +75,8 @@ 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(() => {
|
||||||
|
return roles.filter((role) => {
|
||||||
const matchesSearch = !hasFiltered
|
const matchesSearch = !hasFiltered
|
||||||
? true
|
? true
|
||||||
: !appliedSearch ||
|
: !appliedSearch ||
|
||||||
@@ -64,6 +99,141 @@ export default function RoleManagement() {
|
|||||||
|
|
||||||
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 = () => {
|
||||||
setSelectedRole(null);
|
setSelectedRole(null);
|
||||||
@@ -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 className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Tanggal Dibuat
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Aksi
|
|
||||||
</th>
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</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">
|
|
||||||
<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>
|
</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 */}
|
||||||
|
|||||||
@@ -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,7 +54,8 @@ 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(() => {
|
||||||
|
return users.filter((user) => {
|
||||||
const matchesSearch = !hasFiltered
|
const matchesSearch = !hasFiltered
|
||||||
? true
|
? true
|
||||||
: !appliedSearch ||
|
: !appliedSearch ||
|
||||||
@@ -42,6 +71,139 @@ export default function UserManagement() {
|
|||||||
|
|
||||||
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 = () => {
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
@@ -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 className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Aksi
|
|
||||||
</th>
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</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">
|
|
||||||
<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>
|
</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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user