Compare commits

...

10 Commits

Author SHA1 Message Date
Vassshhh
e3f18d60ff V1 2025-09-11 13:28:33 +07:00
Vassshhh
28c4c4d66b ok 2025-08-21 11:03:40 +07:00
Vassshhh
e30b1a8de8 ok 2025-08-01 16:45:33 +07:00
john aperkat
79914fb7ef ok 2025-07-30 07:55:42 +00:00
john aperkat
afe9b24f56 ok 2025-07-30 04:18:44 +00:00
MOCH. PASHA ARDYAN PUTRA
3206db6010 ok 2025-07-07 15:08:43 +00:00
MOCH. PASHA ARDYAN PUTRA
0ee9f230d6 ok 2025-07-07 10:31:27 +00:00
MOCH. PASHA ARDYAN PUTRA
3d7b5332b3 ok 2025-07-07 10:19:21 +00:00
MOCH. PASHA ARDYAN PUTRA
cd1477855f ok 2025-07-07 04:40:49 +00:00
MOCH. PASHA ARDYAN PUTRA
d5a7f485f2 ok 2025-07-06 17:55:56 +00:00
31 changed files with 7418 additions and 2070 deletions

436
package-lock.json generated
View File

@@ -8,14 +8,18 @@
"name": "workspace", "name": "workspace",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"file-saver": "^2.0.5",
"js-levenshtein": "^1.1.6", "js-levenshtein": "^1.1.6",
"lucide-react": "^0.539.0",
"pixelmatch": "^7.1.0", "pixelmatch": "^7.1.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.2", "react-router-dom": "^7.6.2",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"recharts": "^3.0.2", "recharts": "^3.0.2",
@@ -2912,6 +2916,171 @@
} }
} }
}, },
"node_modules/@react-pdf/fns": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
"integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g=="
},
"node_modules/@react-pdf/font": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.2.tgz",
"integrity": "sha512-/dAWu7Y2RD1RxarDZ9SkYPHgBYOhmcDnet4W/qN/m8k+A2Hr3ja54GymSR7GGxWBtxjKtNauVKrTa9LS1n8WUw==",
"dependencies": {
"@react-pdf/pdfkit": "^4.0.3",
"@react-pdf/types": "^2.9.0",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.3.tgz",
"integrity": "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==",
"dependencies": {
"@react-pdf/png-js": "^3.0.0",
"jay-peg": "^1.1.1"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.0.tgz",
"integrity": "sha512-Aq+Cc6JYausWLoks2FvHe3PwK9cTuvksB2uJ0AnkKJEUtQbvCq8eCRb1bjbbwIji9OzFRTTzZij7LzkpKHjIeA==",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/image": "^3.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.0",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.0",
"emoji-regex": "^10.3.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/layout/node_modules/emoji-regex": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="
},
"node_modules/@react-pdf/pdfkit": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.3.tgz",
"integrity": "sha512-k+Lsuq8vTwWsCqTp+CCB4+2N+sOTFrzwGA7aw3H9ix/PDWR9QksbmNg0YkzGbLAPI6CeawmiLHcf4trZ5ecLPQ==",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/png-js": "^3.0.0",
"browserify-zlib": "^0.2.0",
"crypto-js": "^4.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"linebreak": "^1.1.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/png-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ=="
},
"node_modules/@react-pdf/reconciler": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.4.tgz",
"integrity": "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="
},
"node_modules/@react-pdf/render": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.0.tgz",
"integrity": "sha512-MdWfWaqO6d7SZD75TZ2z5L35V+cHpyA43YNRlJNG0RJ7/MeVGDQv12y/BXOJgonZKkeEGdzM3EpAt9/g4E22WA==",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.0",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.0.tgz",
"integrity": "sha512-28gpA69fU9ZQrDzmd5xMJa1bDf8t0PT3ApUKBl2PUpoE/x4JlvCB5X66nMXrfFrgF2EZrA72zWQAkvbg7TE8zw==",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/font": "^4.0.2",
"@react-pdf/layout": "^4.4.0",
"@react-pdf/pdfkit": "^4.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/reconciler": "^1.1.4",
"@react-pdf/render": "^4.3.0",
"@react-pdf/types": "^2.9.0",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.0.tgz",
"integrity": "sha512-BGZ2sYNUp38VJUegjva/jsri3iiRGnVNjWI+G9dTwAvLNOmwFvSJzqaCsEnqQ/DW5mrTBk/577FhDY7pv6AidA==",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/types": "^2.9.0",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.0.0.tgz",
"integrity": "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.0.tgz",
"integrity": "sha512-ckj80vZLlvl9oYrQ4tovEaqKWP3O06Eb1D48/jQWbdwz1Yh7Y9v1cEmwlP8ET+a1Whp8xfdM0xduMexkuPANCQ==",
"dependencies": {
"@react-pdf/font": "^4.0.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.0"
}
},
"node_modules/@reduxjs/toolkit": { "node_modules/@reduxjs/toolkit": {
"version": "2.8.2", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
@@ -3279,6 +3448,14 @@
"url": "https://github.com/sponsors/gregberge" "url": "https://github.com/sponsors/gregberge"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -4152,6 +4329,11 @@
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"optional": true "optional": true
}, },
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -4956,6 +5138,25 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/batch": { "node_modules/batch": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
@@ -4976,6 +5177,14 @@
"node": ">= 8.0.0" "node": ">= 8.0.0"
} }
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/big.js": { "node_modules/big.js": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -5086,11 +5295,27 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browser-process-hrtime": { "node_modules/browser-process-hrtime": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow=="
}, },
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.0", "version": "4.25.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
@@ -5426,6 +5651,14 @@
"wrap-ansi": "^7.0.0" "wrap-ansi": "^7.0.0"
} }
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -5549,6 +5782,15 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
}, },
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color-support": { "node_modules/color-support": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -5778,6 +6020,11 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/crypto-random-string": { "node_modules/crypto-random-string": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@@ -6509,6 +6756,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -7855,6 +8107,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/filelist": { "node_modules/filelist": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -7996,6 +8254,22 @@
} }
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -8694,6 +8968,19 @@
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
}, },
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg=="
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -8888,6 +9175,11 @@
"node": ">=10.17.0" "node": ">=10.17.0"
} }
}, },
"node_modules/hyphen": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw=="
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -9674,6 +9966,14 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jest": { "node_modules/jest": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
@@ -10784,6 +11084,23 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -10878,6 +11195,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.539.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -10937,6 +11263,11 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA=="
}, },
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg=="
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -11317,6 +11648,14 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/normalize-url": { "node_modules/normalize-url": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@@ -11655,6 +11994,11 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/param-case": { "node_modules/param-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -11692,6 +12036,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
@@ -13293,6 +13642,14 @@
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
}, },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -13500,6 +13857,15 @@
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz",
"integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==" "integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ=="
}, },
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -14029,6 +14395,11 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
},
"node_modules/retry": { "node_modules/retry": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -14675,6 +15046,19 @@
"simple-concat": "^1.0.0" "simple-concat": "^1.0.0"
} }
}, },
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
},
"node_modules/sisteransi": { "node_modules/sisteransi": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -15328,6 +15712,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
},
"node_modules/svg-parser": { "node_modules/svg-parser": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
@@ -15756,6 +16145,11 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -16070,6 +16464,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-property-aliases-ecmascript": { "node_modules/unicode-property-aliases-ecmascript": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
@@ -16078,6 +16481,20 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
},
"node_modules/unique-string": { "node_modules/unique-string": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
@@ -16260,6 +16677,19 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/w3c-hr-time": { "node_modules/w3c-hr-time": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
@@ -17095,6 +17525,7 @@
"version": "0.18.5", "version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"adler-32": "~1.3.0", "adler-32": "~1.3.0",
"cfb": "~1.2.1", "cfb": "~1.2.1",
@@ -17178,6 +17609,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="
},
"node_modules/zlibjs": { "node_modules/zlibjs": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",

View File

@@ -3,14 +3,18 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"file-saver": "^2.0.5",
"js-levenshtein": "^1.1.6", "js-levenshtein": "^1.1.6",
"lucide-react": "^0.539.0",
"pixelmatch": "^7.1.0", "pixelmatch": "^7.1.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.2", "react-router-dom": "^7.6.2",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"recharts": "^3.0.2", "recharts": "^3.0.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

BIN
public/asfasf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

BIN
public/ikasapta.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
public/ikasapta1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

View File

@@ -2,13 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/ikasapta.png" type="image/png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta name="description" content="Website SOLID DATA" />
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!-- <!--
manifest.json provides metadata used when your web app is installed on a manifest.json provides metadata used when your web app is installed on a
@@ -25,7 +22,7 @@
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>SOLID DATA</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,19 +1,19 @@
{ {
"short_name": "React App", "short_name": "IKASAPTA",
"name": "Create React App Sample", "name": "IKASAPTA HUB",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "ikasapta.png",
"sizes": "64x64 32x32 24x24 16x16", "sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon" "type": "image/x-icon"
}, },
{ {
"src": "logo192.png", "src": "ikasapta.png",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
}, },
{ {
"src": "logo512.png", "src": "ikasapta.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
} }

View File

@@ -1,49 +1,107 @@
import "./App.css"; import "./App.css";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
import { useEffect } from "react";
import ShowImage from "./ShowImage"; import ShowImage from "./ShowImage";
import SuccessPage from "./SuccessPage";
import Dashboard from "./Dashboard"; import Dashboard from "./Dashboard";
import Login from "./Login"; import LoginPage from "./Login";
import Expetation from "./DataTypePage";
import CameraKtp from "./KTPScanner"; import CameraKtp from "./KTPScanner";
import Profile from "./ProfileTab";
import PickOrganization from "./PickOrganization";
// Komponen untuk melindungi route dengan token // ===== ProtectedRoute: cek token sebelum render =====
const ProtectedRoute = ({ element }) => { const ProtectedRoute = ({ element }) => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
return token ? element : <Navigate to="/login" />; return token ? element : <Navigate to="/login" replace />;
}; };
// Komponen redirect berdasarkan sessionStorage // ===== Redirector: /dashboard → /dashboard/:organization_id (kalau ada), kalau tidak ada ke /pick-organization =====
const HomeRedirect = () => { const RedirectToOrgDashboard = () => {
const token = localStorage.getItem("token"); const orgId = localStorage.getItem("organization_id");
const hasOpen = sessionStorage.getItem("hasOpen"); if (orgId) return <Navigate to={`/dashboard/${orgId}`} replace />;
return <Navigate to="/pick-organization" replace />;
};
if (!token) { // ===== Redirector: /scan → /scan/:organization_id (kalau ada), kalau tidak ada ke /pick-organization =====
return <Navigate to="/login" />; const RedirectToOrgScan = () => {
} const orgId = localStorage.getItem("organization_id");
if (orgId) return <Navigate to={`/scan/${orgId}`} replace />;
// Jika tidak ada sessionId (anggap sebagai session baru) return <Navigate to="/pick-organization" replace />;
if (!hasOpen) {
sessionStorage.setItem("hasOpen", true);
return <Navigate to="/scan" />;
}
// Jika sudah ada sessionId
return <Navigate to="/dashboard" />;
}; };
function App() { function App() {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
const params = new URLSearchParams(location.search);
// 1) Ambil token dari query, simpan, lalu arahkan ke pemilihan organisasi
const token = params.get("token");
if (token) {
localStorage.setItem("token", token);
params.delete("token");
navigate("/pick-organization", { replace: true });
return;
}
// 2) Jika tidak ada token di query, biarkan mengalir normal
}, [location, navigate]);
return ( return (
<div className="App"> <div className="App">
<Routes> <Routes>
<Route path="/login" element={<Login />} /> {/* Default → login */}
<Route path="/scan" element={<CameraKtp />} /> <Route path="/" element={<Navigate to="/login" replace />} />
{/* Auth */}
<Route path="/login" element={<LoginPage />} />
{/* Setelah login → pilih organisasi */}
<Route
path="/pick-organization"
element={<ProtectedRoute element={<PickOrganization />} />}
/>
{/* Dashboard "polos" otomatis diarahkan ke dashboard org aktif */}
<Route <Route
path="/dashboard" path="/dashboard"
element={<ProtectedRoute element={<RedirectToOrgDashboard />} />}
/>
{/* Dashboard spesifik organisasi */}
<Route
path="/dashboard/:organization_id"
element={<ProtectedRoute element={<Dashboard />} />} element={<ProtectedRoute element={<Dashboard />} />}
/> />
<Route path="/" element={<HomeRedirect />} />
<Route path="/:nik" element={<ShowImage />} /> {/* Scan "polos" otomatis diarahkan ke scan org aktif */}
<Route
path="/scan"
element={<ProtectedRoute element={<RedirectToOrgScan />} />}
/>
{/* Scan spesifik organisasi */}
<Route
path="/scan/:organization_id"
element={<ProtectedRoute element={<CameraKtp />} />}
/>
{/* Alur scan di dalam dashboard (jika memang ada halaman ini) */}
<Route
path="/dashboard/:organization_id/scan"
element={<ProtectedRoute element={<Expetation />} />}
/>
{/* Halaman lain */}
<Route path="/success" element={<SuccessPage />} />
<Route path="/profile" element={<ProtectedRoute element={<Profile />} />} />
{/* Contoh: ShowImage jika masih dipakai */}
<Route path="/show-image" element={<ProtectedRoute element={<ShowImage />} />} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>
</div> </div>
); );

View File

@@ -1,7 +1,6 @@
// Dashboard.jsx
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import styles from "./Dashboard.module.css"; import styles from "./Dashboard.module.css";
import { useNavigate } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import FileListComponent from "./FileListComponent"; import FileListComponent from "./FileListComponent";
import { import {
BarChart, BarChart,
@@ -12,93 +11,203 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
// Konsistenkan base URL (tanpa tanda minus)
const API_BASE = "https://bot.kediritechnopark.com/webhook/soliddata";
const Dashboard = () => { const Dashboard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
// Ambil org dari URL, lalu sediakan fallback ke localStorage
const { organization_id: orgParam } = useParams();
const [organizationId, setOrganizationId] = useState(
orgParam || localStorage.getItem("organization_id") || ""
);
useEffect(() => {
if (orgParam) {
localStorage.setItem("organization_id", orgParam);
setOrganizationId(orgParam);
}
}, [orgParam]);
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef(null); const menuRef = useRef(null);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [successMessage, setSuccessMessage] = useState(""); const [successMessage, setSuccessMessage] = useState("");
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [user, setUser] = useState({}); const [user, setUser] = useState({});
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0); const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0); const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
const [totalFileSentYear, setTotalFileSentYear] = useState(0);
const [totalFilesSentOverall, setTotalFilesSentOverall] = useState(0); const [totalFilesSentOverall, setTotalFilesSentOverall] = useState(0);
const [officerPerformanceData, setOfficerPerformanceData] = useState([]);
// === Grafik ===
const [officerPerformanceData, setOfficerPerformanceData] = useState([]); // data yang sedang ditampilkan di chart
const [byTypeSeries, setByTypeSeries] = useState([]); // dari /files: [{ label: nama_tipe, count }]
const [typeOptions, setTypeOptions] = useState([]); // daftar tipe untuk dropdown: [{id, name}]
const [performanceByType, setPerformanceByType] = useState({}); // { [nama_tipe]: monthlySeries [{label: 'YYYY-MM', count}] }
const [chartKey, setChartKey] = useState(""); // "" = semua tipe (agregat), selain itu = nama_tipe (tren bulanan)
const [officers, setOfficers] = useState([]);
// Terima daftar tipe + seri per-tipe dari FileListComponent (/files)
const handleTypesLoaded = (options, series) => {
setTypeOptions(options || []);
setByTypeSeries(series || []);
// Default grafik: tampilkan agregat per-tipe
setOfficerPerformanceData(series || []);
setChartKey(""); // mode "Semua tipe"
};
// Terima seri bulanan saat tipe dibuka di FileListComponent
const handlePerformanceReady = (typeName, monthlySeries) => {
setPerformanceByType((prev) => ({ ...prev, [typeName]: monthlySeries }));
// Jika user sedang memilih tipe ini → langsung update grafik
if (chartKey === typeName) {
setOfficerPerformanceData(monthlySeries || []);
}
};
// Header auth + optional X-Organization-Id
const authHeaders = (extra = {}) => {
const token = localStorage.getItem("token");
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...extra,
};
return headers;
};
// Pastikan login
useEffect(() => { useEffect(() => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) { if (!token) {
window.location.href = "/login"; navigate("/login");
return;
} }
}, []); // Opsional: jika org kosong, arahkan user ke pemilihan organisasi
if (!organizationId) {
setErrorMessage("Organisasi tidak terdeteksi. Silakan akses dashboard melalui tautan organisasi.");
}
}, [organizationId, navigate]);
// Verifikasi token & fetch ringkasan dashboard org aktif
useEffect(() => { useEffect(() => {
const verifyTokenAndFetchData = async () => { const verifyTokenAndFetchData = async () => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) { if (!token || !organizationId) return;
window.location.href = "/login";
return; const toNum = (v) => {
} const n = typeof v === "number" ? v : Number(v ?? 0);
return Number.isFinite(n) ? n : 0;
};
try { try {
const response = await fetch( // Fetch total scans data (pakai API_BASE yang konsisten)
"https://bot.kediritechnopark.com/webhook/dashboard/psi", const totalScansRes = await fetch(
{ `${API_BASE}/total-scans?organization_id=${encodeURIComponent(
method: "GET", organizationId
headers: { )}`,
Authorization: `Bearer ${token}`, { method: "GET", headers: authHeaders() }
},
}
); );
const totalScansRaw = await totalScansRes.json();
console.log("RAW total-scans payload:", totalScansRaw);
const data = await response.json(); if (!totalScansRes.ok) {
console.error("Total Scans error:", totalScansRaw);
if (!response.ok || !data[0].payload.username) { } else {
throw new Error("Unauthorized"); const totalScansPayload = Array.isArray(totalScansRaw)
? totalScansRaw[0]
: totalScansRaw;
setTotalFilesSentToday(toNum(totalScansPayload?.total_today));
setTotalFilesSentMonth(toNum(totalScansPayload?.total_month));
setTotalFileSentYear(toNum(totalScansPayload?.total_year));
setTotalFilesSentOverall(toNum(totalScansPayload?.total_overall));
} }
setUser(data[0].payload); // Fetch dashboard (user, officer performance default)
} catch (error) { const res = await fetch(
console.error("Token tidak valid:", error.message); `${API_BASE}/dashboard?organization_id=${encodeURIComponent(
localStorage.removeItem("token"); organizationId
window.location.href = "/login"; )}`,
{ method: "GET", headers: authHeaders() }
);
const raw = await res.json();
console.log("RAW dashboard payload:", raw);
if (!res.ok) {
console.error("Dashboard error:", raw);
}
const payload = Array.isArray(raw) ? raw[0] : raw;
if (payload?.user) setUser(payload.user);
// Kalau backend kirim default "officerPerformance", tetap tampilkan (akan ditimpa saat /files datang)
if (Array.isArray(payload?.officerPerformance)) {
setOfficerPerformanceData(payload.officerPerformance);
setChartKey(""); // mode umum
}
} catch (err) {
console.error("Token/Fetch dashboard gagal:", err);
} }
}; };
verifyTokenAndFetchData(); verifyTokenAndFetchData();
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId]);
// Ambil daftar officer (khusus admin)
const fetchOfficers = async () => {
if (!organizationId) return;
try {
const res = await fetch(
`${API_BASE}/list-user?organization_id=${encodeURIComponent(
organizationId
)}`,
{ method: "GET", headers: authHeaders() }
);
const data = await res.json();
setOfficers(Array.isArray(data) ? data : []);
} catch (err) {
console.error("Gagal memuat daftar officer:", err);
}
};
useEffect(() => {
if (user?.role === "admin") {
fetchOfficers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.role]);
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("user"); localStorage.removeItem("user");
localStorage.removeItem("organization_id"); // bersihkan juga orgId
window.location.reload(); window.location.reload();
}; };
const handleAddOfficer = async (e) => { const handleAddOfficer = async (e) => {
e.preventDefault(); e.preventDefault();
if (!organizationId) return;
const token = localStorage.getItem("token");
try { try {
const response = await fetch( const res = await fetch(`${API_BASE}/add-officer`, {
"https://bot.kediritechnopark.com/webhook/add-officer",
{
method: "POST", method: "POST",
headers: { headers: authHeaders(),
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: JSON.stringify({
username, username,
password, password,
organization_id: organizationId,
}), }),
} });
);
const data = await response.json(); const data = await res.json();
if (!response.ok || data.success === false) { if (!res.ok || data.success === false) {
throw new Error(data.message || "Gagal menambahkan officer"); throw new Error(data.message || "Gagal menambahkan officer");
} }
@@ -106,12 +215,45 @@ const Dashboard = () => {
setUsername(""); setUsername("");
setPassword(""); setPassword("");
setErrorMessage(""); setErrorMessage("");
} catch (error) {
setErrorMessage(error.message || "Gagal menambahkan officer"); await fetchOfficers();
} catch (err) {
setErrorMessage(err.message || "Gagal menambahkan officer");
setSuccessMessage(""); setSuccessMessage("");
} }
}; };
const handleDeleteOfficer = async (id) => {
const confirmDelete = window.confirm(
"Apakah Anda yakin ingin menghapus petugas ini?"
);
if (!confirmDelete) return;
if (!organizationId) return;
try {
const res = await fetch(`${API_BASE}/delete-officer`, {
method: "DELETE",
headers: authHeaders(),
body: JSON.stringify({
id,
organization_id: organizationId,
}),
});
const data = await res.json();
if (!res.ok || data.success === false) {
throw new Error(data.message || "Gagal menghapus officer");
}
setOfficers((prev) => prev.filter((o) => o.id !== id));
} catch (err) {
alert("Gagal menghapus petugas: " + err.message);
}
};
// Tutup menu ketika klik di luar
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) { if (menuRef.current && !menuRef.current.contains(event.target)) {
@@ -122,18 +264,36 @@ const Dashboard = () => {
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, []); }, []);
const { orgName } = {};
// ====== WRAPPER SETTER untuk proteksi dari FileListComponent ======
const safeSetToday = (v) => {
const n = Number(v ?? 0);
if (Number.isFinite(n)) setTotalFilesSentToday(n);
};
const safeSetMonth = (v) => {
const n = Number(v ?? 0);
if (Number.isFinite(n)) setTotalFilesSentMonth(n);
};
const safeSetYear = (v) => {
const n = Number(v ?? 0);
if (Number.isFinite(n)) setTotalFileSentYear(n);
};
const safeSetOverall = (v) => {
const n = Number(v ?? 0);
if (Number.isFinite(n)) setTotalFilesSentOverall(n);
};
return ( return (
<div className={styles.dashboardContainer}> <div className={styles.dashboardContainer}>
<div className={styles.dashboardHeader}> <div className={styles.dashboardHeader}>
<div className={styles.logoAndTitle}> <div className={styles.logoAndTitle}>
<img src="/PSI.png" alt="Bot Avatar" /> <img src="/ikasapta.png" alt="Bot Avatar" />
<h1 className={styles.h1}>Kawal PSI Dashboard</h1> <h1 className={styles.h1}>SOLID</h1>
<h1 className={`${styles.h1} ${styles.h1Accent}`}>DATA</h1>
</div> </div>
<div className={styles.dropdownContainer} ref={menuRef}> <div className={styles.dropdownContainer} ref={menuRef}>
<span className={styles.userDisplayName}>
{user.username || "Guest"}
</span>
<button <button
onClick={() => setIsMenuOpen(!isMenuOpen)} onClick={() => setIsMenuOpen(!isMenuOpen)}
className={styles.dropdownToggle} className={styles.dropdownToggle}
@@ -149,6 +309,7 @@ const Dashboard = () => {
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true"
> >
<line x1="3" y1="6" x2="21" y2="6" /> <line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" /> <line x1="3" y1="12" x2="21" y2="12" />
@@ -157,24 +318,48 @@ const Dashboard = () => {
</button> </button>
{isMenuOpen && ( {isMenuOpen && (
<div className={styles.dropdownMenu}> <div className={styles.dropdownMenu}>
{/* Static Organization */}
<div className={styles.dropdownItemStatic}>
<div className={styles.dropdownText}>
<strong>Organisasi</strong>
<div className={styles.orgName}>{orgName}</div>
</div>
</div>
{/* Scan */}
<button <button
onClick={() => { onClick={() => {
navigate("/profile"); // Selalu bawa organizationId saat navigasi
if (organizationId) {
navigate(`/scan/${organizationId}`);
} else {
setErrorMessage("Organisasi tidak terdeteksi.");
}
setIsMenuOpen(false); setIsMenuOpen(false);
}} }}
className={styles.dropdownItem} className={styles.dropdownItem}
> >
Profile <svg
</button> xmlns="http://www.w3.org/2000/svg"
<button width="18"
onClick={() => { height="18"
navigate("/scan"); viewBox="0 0 24 24"
setIsMenuOpen(false); fill="none"
}} stroke="currentColor"
className={styles.dropdownItem} strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
> >
Scan <path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<rect x="7" y="7" width="10" height="10" rx="1" />
</svg>
<span>Scan</span>
</button> </button>
{/* Logout */}
<button <button
onClick={() => { onClick={() => {
handleLogout(); handleLogout();
@@ -182,15 +367,36 @@ const Dashboard = () => {
}} }}
className={styles.dropdownItem} className={styles.dropdownItem}
> >
Logout <svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>Logout</span>
</button> </button>
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* ... sisanya tetap sama persis */}
<div className={styles.mainContent}> <div className={styles.mainContent}>
{/* Summary Cards */} {errorMessage && (
<div className={styles.error} style={{ marginBottom: 12 }}>
{errorMessage}
</div>
)}
<div className={styles.summaryCardsContainer}> <div className={styles.summaryCardsContainer}>
<div className={styles.summaryCard}> <div className={styles.summaryCard}>
<h3>Hari Ini</h3> <h3>Hari Ini</h3>
@@ -200,16 +406,55 @@ const Dashboard = () => {
<h3>Bulan Ini</h3> <h3>Bulan Ini</h3>
<p>{totalFilesSentMonth.toLocaleString()}</p> <p>{totalFilesSentMonth.toLocaleString()}</p>
</div> </div>
<div className={styles.summaryCard}>
<h3>Tahun Ini</h3>
<p>{totalFileSentYear.toLocaleString()}</p>
</div>
<div className={styles.summaryCard}> <div className={styles.summaryCard}>
<h3>Total Keseluruhan</h3> <h3>Total Keseluruhan</h3>
<p>{totalFilesSentOverall.toLocaleString()}</p> <p>{totalFilesSentOverall.toLocaleString()}</p>
</div> </div>
</div> </div>
{/* Grid for Form (Admin) and Chart (Admin & Officer) */}
<div className={styles.dashboardGrid}> <div className={styles.dashboardGrid}>
{user.role === "admin" && ( {user?.role === "admin" && (
<div className={styles.formSection}> <div className={styles.formSection}>
<h2>Daftar Petugas</h2>
<div className={styles.officerListContainer}>
<div className={styles.officerList}>
{officers.length > 0 ? (
officers.map((officer) => (
<div key={officer.id} className={styles.officerItem}>
<div className={styles.officerInfo}>
<span className={styles.officerIcon}>👤</span>
<div className={styles.officerDetails}>
<strong className={styles.officerName}>
{officer.username}
</strong>
<span className={styles.officerRole}>
{officer.role}
</span>
</div>
</div>
<button
onClick={() => handleDeleteOfficer(officer.id)}
className={styles.deleteButton}
title="Hapus Petugas"
>
</button>
</div>
))
) : (
<div className={styles.emptyState}>
<span>📋</span>
<p>Belum ada petugas terdaftar</p>
</div>
)}
</div>
</div>
<hr className={styles.separator} />
<h2>Tambah Petugas Baru</h2> <h2>Tambah Petugas Baru</h2>
<form onSubmit={handleAddOfficer} className={styles.form}> <form onSubmit={handleAddOfficer} className={styles.form}>
<label> <label>
@@ -245,35 +490,77 @@ const Dashboard = () => {
)} )}
<div className={styles.chartSection}> <div className={styles.chartSection}>
<h2>Grafik Pertumbuhan Anggota</h2> <div className={styles.chartHeader}>
<h2>Grafik Upload Dokumen</h2>
<div className={styles.chartFilter}>
<label htmlFor="chartTypeSelect">Filter tipe:&nbsp;</label>
<select
id="chartTypeSelect"
value={chartKey}
onChange={(e) => {
const key = e.target.value;
setChartKey(key);
if (!key) {
// Semua tipe → pakai agregat per-tipe
setOfficerPerformanceData(byTypeSeries);
return;
}
// Jika sudah ada seri bulanan tipe tsb → pakai
if (performanceByType[key]?.length) {
setOfficerPerformanceData(performanceByType[key]);
} else {
// fallback sementara: 1 bar dari agregat tipe
const one = byTypeSeries.find((s) => s.label === key);
setOfficerPerformanceData(one ? [one] : []);
}
}}
disabled={typeOptions.length === 0}
>
<option value="">Semua tipe</option>
{typeOptions.map((t) => (
<option key={t.id} value={t.name}>
{t.name}
</option>
))}
</select>
</div>
</div>
{officerPerformanceData.length > 0 ? ( {officerPerformanceData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<BarChart data={officerPerformanceData}> <BarChart data={officerPerformanceData}>
<XAxis dataKey="month" /> <XAxis dataKey="label" /> {/* label = nama_tipe ATAU YYYY-MM */}
<YAxis allowDecimals={false} /> <YAxis allowDecimals={false} />
<Tooltip /> <Tooltip />
<Bar dataKey="count" fill="#00adef" /> <Bar dataKey="count" fill="var(--brand-primary)" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className={styles.warning}> <div className={styles.warning}>
📋 Belum ada data performa untuk ditampilkan 📋 Belum ada data upload untuk ditampilkan
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* ✅ Tambahkan FileListComponent di sini */}
<FileListComponent <FileListComponent
setTotalFilesSentToday={setTotalFilesSentToday} organizationId={organizationId}
setTotalFilesSentMonth={setTotalFilesSentMonth} // Gunakan wrapper agar nilai dari child selalu angka valid
setTotalFilesSentOverall={setTotalFilesSentOverall} setTotalFilesSentToday={safeSetToday}
setTotalFilesSentMonth={safeSetMonth}
setTotalFileSentYear={safeSetYear}
setTotalFilesSentOverall={safeSetOverall}
// tampilkan agregat saat /files selesai
setOfficerPerformanceData={setOfficerPerformanceData} setOfficerPerformanceData={setOfficerPerformanceData}
// === baru: terima daftar tipe + agregat & seri bulanan
onTypesLoaded={handleTypesLoaded}
onPerformanceReady={handlePerformanceReady}
/> />
</div> </div>
<div className={styles.footer}> <div className={styles.footer}>
© 2025 Kediri Technopark Dashboard PSI © 2025 Kediri Technopark Dashboard SOLID DATA
</div> </div>
</div> </div>
); );

View File

@@ -1,444 +1,512 @@
/* Dashboard.module.css - Cleaned Version */ /* Dashboard.module.css - Brand Blue/Indigo, Full Page & Responsive */
/* Modern Color Palette */ /* ==== GLOBAL FULL-HEIGHT ==== */
html, body, #root {
height: 100%;
}
* { box-sizing: border-box; }
/* ==== Palette & Tokens ==== */
:root { :root {
--primary-blue: #3b82f6; /* Brand */
--secondary-blue: #60a5fa; --brand-primary: #2961eb; /* blue-600 */
--dark-blue: #1e40af; --brand-primary-700: #1d4ed8; /* blue-700 */
--neutral-50: #fafafa; --brand-secondary: #4f46e5; /* indigo-600 */
--neutral-100: #f5f5f5; --brand-secondary-700: #4338ca;/* indigo-700 */
--neutral-200: #e5e5e5; --brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
--neutral-300: #d4d4d4;
--neutral-500: #737373; /* Gradients for cards */
--neutral-700: #404040; --card-grad-1: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
--neutral-800: #262626; --card-grad-2: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
--neutral-900: #171717; --card-grad-3: linear-gradient(135deg, #2563eb 10%, #4338ca 100%);
/* Neutral */
--neutral-25: #fcfcfd;
--neutral-50: #f9fafb;
--neutral-100: #f3f4f6;
--neutral-200: #e5e7eb;
--neutral-300: #d1d5db;
--neutral-400: #9ca3af;
--neutral-600: #475569;
--neutral-800: #1f2937;
--white: #ffffff; --white: #ffffff;
--success-green: #10b981;
--warning-amber: #f59e0b; /* Text */
--error-red: #ef4444;
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #64748b; --text-secondary: #64748b;
--text-light: #ffffff; --text-on-brand: #ffffff;
--border-light: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); /* Borders & Shadows */
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --border-light: #e5e7eb;
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), --shadow-sm: 0 1px 2px rgba(0,0,0,.06);
0 4px 6px -4px rgb(0 0 0 / 0.1); --shadow-md: 0 4px 10px rgba(2,6,23,.08);
} --shadow-lg: 0 12px 22px rgba(2,6,23,.12);
/* Base Styles & Reset */ /* States */
* { --focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
box-sizing: border-box;
/* Semantic */
--error-red: #ef4444;
} }
/* ==== Base ==== */
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
"Helvetica Neue", Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
color: var(--text-primary); color: var(--text-primary);
background-color: var(--neutral-50); background: var(--neutral-50);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Root container: full width & height */
.dashboardContainer { .dashboardContainer {
background-color: var(--neutral-50); min-height: 100dvh; /* support mobile dynamic viewport */
min-height: 100vh; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--neutral-50);
} }
/* --- Header --- */ /* ==== Header ==== */
.dashboardHeader { .dashboardHeader {
background-color: var(--white); position: sticky; top: 0; z-index: 50;
color: var(--text-primary); display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
display: flex; background: rgba(255,255,255,.95);
justify-content: space-between; backdrop-filter: blur(6px);
align-items: center; border-bottom: 1px solid var(--border-light);
box-shadow: var(--shadow-sm); width: 100%;
border-bottom: 3px solid #ef4444;
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(8px);
} }
.logoAndTitle { .logoAndTitle { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
display: flex; .logoAndTitle img { width: 2.8rem; height: 2.8rem; object-fit: cover; border-radius: .6rem; box-shadow: var(--shadow-sm); }
align-items: center;
flex-shrink: 0;
}
.logoAndTitle img { .h1 {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
margin-right: 0.75rem;
object-fit: cover;
}
.dashboardHeader .h1 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: clamp(1.25rem, 1.2vw + 1rem, 2rem);
font-weight: 700; font-weight: 800; letter-spacing: -.02em;
color: #ed4344; background: var(--brand-gradient);
letter-spacing: -0.025em; -webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.h1Accent {
background: linear-gradient(135deg, var(--brand-secondary), var(--brand-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
} }
/* Dropdown Menu */ .orgBadge {
.dropdownContainer { margin-left: .75rem;
position: relative; padding: .25rem .6rem;
display: flex; font-size: .75rem; font-weight: 700;
align-items: center; color: var(--text-on-brand);
gap: 0.75rem; background: var(--brand-secondary);
flex-shrink: 0; border-radius: .5rem;
} border: 1px solid rgba(255,255,255,.5);
.userDisplayName {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.875rem;
} }
/* ==== Dropdown ==== */
.dropdownContainer { position: relative; display: flex; align-items: center; gap: .5rem; }
.dropdownToggle { .dropdownToggle {
background-color: var(--neutral-100); min-width: 2.5rem; height: 2.5rem;
color: var(--text-primary); display: inline-flex; align-items: center; justify-content: center;
background: var(--white);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
padding: 0.5rem; border-radius: .6rem;
border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
font-size: 1rem; box-shadow: var(--shadow-sm);
transition: all 0.2s ease; transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
min-width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.dropdownToggle:hover {
background-color: var(--neutral-200);
border-color: var(--neutral-300);
} }
.dropdownToggle:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: var(--neutral-300); }
.dropdownToggle:focus { outline: none; box-shadow: var(--focus-ring); }
.dropdownMenu { .dropdownMenu {
position: absolute; position: absolute; right: 0; top: calc(100% + .5rem);
top: calc(100% + 0.5rem); min-width: 10rem; padding: .5rem;
right: 0; background: var(--white);
background-color: var(--white);
border-radius: 0.75rem;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
z-index: 10; border-radius: .75rem;
display: flex; box-shadow: var(--shadow-lg);
flex-direction: column; display: flex; flex-direction: column; gap: .25rem;
min-width: 10rem;
overflow: hidden;
padding: 0.5rem;
} }
.dropdownItem { .dropdownItem {
background: none; border: 0; background: transparent; text-align: left; cursor: pointer;
border: none; padding: .65rem .75rem; border-radius: .5rem;
padding: 0.75rem 1rem; font-size: .95rem; font-weight: 600; color: var(--neutral-800);
text-align: left; transition: background .15s ease, transform .06s ease;
cursor: pointer;
color: var(--text-primary);
transition: background-color 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
margin-bottom: 0.125rem;
} }
.dropdownItem:hover { background: rgba(37,99,235,.08); transform: translateY(-1px); }
.dropdownItem:hover { /* ==== Main: FULL WIDTH ==== */
background-color: var(--neutral-100);
}
.dropdownItem:last-child {
margin-bottom: 0;
}
/* --- Main Content --- */
.mainContent { .mainContent {
flex-grow: 1; flex: 1 1 auto;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
width: 100%; width: 100%;
padding: 2rem 1.5rem;
margin: 0; /* remove center constraint */
display: flex; flex-direction: column; gap: 2rem;
min-width: 0; /* prevent overflow causing shrink */
} }
/* Summary Cards Container */ /* Summary Cards */
.summaryCardsContainer { .summaryCardsContainer {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* prevent too-small cards */
gap: 1.5rem; gap: 1rem;
align-items: stretch;
width: 100%;
min-width: 0;
} }
/* Gradient cards (3 variasi) */
.summaryCard { .summaryCard {
background-color: var(--white); position: relative;
padding: 1.5rem; overflow: hidden;
background: var(--card-grad-1);
color: var(--text-on-brand);
border: 1px solid rgba(255,255,255,.18);
border-radius: 1rem; border-radius: 1rem;
border: 1px solid var(--border-light); padding: 1.25rem 1.5rem;
box-shadow: var(--shadow-sm); min-height: 120px;
transition: all 0.2s ease; box-shadow: 0 10px 20px rgba(37,99,235,.15);
transition: box-shadow .2s ease, transform .12s ease, background-position .4s ease, filter .2s ease;
background-size: 140% 140%;
background-position: 0% 50%;
}
.summaryCardsContainer .summaryCard:nth-child(1) { background: var(--card-grad-1); }
.summaryCardsContainer .summaryCard:nth-child(2) { background: var(--card-grad-2); }
.summaryCardsContainer .summaryCard:nth-child(3) { background: var(--card-grad-3); }
.summaryCard::before {
content: "";
position: absolute; inset: 0;
background:
radial-gradient(120% 60% at 10% -10%, rgba(255,255,255,.25) 0%, rgba(255,255,255,0) 55%),
radial-gradient(90% 50% at 90% -10%, rgba(255,255,255,.18) 0%, rgba(255,255,255,0) 60%);
pointer-events: none;
} }
.summaryCard:hover { .summaryCard:hover {
box-shadow: var(--shadow-md); transform: translateY(-2px);
transform: translateY(-1px); box-shadow: 0 14px 28px rgba(37,99,235,.22);
filter: saturate(1.05);
background-position: 100% 50%;
} }
.summaryCard h3 { .summaryCard h3 {
margin: 0 0 0.5rem 0; margin: 0 0 .5rem 0;
font-size: 0.875rem; font-size: 1rem; letter-spacing: .06em; text-transform: uppercase;
color: var(--text-secondary); color: rgba(255,255,255,.92);
font-weight: 500; font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.summaryCard p { .summaryCard p {
font-size: 2rem; margin: 0; line-height: 1;
font-weight: 700; font-size: clamp(2rem, 2vw + .5rem, 2.2rem);
color: #ef4444; font-weight: 900; color: #fff;
margin: 0; background: none !important;
line-height: 1; -webkit-background-clip: initial !important;
-webkit-text-fill-color: #fff !important;
text-shadow: 0 1px 1px rgba(0,0,0,.15);
} }
/* Dashboard Grid for Form and Chart */ /* Grid: Form & Chart */
.dashboardGrid { .dashboardGrid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr; /* mobile: 1 kolom */
gap: 2rem; gap: 2rem;
flex-grow: 1; flex-grow: 1;
min-width: 0;
} }
.formSection, .formSection, .chartSection {
.chartSection { background: var(--white);
background-color: var(--white);
padding: 2rem;
border-radius: 1rem;
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: 1rem;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
padding: 1.5rem;
min-width: 0;
} }
.formSection h2, .formSection h2, .chartSection h2 {
.chartSection h2 { margin: 0 0 1rem 0;
color: var(--text-primary); font-size: clamp(1.05rem, .9vw + .7rem, 1.35rem);
margin: 0 0 1.5rem 0; font-weight: 800; letter-spacing: -.015em;
font-size: 1.25rem; color: var(--neutral-800);
font-weight: 600;
letter-spacing: -0.025em;
} }
/* Form */
.form label { .form label {
text-align: left; display: block; margin-bottom: 1rem;
display: block; font-size: .95rem; font-weight: 700; color: var(--neutral-800);
margin-bottom: 1rem;
color: var(--text-primary);
font-weight: 500;
font-size: 0.875rem;
} }
.form input[type="text"], .form input[type="text"],
.form input[type="password"], .form input[type="password"],
.form select { .form select {
width: 100%; width: 100%; margin-top: .35rem;
padding: 0.75rem 1rem; padding: .7rem 1rem;
margin-top: 0.375rem; font-size: .95rem; color: var(--neutral-800);
background: var(--white);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: 0.5rem; border-radius: .55rem;
font-size: 0.875rem; transition: border-color .2s ease, box-shadow .2s ease;
transition: all 0.2s ease;
background-color: var(--white);
color: var(--text-primary);
} }
.form input[type="text"]:focus, .form input[type="text"]:focus,
.form input[type="password"]:focus, .form input[type="password"]:focus,
.form select:focus { .form select:focus {
border-color: var(--primary-blue); outline: none; border-color: var(--brand-primary);
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1); box-shadow: var(--focus-ring);
outline: none;
} }
/* Buttons */
.submitButton { .submitButton {
background-color: #ef4444;
color: var(--text-light);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
margin-top: 1rem;
width: 100%; width: 100%;
transition: all 0.2s ease; display: inline-flex; align-items: center; justify-content: center;
letter-spacing: 0.025em; padding: .9rem 1.2rem;
} border: none; border-radius: .6rem; cursor: pointer;
font-size: 1rem; font-weight: 800; letter-spacing: .02em;
.submitButton:hover { color: var(--text-on-brand);
background-color: #d03b3b; background: var(--brand-primary);
transform: translateY(-1px); box-shadow: 0 6px 18px rgba(37,99,235,.18);
box-shadow: var(--shadow-md); transition: transform .12s ease, box-shadow .2s ease, background .2s ease;
}
.submitButton:active {
transform: translateY(0);
} }
.submitButton:hover { transform: translateY(-1px); background: var(--brand-primary-700); box-shadow: 0 8px 22px rgba(37,99,235,.22); }
.submitButton:active { transform: translateY(0); }
.submitButton:focus { outline: none; box-shadow: var(--focus-ring); }
/* Messages */ /* Messages */
.success, .warning {
margin-top: 1rem; padding: .85rem 1rem;
border-radius: .6rem; font-size: .92rem; font-weight: 700;
border: 1px solid transparent;
}
.success { .success {
background-color: rgb(16 185 129 / 0.1); color: var(--brand-primary);
color: var(--success-green); background: rgba(37,99,235,.08);
border: 1px solid rgb(16 185 129 / 0.2); border-color: rgba(37,99,235,.18);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
font-size: 0.875rem;
font-weight: 500;
} }
.error {
background-color: rgb(239 68 68 / 0.1);
color: var(--error-red);
border: 1px solid rgb(239 68 68 / 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.warning { .warning {
background-color: #ef444417; color: var(--brand-secondary);
color: #ef4444; background: rgba(79,70,229,.08);
border: 1px solid #ef444433; border-color: rgba(79,70,229,.18);
padding: 1rem; }
border-radius: 0.5rem; .error {
margin-top: 1rem; margin-top: 1rem; padding: .85rem 1rem;
font-weight: 500; border-radius: .6rem; font-size: .92rem; font-weight: 700;
font-size: 0.875rem; color: var(--error-red);
background: rgba(239,68,68,.08);
border: 1px solid rgba(239,68,68,.18);
} }
/* Footer */ /* Footer */
.footer { .footer {
background-color: var(--white); margin-top: auto;
color: var(--text-secondary);
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;
margin-top: auto; font-size: .85rem;
font-size: 0.75rem; color: var(--text-secondary);
background: var(--white);
border-top: 1px solid var(--border-light); border-top: 1px solid var(--border-light);
} }
/* Chart placeholder (kalau perlu) */
.chartPlaceholder { .chartPlaceholder {
background-color: var(--neutral-50);
height: 20rem; height: 20rem;
display: flex; display: flex; align-items: center; justify-content: center;
justify-content: center; background: var(--neutral-50);
align-items: center;
color: var(--text-secondary);
font-style: italic;
border-radius: 0.75rem;
border: 2px dashed var(--border-light); border: 2px dashed var(--border-light);
font-size: 0.875rem; border-radius: .75rem;
color: var(--text-secondary);
font-style: italic; font-size: .95rem;
} }
/* --- Media Queries for Tablets and Desktops --- */ /* Officers List */
.officerListContainer {
background: var(--white);
border: 1px solid var(--border-light);
border-radius: .8rem;
padding: 1rem;
box-shadow: var(--shadow-sm);
margin-bottom: 1.25rem;
}
.officerList {
max-height: 300px; overflow-y: auto;
list-style: none; margin: 0; padding: 0;
border: 1px solid var(--border-light);
border-radius: .6rem; background: var(--white);
}
.officerItem {
display: flex; align-items: center; justify-content: space-between;
padding: .95rem 1rem;
border-bottom: 1px solid var(--border-light);
transition: background .15s ease;
}
.officerItem:last-child { border-bottom: 0; }
.officerItem:hover { background: rgba(79,70,229,.05); }
.officerInfo { display: flex; align-items: center; gap: .75rem; flex: 1; }
.officerIcon { width: 24px; height: 24px; display: grid; place-items: center; font-size: 18px; }
.officerDetails { display: flex; flex-direction: column; gap: 2px; }
.officerName { font-weight: 800; color: var(--neutral-800); font-size: .98rem; }
.officerRole { font-size: .85rem; color: var(--text-secondary); text-transform: capitalize; font-style: italic; }
.deleteButton {
background: transparent; border: 0; cursor: pointer;
font-size: .85rem; padding: .45rem .65rem; border-radius: .4rem;
color: var(--error-red);
transition: background .15s ease, transform .06s ease;
opacity: .9;
}
.deleteButton:hover { background: rgba(239,68,68,.08); transform: translateY(-1px); }
.deleteButton:focus { outline: none; box-shadow: var(--focus-ring); }
.emptyState { text-align: center; padding: 36px 18px; color: var(--text-secondary); }
.emptyState span { display: block; font-size: 32px; margin-bottom: .5rem; }
.separator { border: 0; border-top: 1px solid var(--border-light); margin: 20px 0; }
/* Tablet-sized screens and up */ /* Scrollbar */
.officerList::-webkit-scrollbar { width: 8px; }
.officerList::-webkit-scrollbar-track { background: var(--neutral-100); border-radius: 4px; }
.officerList::-webkit-scrollbar-thumb { background: var(--neutral-300); border-radius: 4px; }
.officerList::-webkit-scrollbar-thumb:hover { background: var(--neutral-400); }
/* ==== Responsive ==== */
@media (min-width: 768px) { @media (min-width: 768px) {
.dashboardHeader { .dashboardHeader { padding: 1rem 2rem; }
padding: 1rem 2rem; .logoAndTitle img { width: 3rem; height: 3rem; }
}
.logoAndTitle img { .mainContent { padding: 2.25rem 2rem; gap: 2rem; }
width: 3rem; .dashboardGrid { grid-template-columns: 1.1fr 2fr; gap: 2rem; } /* 2 kolom, tetap lega */
height: 3rem;
}
.dashboardHeader .h1 {
font-size: 1.75rem;
}
.userDisplayName {
font-size: 0.875rem;
}
.mainContent {
padding: 2.5rem 2rem;
gap: 2.5rem;
}
.summaryCardsContainer {
grid-template-columns: repeat(3, 1fr);
}
.dashboardGrid {
grid-template-columns: 1fr 2fr;
gap: 2.5rem;
}
.formSection,
.chartSection {
padding: 2.5rem;
}
.formSection h2,
.chartSection h2 {
font-size: 1.5rem;
}
.chartPlaceholder {
height: 25rem;
}
} }
/* Desktop-sized screens and up */ @media (min-width: 1280px) {
@media (min-width: 1024px) { .mainContent { padding: 2.5rem 2.25rem; gap: 2.25rem; }
.dashboardHeader { .dashboardGrid { grid-template-columns: 1fr 2fr; gap: 2.25rem; }
padding: 1.25rem 3rem;
}
.logoAndTitle img {
width: 3.5rem;
height: 3.5rem;
}
.dashboardHeader .h1 {
font-size: 2rem;
}
.mainContent {
padding: 3rem 2.5rem;
gap: 3rem;
}
.dashboardGrid {
gap: 3rem;
}
.formSection,
.chartSection {
padding: 3rem;
}
.chartPlaceholder {
height: 30rem;
}
} }
/* Single column layout when only one section is present */ /* ==== FIX: jika hanya ada 1 section, lebarkan full ==== */
@media (min-width: 768px) { .dashboardGrid > *:only-child {
.dashboardGrid > *:only-child {
grid-column: 1 / -1; grid-column: 1 / -1;
} width: 100%;
}
/* Pastikan chart section benar-benar membentang */
.chartSection {
width: 100%;
min-width: 0; /* cegah overflow anak bikin kontainer menyempit */
}
/* Rapikan baris kartu ringkasan */
.summaryCardsContainer {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
align-items: stretch;
justify-items: stretch;
gap: 1.25rem;
}
/* Tinggi & padding kartu konsisten + bayangan lebih halus */
.summaryCard {
min-height: 110px;
padding: 1.25rem 1.5rem;
box-shadow: 0 8px 18px rgba(37,99,235,.12);
}
/* Judul chart dan box warning sedikit lebih rapat */
.chartSection h2 { margin-bottom: .75rem; }
.warning { margin-top: .75rem; }
/* Header biar konten tidak terlalu mepet tepi di layar lebar */
@media (min-width: 1280px) {
.dashboardHeader { padding-left: 2rem; padding-right: 2rem; }
.mainContent { padding-left: 2rem; padding-right: 2rem; }
}
/* (Opsional) Kalau tetap terlihat terlalu ke kiri,
kamu bisa center-kan isi utama tanpa mengubah full width list dokumen */
@media (min-width: 1024px) {
.mainContent { max-width: 1280px; margin-left: auto; margin-right: auto; }
}
.dashboard {
font-family: sans-serif;
}
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: #ffffff;
border-bottom: 1px solid #e5e5e5;
}
.logo {
display: flex;
align-items: center;
gap: 6px;
font-weight: bold;
color: #333;
}
.menuWrapper {
position: relative;
}
.menuButton {
background: none;
border: none;
cursor: pointer;
padding: 6px;
border-radius: 6px;
}
.menuButton:hover {
background: #f2f2f2;
}
.dropdownMenu {
position: absolute;
right: 0;
top: 45px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 8px;
width: 220px;
z-index: 1000;
}
.dropdownItem,
.dropdownItemStatic {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
cursor: pointer;
}
.dropdownItem:hover {
background: #f5f5f5;
}
.dropdownItemStatic {
cursor: default;
background: #fafafa;
display: block;
}
.dropdownIcon {
flex-shrink: 0;
color: #444;
}
.dropdownText {
display: flex;
flex-direction: column;
}
.orgName {
font-size: 12px;
color: #666;
} }

441
src/DataTypePage.js Normal file
View File

@@ -0,0 +1,441 @@
import React, { useState, useEffect } from "react";
import {
User, Eye, EyeOff, Plus, X, RefreshCw, FileText, Users, Baby, Settings, LogOut, Camera
} from "lucide-react";
import styles from "./Login.module.css";
/* ===========================================================
TEMPLATE DATA
=========================================================== */
const templates = {
KTP: {
icon: <User className={styles.templateIcon} />,
fields: [
{ key: "nik", value: "number" },
{ key: "nama", value: "text" },
{ key: "tempat_lahir", value: "text" },
{ key: "tanggal_lahir", value: "date" },
{ key: "jenis_kelamin", value: "selection" },
{ key: "alamat", value: "text" },
{ key: "agama", value: "selection" },
{ key: "status_perkawinan", value: "selection" },
{ key: "pekerjaan", value: "text" },
]
},
KK: {
icon: <Users className={styles.templateIcon} />,
fields: [
{ key: "nomor_kk", value: "number" },
{ key: "kepala_keluarga", value: "text" },
{ key: "istri", value: "list" },
{ key: "anak", value: "list" },
{ key: "orang_tua", value: "list" },
{ key: "alamat", value: "text" },
{ key: "rt_rw", value: "text" },
{ key: "kelurahan", value: "text" },
{ key: "kecamatan", value: "text" },
{ key: "kabupaten_kota", value: "text" },
{ key: "provinsi", value: "text" },
]
},
"Akta Kelahiran": {
icon: <Baby className={styles.templateIcon} />,
fields: [
{ key: "nomor_akta", value: "text" },
{ key: "nama_anak", value: "text" },
{ key: "jenis_kelamin", value: "selection" },
{ key: "tempat_lahir", value: "text" },
{ key: "tanggal_lahir", value: "date" },
{ key: "nama_ayah", value: "text" },
{ key: "nama_ibu", value: "text" },
]
},
};
/* ===========================================================
EXPECTATION FORM (Controlled Component)
- Tidak memakai state internal; parent (DataTypePage) sebagai sumber kebenaran
=========================================================== */
function ExpectationForm({ fields, setFields }) {
const safeFields = fields?.length ? fields : [{ key: "", value: "" }];
const updateField = (index, key, value) => {
const next = safeFields.map((f, i) => (i === index ? { ...f, [key]: value } : f));
setFields(next);
};
const addField = () =>
setFields([...(safeFields || []), { key: "", value: "" }]);
const removeField = (index) => {
const next = safeFields.filter((_, i) => i !== index);
setFields(next);
};
return (
<div className={styles.expectationForm}>
{safeFields.map((f, i) => (
<div key={i} className={styles.fieldRow}>
<input
type="text"
placeholder="Field name"
value={f.key}
onChange={(e) => updateField(i, "key", e.target.value)}
className={styles.fieldInput}
/>
<select
value={f.value}
onChange={(e) => updateField(i, "value", e.target.value)}
className={styles.fieldSelect}
>
<option value="">Pilih Type</option>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Boolean</option>
<option value="selection">Selection</option>
<option value="list">List</option>
</select>
<button
type="button"
onClick={() => removeField(i)}
className={styles.removeFieldButton}
title="Hapus field"
>
<X className={styles.removeIcon} />
</button>
</div>
))}
<button
type="button"
onClick={addField}
className={styles.addFieldButton}
>
<Plus className={styles.addIcon} />
Tambah Field
</button>
</div>
);
}
/* ===========================================================
DATA TYPE PAGE
=========================================================== */
export default function DataTypePage() {
const [selectedTemplate, setSelectedTemplate] = useState("");
const [isFormSectionOpen, setIsFormSectionOpen] = useState(false);
const [namaTipe, setNamaTipe] = useState("");
const [fields, setFields] = useState([]);
const [expectation, setExpectation] = useState({});
const [scanned, setScanned] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const LIST_SCANNED_URL = "https://bot.kediritechnopark.com/webhook/list-scanned";
const resolveNama = (row) =>
row?.nama ??
row?.data?.nama ??
row?.fields?.nama ??
row?.payload?.nama ??
row?.kepala_keluarga ??
row?.nama_anak ??
row?.name ??
"-";
const resolveType = (row) =>
row?.type ??
row?.type_data ??
row?.nama_tipe ??
row?.data_type ??
row?.template_name ??
"-";
const fetchScanned = async () => {
try {
setLoading(true);
setError("");
const token = localStorage.getItem("token");
const res = await fetch(LIST_SCANNED_URL, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
const data = await res.json();
const rows = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
setScanned(rows);
} catch (e) {
setError("Gagal memuat daftar hasil scan");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchScanned();
}, []);
// Auto-bangun expectation dari fields (single source of truth)
useEffect(() => {
const obj = Object.fromEntries(
(fields || [])
.map((f) => [f?.key ?? "", f?.value ?? ""])
.filter(([k]) => k !== "")
);
setExpectation(obj);
}, [fields]);
const handleTemplateSelect = (templateName) => {
if (selectedTemplate === templateName && isFormSectionOpen) {
// klik ulang => tutup form & reset
setIsFormSectionOpen(false);
setSelectedTemplate("");
setNamaTipe("");
setFields([]);
setExpectation({});
return;
}
// pilih dan buka form
setIsFormSectionOpen(true);
setSelectedTemplate(templateName);
if (templateName === "Custom") {
setNamaTipe("");
setFields([]);
setExpectation({});
} else {
const tpl = templates[templateName]?.fields || [];
setNamaTipe(templateName);
setFields(tpl); // expectation otomatis lewat useEffect
}
};
const handleSubmit = async () => {
if (!namaTipe.trim()) {
alert("Nama Tipe harus diisi!");
return;
}
try {
const res = await fetch(
"https://bot.kediritechnopark.com/webhook/create-data-type",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nama_tipe: namaTipe, expectation }),
}
);
const data = await res.json();
if (data.success) {
alert("Data Type created!");
setSelectedTemplate("");
setNamaTipe("");
setFields([]);
setExpectation({});
setIsFormSectionOpen(false);
} else {
alert("Gagal membuat data type");
}
} catch (error) {
alert("Gagal membuat data type");
}
};
return (
<div className={styles.dataTypePage}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerContent}>
<div className={styles.headerBrand}>
<div className={styles.headerLogo}>
<FileText className={styles.headerLogoIcon} />
</div>
<div className={styles.headerInfo}>
<h1 className={styles.headerTitle}>DataScan</h1>
<p className={styles.headerSubtitle}>Data Management System</p>
</div>
</div>
{/* Tombol Scans + Logout */}
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => { window.location.href = "/scan"; }}
className={styles.submitButton}
title="Buka pemindaian KTP"
>
<Camera style={{ marginRight: 6 }} />
Scans
</button>
<button
className={styles.logoutButton}
onClick={() => {
localStorage.removeItem("token");
window.location.reload();
}}
>
<LogOut className={styles.logoutIcon} />
<span className={styles.logoutText}>Keluar</span>
</button>
</div>
</div>
</div>
{/* Main */}
<div className={styles.mainContent}>
{/* Create Data Type Section */}
<div className={styles.createSection}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>Buat Tipe Data Baru</h2>
<p className={styles.sectionSubtitle}>Pilih template atau buat tipe data custom sesuai kebutuhan</p>
</div>
{/* Template Selection */}
<div className={styles.templateSection}>
<h3 className={styles.templateTitle}>Pilih Template</h3>
<div className={styles.templateGrid}>
{Object.entries(templates).map(([templateName, template]) => (
<button
key={templateName}
onClick={() => handleTemplateSelect(templateName)}
className={`${styles.templateCard} ${
selectedTemplate === templateName ? styles.templateCardActive : ""
}`}
>
<div className={styles.templateContent}>
<div className={`${styles.templateIconContainer} ${
selectedTemplate === templateName ? styles.templateIconActive : ""
}`}>
{template.icon}
</div>
<span className={styles.templateName}>{templateName}</span>
</div>
</button>
))}
{/* Custom Template */}
<button
onClick={() => handleTemplateSelect("Custom")}
className={`${styles.templateCard} ${styles.customTemplateCard} ${
selectedTemplate === "Custom" ? styles.customTemplateActive : ""
}`}
>
<div className={styles.templateContent}>
<div className={`${styles.templateIconContainer} ${
selectedTemplate === "Custom" ? styles.customIconActive : ""
}`}>
<Settings className={styles.templateIcon} />
</div>
<span className={styles.templateName}>Custom</span>
</div>
</button>
</div>
</div>
{/* Form Section */}
{isFormSectionOpen && (
<div className={styles.formSection}>
<div className={styles.formGroup}>
<label className={styles.formLabel}>Nama Tipe Data</label>
<input
type="text"
placeholder="Masukkan nama tipe data"
className={styles.inputField}
value={namaTipe}
onChange={(e) => setNamaTipe(e.target.value)}
/>
</div>
{/* Fields Section */}
<div className={styles.formGroup}>
<label className={styles.formLabel}>Fields</label>
<ExpectationForm
fields={fields}
setFields={setFields}
/>
</div>
<button
onClick={handleSubmit}
className={styles.submitButton}
>
Simpan Tipe Data
</button>
</div>
)}
</div>
{/* Scanned Data List */}
<div className={styles.dataSection}>
<div className={styles.dataHeader}>
<div className={styles.dataHeaderInfo}>
<h2 className={styles.dataTitle}>Data Hasil Scan</h2>
<p className={styles.dataSubtitle}>Daftar semua data yang telah di-scan</p>
</div>
<button
onClick={fetchScanned}
disabled={loading}
className={styles.refreshButton}
>
<RefreshCw className={`${styles.refreshIcon} ${loading ? styles.spinning : ''}`} />
Refresh
</button>
</div>
{error && (
<div className={styles.errorMessage}>
{error}
</div>
)}
<div className={styles.tableContainer}>
<table className={styles.dataTable}>
<thead className={styles.tableHeader}>
<tr>
<th className={styles.tableHeaderCell}>No</th>
<th className={styles.tableHeaderCell}>Tipe Data</th>
<th className={styles.tableHeaderCell}>Nama</th>
</tr>
</thead>
<tbody className={styles.tableBody}>
{loading ? (
<tr>
<td colSpan={3} className={styles.loadingCell}>
<div className={styles.loadingContent}>
<RefreshCw className={`${styles.loadingIcon} ${styles.spinning}`} />
Memuat data...
</div>
</td>
</tr>
) : scanned.length === 0 ? (
<tr>
<td colSpan={3} className={styles.emptyCell}>
Belum ada data hasil scan
</td>
</tr>
) : (
scanned.map((row, idx) => (
<tr key={row.id || row.nik || idx} className={styles.tableRow}>
<td className={styles.tableCell}>{idx + 1}</td>
<td className={styles.tableCell}>
<span className={styles.typeBadge}>
{resolveType(row)}
</span>
</td>
<td className={styles.tableCell}>{resolveNama(row)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

960
src/Expetation.js Normal file
View File

@@ -0,0 +1,960 @@
import React, { useEffect, useState, useRef } from "react";
import { User, Users, Baby, Settings, Plus, X, Scan, CheckCircle, AlertTriangle, FolderOpen } from "lucide-react";
import { useNavigate } from "react-router-dom";
import styles from "./Dashboard.module.css"; // Import Dashboard CSS
import expetationStyles from "./Expetation.module.css"; // Import Expetation CSS
/* ============================
Helpers
============================ */
const getCleanToken = () => {
let raw = localStorage.getItem("token") || "";
try { raw = JSON.parse(raw); } catch {}
return String(raw).replace(/^"+|"+$/g, "");
};
// BACA org dari localStorage: utamakan 'selected_organization', fallback 'select_organization'
const getSelectedOrganization = () => {
let raw =
localStorage.getItem("selected_organization") ??
localStorage.getItem("select_organization");
if (!raw) return null;
try { return JSON.parse(raw); } catch { return raw; }
};
// Ambil organization_id aktif (string atau object)
const getActiveOrgId = () => {
const sel = getSelectedOrganization();
if (!sel) return "";
if (typeof sel === "object" && sel?.organization_id) return String(sel.organization_id);
return String(sel);
};
// Header auth standar (ikutkan X-Organization-Id juga)
const authHeaders = () => {
const token = getCleanToken();
const orgId = getActiveOrgId();
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(orgId ? { "X-Organization-Id": orgId } : {}),
};
};
// ID generator aman
const safeUUID = () => {
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).slice(1);
return `${Date.now().toString(16)}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
};
// Ubah array fields -> object expectation dengan menjaga urutan
const fieldsToExpectationObject = (fields, forcedOrder = []) => {
if (!Array.isArray(fields)) return {};
const base = {};
fields.forEach((f) => {
const k = (f?.key || f?.label || "").toString().trim();
const v = (f?.value || "text").toString().trim();
if (k) base[k] = v || "text";
});
if (!forcedOrder?.length) return base;
const ordered = {};
forcedOrder.forEach((k) => {
if (k in base) ordered[k] = base[k];
});
Object.keys(base).forEach((k) => {
if (!(k in ordered)) ordered[k] = base[k];
});
return ordered;
};
const toSlug = (name) =>
(name || "")
.toString()
.trim()
.toLowerCase()
.replace(/\s+/g, "_");
/* ============================
Template Data (Default)
============================ */
const templates = {
KTP: {
icon: <User style={{ width: 24, height: 24 }} />,
fields: [
{ key: "nik", value: "number" },
{ key: "nama", value: "text" },
{ key: "tempat_lahir", value: "text" },
{ key: "tanggal_lahir", value: "date" },
{ key: "jenis_kelamin", value: "selection" },
{ key: "alamat", value: "text" },
{ key: "agama", value: "selection" },
{ key: "status_perkawinan", value: "selection" },
{ key: "pekerjaan", value: "text" },
]
},
KK: {
icon: <Users style={{ width: 24, height: 24 }} />,
fields: [
{ key: "nomor_kk", value: "number" },
{ key: "kepala_keluarga", value: "text" },
{ key: "istri", value: "list" },
{ key: "anak", value: "list" },
{ key: "orang_tua", value: "list" },
{ key: "alamat", value: "text" },
{ key: "rt_rw", value: "text" },
{ key: "kelurahan", value: "text" },
{ key: "kecamatan", value: "text" },
{ key: "kabupaten_kota", value: "text" },
{ key: "provinsi", value: "text" },
]
},
"Akta Kelahiran": {
icon: <Baby style={{ width: 24, height: 24 }} />,
fields: [
{ key: "nomor_akta", value: "text" },
{ key: "nama_anak", value: "text" },
{ key: "jenis_kelamin", value: "selection" },
{ key: "tempat_lahir", value: "text" },
{ key: "tanggal_lahir", value: "date" },
{ key: "nama_ayah", value: "text" },
{ key: "nama_ibu", value: "text" },
]
},
};
// Urutan paksa untuk payload "Akta Kelahiran"
const AKTA_KELAHIRAN_FORCED_ORDER = [
"nomor_akta",
"nama_anak",
"jenis_kelamin",
"tempat_lahir",
"tanggal_lahir",
"nama_ayah",
"nama_ibu",
];
/* ============================
ExpectationForm
============================ */
const ExpectationForm = ({ fields, setFields }) => {
const safeFields = fields?.length ? fields : [{ key: "", value: "" }];
const updateField = (index, key, value) => {
const next = safeFields.map((f, i) => (i === index ? { ...f, [key]: value } : f));
setFields(next);
};
const addField = () =>
setFields([...(safeFields || []), { key: "", value: "" }]);
const removeField = (index) => {
const next = safeFields.filter((_, i) => i !== index);
setFields(next);
};
return (
<div className={expetationStyles.expectationFormContainer}>
{safeFields.map((f, i) => (
<div key={i} className={expetationStyles.fieldRow}>
<input
type="text"
placeholder="Field name"
value={f.key}
onChange={(e) => updateField(i, "key", e.target.value)}
className={expetationStyles.fieldInput}
/>
<select
value={f.value}
onChange={(e) => updateField(i, "value", e.target.value)}
className={expetationStyles.fieldSelect}
>
<option value="">Pilih Type</option>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Boolean</option>
<option value="selection">Selection</option>
<option value="list">List</option>
</select>
<button
type="button"
onClick={() => removeField(i)}
className={expetationStyles.removeFieldButton}
title="Hapus field"
>
<X style={{ width: 16, height: 16 }} />
</button>
</div>
))}
<button
type="button"
onClick={addField}
className={expetationStyles.addFieldButton}
>
<Plus style={{ width: 16, height: 16, marginRight: 8 }} />
Tambah Field
</button>
</div>
);
};
/* ============================
Modal: New Document
============================ */
const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
const [selectedTemplate, setSelectedTemplate] = useState("");
const [documentName, setDocumentName] = useState("");
const [fields, setFields] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isOpen) {
setSelectedTemplate("");
setDocumentName("");
setFields([]);
}
}, [isOpen]);
const handleTemplateSelect = (templateName) => {
setSelectedTemplate(templateName);
if (templateName === "Custom") {
setDocumentName("");
setFields([{ key: "", value: "" }]);
} else {
const tpl = templates[templateName]?.fields || [];
setDocumentName(templateName);
setFields(tpl);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!documentName.trim()) return;
const validFields = (fields || []).filter(f => f.key && f.key.trim() && f.value);
if (validFields.length === 0) return;
setIsSubmitting(true);
try {
const forcedOrder = documentName.trim() === "Akta Kelahiran"
? AKTA_KELAHIRAN_FORCED_ORDER
: [];
const expectationObj = fieldsToExpectationObject(validFields, forcedOrder);
await onSubmit(documentName.trim(), expectationObj);
setSelectedTemplate("");
setDocumentName("");
setFields([]);
onClose();
} catch (err) {
console.error("Error submit new document type:", err);
alert("Terjadi kesalahan saat membuat tipe dokumen baru.");
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
const validFields = (fields || []).filter(f => f.key && f.key.trim() && f.value);
const isFormValid = documentName.trim() && validFields.length > 0;
return (
<div className={expetationStyles.modalOverlay}>
<div className={expetationStyles.modal}>
<div className={expetationStyles.modalHeader}>
<h3 className={expetationStyles.modalTitle}>Tambah Jenis Dokumen Baru</h3>
<button onClick={onClose} className={expetationStyles.modalCloseButton} disabled={isSubmitting}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className={expetationStyles.modalContent}>
{/* Template Selection */}
<div className={expetationStyles.modalSection}>
<label className={expetationStyles.sectionLabel}>Pilih Template</label>
<div className={expetationStyles.templateGrid}>
{Object.entries(templates).map(([templateName, template]) => (
<button
key={templateName}
type="button"
onClick={() => handleTemplateSelect(templateName)}
className={`${expetationStyles.templateCard} ${selectedTemplate === templateName ? expetationStyles.templateCardActive : ''}`}
disabled={isSubmitting}
>
<div className={expetationStyles.templateContent}>
<div className={`${expetationStyles.templateIconContainer} ${selectedTemplate === templateName ? expetationStyles.templateIconActive : ''}`}>
{template.icon}
</div>
<span className={expetationStyles.templateName}>{templateName}</span>
</div>
</button>
))}
{/* Custom Template */}
<button
type="button"
onClick={() => handleTemplateSelect("Custom")}
className={`${expetationStyles.templateCard} ${expetationStyles.customTemplateCard} ${selectedTemplate === "Custom" ? expetationStyles.customTemplateActive : ''}`}
disabled={isSubmitting}
>
<div className={expetationStyles.templateContent}>
<div className={`${expetationStyles.templateIconContainer} ${selectedTemplate === "Custom" ? expetationStyles.customIconActive : ''}`}>
<Settings style={{ width: 24, height: 24 }} />
</div>
<span className={expetationStyles.templateName}>Custom</span>
</div>
</button>
</div>
</div>
{/* Form Section - hanya muncul jika template dipilih */}
{selectedTemplate && (
<>
<div className={expetationStyles.modalSection}>
<label className={expetationStyles.modalLabel}>Nama Tipe Dokumen</label>
<input
type="text"
value={documentName}
onChange={(e) => setDocumentName(e.target.value)}
placeholder="Contoh: KTP, KK, Ijazah, dll"
className={expetationStyles.modalInput}
disabled={isSubmitting}
required
/>
</div>
<div className={expetationStyles.modalSection}>
<label className={expetationStyles.modalLabel}>Fields</label>
<ExpectationForm
fields={fields}
setFields={setFields}
/>
</div>
</>
)}
</div>
{selectedTemplate && (
<div className={expetationStyles.modalFooter}>
<button type="button" onClick={onClose} className={expetationStyles.cancelButton} disabled={isSubmitting}>
Batal
</button>
<button
type="submit"
className={expetationStyles.submitButton}
disabled={isSubmitting || !isFormValid}
>
{isSubmitting ? "Mengirim..." : "Tambah"}
</button>
</div>
)}
</form>
</div>
</div>
);
};
/* ============================
Modal: Edit Document
============================ */
const EditDocumentModal = ({ isOpen, onClose, document, onSubmit }) => {
const [documentName, setDocumentName] = useState("");
const [fields, setFields] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isOpen && document) {
setDocumentName(document.display_name || document.nama_tipe || "");
// expectation object -> array of { key, value }
const initFields = Object.entries(document.expectation || {}).map(([k, v]) => ({
key: k,
value: v
}));
setFields(initFields.length ? initFields : [{ key: "", value: "" }]);
}
}, [isOpen, document]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!documentName.trim()) return;
const validFields = (fields || []).filter(f => f.key && f.key.trim() && f.value);
if (!validFields.length) return;
setIsSubmitting(true);
try {
const expectationObj = fieldsToExpectationObject(validFields);
await onSubmit(document.id, documentName.trim(), expectationObj);
onClose();
} catch (err) {
console.error("Error update document type:", err);
alert("Gagal memperbarui tipe dokumen.");
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
const validFields = (fields || []).filter(f => f.key && f.key.trim() && f.value);
const isFormValid = documentName.trim() && validFields.length > 0;
return (
<div className={expetationStyles.modalOverlay}>
<div className={expetationStyles.modal}>
<div className={expetationStyles.modalHeader}>
<h3 className={expetationStyles.modalTitle}>Edit Jenis Dokumen</h3>
<button onClick={onClose} className={expetationStyles.modalCloseButton} disabled={isSubmitting}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className={expetationStyles.modalContent}>
<div className={expetationStyles.modalSection}>
<label className={expetationStyles.modalLabel}>Nama Tipe Dokumen</label>
<input
type="text"
value={documentName}
onChange={(e) => setDocumentName(e.target.value)}
className={expetationStyles.modalInput}
disabled={isSubmitting}
required
/>
</div>
<div className={expetationStyles.modalSection}>
<label className={expetationStyles.modalLabel}>Fields</label>
<ExpectationForm fields={fields} setFields={setFields} />
</div>
</div>
<div className={expetationStyles.modalFooter}>
<button type="button" onClick={onClose} className={expetationStyles.cancelButton} disabled={isSubmitting}>
Batal
</button>
<button
type="submit"
className={expetationStyles.submitButton}
disabled={isSubmitting || !isFormValid}
>
{isSubmitting ? "Menyimpan..." : "Simpan"}
</button>
</div>
</form>
</div>
</div>
);
};
/* ============================
Komponen Utama: Expetation (Dashboard Style)
============================ */
const Expetation = ({ onSelect }) => {
const navigate = useNavigate();
const [documentTypes, setDocumentTypes] = useState([]);
const [loadingDocumentTypes, setLoadingDocumentTypes] = useState(true);
const [isEditMode, setIsEditMode] = useState(false);
const [showNewDocumentModal, setShowNewDocumentModal] = useState(false);
const [showEditDocumentModal, setShowEditDocumentModal] = useState(false);
const [editingDocument, setEditingDocument] = useState(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef(null);
const getDocumentDisplayInfo = (doc) => {
const base = (doc?.display_name ?? doc?.nama_tipe ?? "").toString();
const pretty = base
? base.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())
: "Tanpa Nama";
return { icon: "📄", name: pretty, fullName: pretty };
};
// Normalisasi data dari server "show"
const normalizeItem = (doc) => {
// UBAH: Prioritas nama_tipe > display_name > document_type
const humanName = doc.nama_tipe ?? doc.display_name ?? doc.document_type ?? "";
const slug = toSlug(humanName);
let expectationObj = {};
if (doc.expectation && typeof doc.expectation === "object" && !Array.isArray(doc.expectation)) {
expectationObj = { ...doc.expectation };
} else if (Array.isArray(doc.expectation)) {
expectationObj = fieldsToExpectationObject(doc.expectation);
} else if (Array.isArray(doc.fields)) {
expectationObj = fieldsToExpectationObject(doc.fields);
} else if (templates[humanName]) {
expectationObj = fieldsToExpectationObject(templates[humanName].fields);
}
return {
id: doc.id ?? doc.data_type_id ?? safeUUID(),
nama_tipe: slug, // UBAH: pastikan selalu ada nama_tipe
display_name: humanName,
expectation: expectationObj,
entry_name: doc.entry_name
};
};
/* ============================
Komunikasi dengan webhook
============================ */
// Kirim org ke /solid-data/show (GET + query + header)
const sendSelectedOrgToWebhook = async () => {
try {
const orgId = getActiveOrgId();
const url = new URL("https://bot.kediritechnopark.com/webhook/solid-data/show");
if (orgId) url.searchParams.set("organization_id", orgId);
await fetch(url.toString(), {
method: "GET",
headers: authHeaders(),
});
} catch (err) {
console.error("Gagal mengirim organization_id ke /solid-data/show:", err);
}
};
// Ambil daftar tipe dokumen (ikutkan organization_id)
const fetchDocumentTypes = async () => {
try {
setLoadingDocumentTypes(true);
const orgId = getActiveOrgId();
const url = new URL("https://bot.kediritechnopark.com/webhook/solid-data/show");
if (orgId) url.searchParams.set("organization_id", orgId);
const response = await fetch(url.toString(), {
method: "GET",
headers: authHeaders(),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
const normalized = (Array.isArray(data) ? data : [])
// UBAH: filter berdasarkan nama_tipe dan document_type
.filter((doc) => {
const namaType = doc.nama_tipe ?? doc.document_type ?? "";
return namaType !== "INACTIVE";
})
.map(normalizeItem);
setDocumentTypes(normalized);
} catch (error) {
console.error("Error fetching document types:", error);
// fallback dari templates lokal
const fallback = Object.keys(templates).map((name) =>
normalizeItem({
id: safeUUID(),
nama_tipe: toSlug(name),
display_name: name,
fields: templates[name].fields
})
);
setDocumentTypes(fallback);
} finally {
setLoadingDocumentTypes(false);
}
};
// Saat mount: 1) kirim organization_id 2) ambil list tipe dokumen
useEffect(() => {
(async () => {
await sendSelectedOrgToWebhook();
await fetchDocumentTypes();
})();
}, []);
// Hapus tipe dokumen (POST body + header X-Organization-Id)
const handleDeleteDocumentType = async (id, namaTipe) => {
if (window.confirm(`Apakah Anda yakin ingin menghapus dokumen tipe "${namaTipe}"?`)) {
try {
const orgId = getActiveOrgId();
const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/delete-expetation-type", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
id,
nama_tipe: namaTipe, // UBAH: konsisten gunakan nama_tipe
...(orgId ? { organization_id: orgId } : {}),
}),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
if (result.success) {
setDocumentTypes((prev) => prev.filter((d) => d.id !== id));
alert(`Dokumen tipe "${namaTipe}" berhasil dihapus.`);
} else {
console.error("Server reported failure:", result);
alert(`Gagal menghapus dokumen tipe "${namaTipe}": ${result.message || "Respon tidak menunjukkan keberhasilan."}`);
}
} catch (error) {
console.error("Error deleting document type:", error);
alert(`Terjadi kesalahan saat menghapus dokumen tipe "${namaTipe}". Detail: ${error.message}`);
} finally {
setIsEditMode(false);
}
}
};
// Buat tipe dokumen baru (POST body + header X-Organization-Id)
const handleNewDocumentSubmit = async (documentName, expectationObj) => {
try {
const orgId = getActiveOrgId();
const resp = await fetch("https://bot.kediritechnopark.com/webhook/create-data-type", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
nama_tipe: documentName, // UBAH: konsisten gunakan nama_tipe
expectation: expectationObj,
...(orgId ? { organization_id: orgId } : {}),
}),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`HTTP ${resp.status} ${resp.statusText} - ${text}`);
}
await fetchDocumentTypes();
alert(`Dokumen tipe "${documentName}" berhasil dibuat.`);
} catch (error) {
console.error("Error submitting new document type:", error);
alert(`Terjadi kesalahan saat membuat dokumen tipe "${documentName}".`);
}
};
// Edit tipe dokumen (POST body + header X-Organization-Id)
const handleEditDocumentSubmit = async (id, documentName, expectationObj) => {
try {
const orgId = getActiveOrgId();
const resp = await fetch("https://bot.kediritechnopark.com/webhook/edit-data-type", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
id,
nama_tipe: documentName,
expectation: expectationObj,
...(orgId ? { organization_id: orgId } : {}),
}),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`HTTP ${resp.status} ${resp.statusText} - ${text}`);
}
// Asumsi berhasil jika respons HTTP OK dan tidak ada error lain
await resp.json(); // Tetap baca JSON untuk memastikan respons selesai
await fetchDocumentTypes();
alert(`Dokumen tipe "${documentName}" berhasil diperbarui.`);
window.location.reload(); // Refresh halaman setelah berhasil
} catch (err) {
console.error("Error update:", err);
alert(`Terjadi kesalahan saat update dokumen: ${err.message || "Silakan cek konsol untuk detail lebih lanjut."}`);
}
};
const handleDocumentTypeSelection = (item) => {
if (!item) return;
if (item === "new") {
setShowNewDocumentModal(true);
} else {
onSelect?.(item);
}
};
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.reload();
};
// Tutup menu ketika klik di luar
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Colors for different document types
const getDocumentColors = (index) => {
const colors = [
{ bg: '#E8F4FF', icon: '#2563EB' },
{ bg: '#FFF7E6', icon: '#F59E0B' },
{ bg: '#F0FDF4', icon: '#10B981' },
{ bg: '#F3E8FF', icon: '#8B5CF6' },
{ bg: '#FEF2F2', icon: '#EF4444' },
{ bg: '#F0F9FF', icon: '#0EA5E9' },
{ bg: '#FDF4FF', icon: '#D946EF' },
{ bg: '#F7FEE7', icon: '#65A30D' },
];
return colors[index % colors.length];
};
return (
<div className={styles.dashboardContainer}>
{/* Dashboard Header */}
<div className={styles.dashboardHeader}>
<div className={styles.logoAndTitle}>
<img src="/ikasapta.png" alt="Bot Avatar" />
<h1 className={styles.h1}>SOLID</h1>
<h1 className={`${styles.h1} ${styles.h1Accent}`}>DATA</h1>
</div>
<div className={styles.dropdownContainer} ref={menuRef}>
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={styles.dropdownToggle}
aria-expanded={isMenuOpen ? "true" : "false"}
aria-haspopup="true"
>
<svg
width="15"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{isMenuOpen && (
<div className={styles.dropdownMenu}>
{/* Dashboard */}
<button
onClick={() => {
navigate("/dashboard");
setIsMenuOpen(false);
}}
className={styles.dropdownItem}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<span>Dashboard</span>
</button>
{/* Scan */}
<button
onClick={() => {
navigate("/scan");
setIsMenuOpen(false);
}}
className={styles.dropdownItem}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<rect x="7" y="7" width="10" height="10" rx="1" />
</svg>
<span>Scan</span>
</button>
{/* Logout */}
<button
onClick={() => {
handleLogout();
setIsMenuOpen(false);
}}
className={styles.dropdownItem}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>Logout</span>
</button>
</div>
)}
</div>
</div>
{/* Main Content - Dashboard Style */}
<div className={styles.mainContent}>
<div className={expetationStyles.dashboardContainer}>
{/* Header dengan Edit Button */}
<div className={expetationStyles.dashboardHeaderWithEdit}>
<div className={expetationStyles.dashboardHeaderText}>
<h2 className={expetationStyles.dashboardHeader}>
Document Types
</h2>
<p className={expetationStyles.paragraf}>
Choose a document type to scan or create a new one
</p>
</div>
</div>
{/* Dashboard Grid */}
<div className={expetationStyles.dashboardGrid}>
{/* Add New Document Type Button */}
<button
onClick={() => setShowNewDocumentModal(true)}
className={expetationStyles.dashboardCard}
>
<div className={`${expetationStyles.dashboardIconContainer} ${expetationStyles.dashboardIconAdd}`}>
<Plus size={28} />
</div>
<div className={expetationStyles.dashboardCardText}>
<div className={expetationStyles.dashboardCardTitle}>
Add New
</div>
<div className={expetationStyles.dashboardCardSubtitle}>
Document Type
</div>
</div>
</button>
{/* Loading State */}
{loadingDocumentTypes ? (
<div className={expetationStyles.dashboardCard}>
<div className={expetationStyles.spinnerContainer}>
<div className={expetationStyles.spinner} />
</div>
</div>
) : (
/* User Created Document Types */
documentTypes.map((doc, index) => {
const displayInfo = getDocumentDisplayInfo(doc);
const colors = getDocumentColors(index);
return (
<div key={doc.id} className={expetationStyles.documentCardWrapper}>
<button
onClick={() => handleDocumentTypeSelection(doc)}
className={expetationStyles.dashboardCard}
>
{/* Icon */}
<div
className={expetationStyles.dashboardIconContainer}
style={{
backgroundColor: colors.bg,
color: colors.icon
}}
>
<FolderOpen size={28} />
</div>
{/* Text */}
<div className={expetationStyles.dashboardCardText}>
<div className={expetationStyles.dashboardCardTitle}>
{displayInfo.name}
</div>
<div className={expetationStyles.dashboardCardSubtitle}>
Document Type
</div>
</div>
</button>
{/* Edit and Delete Buttons (only visible in edit mode) */}
{isEditMode && (
<div className={expetationStyles.editDeleteWrapper}>
<button
className={expetationStyles.editIcon}
onClick={() => {
setEditingDocument(doc);
setShowEditDocumentModal(true);
}}
title="Edit document type"
>
</button>
<button
className={expetationStyles.deleteIcon}
onClick={() => handleDeleteDocumentType(doc.id, doc.nama_tipe)}
title="Delete document type"
>
×
</button>
</div>
)}
</div>
);
})
)}
</div>
{/* Edit Button - Positioned at Bottom */}
<button
onClick={() => setIsEditMode(!isEditMode)}
className={expetationStyles.editButton}
>
{isEditMode ? "Done" : "Edit"}
</button>
</div>
</div>
{/* Footer */}
<div className={styles.footer}>
© 2025 Kediri Technopark Dashboard SOLID DATA
</div>
{/* Modals */}
<NewDocumentModal
isOpen={showNewDocumentModal}
onClose={() => setShowNewDocumentModal(false)}
onSubmit={handleNewDocumentSubmit}
/>
<EditDocumentModal
isOpen={showEditDocumentModal}
onClose={() => setShowEditDocumentModal(false)}
document={editingDocument}
onSubmit={handleEditDocumentSubmit}
/>
</div>
);
};
export default Expetation;

773
src/Expetation.module.css Normal file
View File

@@ -0,0 +1,773 @@
/* ============================
Dashboard Main Styles
============================ */
.dashboardContainer {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.dashboardHeaderWithEdit {
display: flex;
flex-direction: column;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #E5E7EB;
}
.dashboardHeaderText {
width: 100%;
text-align: center;
}
.dashboardHeader {
font-size: 32px;
font-weight: 700;
color: #1F2937;
margin: 0 0 12px 0;
line-height: 1.2;
letter-spacing: -0.02em;
}
.paragraf {
font-size: 16px;
color: #6B7280;
margin: 0;
line-height: 1.5;
font-weight: 400;
max-width: 600px;
margin: 0 auto;
}
/* Edit button akan ditempatkan di bawah grid */
.editButton {
background-color: #F8FAFC;
color: #475569;
padding: 12px 24px;
border-radius: 12px;
border: 1px solid #E2E8F0;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
min-width: 100px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
align-self: center;
margin-top: 20px;
}
.editButton:hover {
background-color: #F1F5F9;
border-color: #CBD5E1;
color: #334155;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.editButton:active {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
/* FIXED: Grid dengan spacing yang lebih rapi */
.dashboardGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 20px;
margin-bottom: 30px;
padding: 0;
}
/* FIXED: Card dengan design yang lebih premium */
.dashboardCard {
background-color: white;
border: 1px solid #E5E7EB;
border-radius: 16px;
padding: 24px;
text-align: center;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
height: 160px;
width: 100%;
position: relative;
box-sizing: border-box;
}
.dashboardCard:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #C7D2FE;
}
.dashboardCard:active {
transform: translateY(-1px);
}
/* FIXED: Icon container dengan design yang lebih modern */
.dashboardIconContainer {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* Updated colors dengan gradient subtle */
.dashboardIconBlue {
background: linear-gradient(135deg, #DBEAFE 0%, #BFDBFE 100%);
color: #1E40AF;
}
.dashboardIconYellow {
background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%);
color: #D97706;
}
.dashboardIconGreen {
background: linear-gradient(135deg, #D1FAE5 0%, #A7F3D0 100%);
color: #059669;
}
.dashboardIconPurple {
background: linear-gradient(135deg, #EDE9FE 0%, #DDD6FE 100%);
color: #7C3AED;
}
.dashboardIconAdd {
background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%);
color: #6B7280;
border: 2px dashed #D1D5DB;
box-shadow: none;
}
.dashboardIconAdd:hover {
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
border-color: #3B82F6;
color: #1D4ED8;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
}
/* FIXED: Text dengan typography yang lebih baik */
.dashboardCardText {
text-align: center;
width: 100%;
overflow: hidden;
}
.dashboardCardTitle {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 6px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
letter-spacing: -0.02em;
}
.dashboardCardSubtitle {
font-size: 12px;
color: #9CA3AF;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Document Card Wrapper untuk Edit Mode - Design yang lebih clean */
.documentCardWrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
}
.deleteIcon {
position: absolute;
top: -10px;
right: -10px;
background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%);
color: white;
border-radius: 50%;
width: 28px;
height: 28px;
font-size: 18px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
border: 3px solid white;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
z-index: 10;
transition: all 0.2s ease;
font-weight: 600;
}
.deleteIcon:hover {
background: linear-gradient(135deg, #DC2626 0%, #B91C1C 100%);
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
}
/* Loading Spinner - FIXED untuk mengikuti ukuran card */
.spinnerContainer {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #007bff;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ============================
Modal Styles
============================ */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background-color: white;
border-radius: 16px;
width: 90%;
max-width: 600px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-height: 85vh;
overflow-y: auto;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 0 20px;
border-bottom: 1px solid #e9ecef;
margin-bottom: 20px;
}
.modalTitle {
margin: 0;
font-size: 18px;
font-weight: bold;
color: #333;
}
.modalCloseButton {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: gray;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.modalCloseButton:hover {
color: #333;
}
.modalContent {
padding: 0 20px 20px 20px;
}
.modalSection {
margin-bottom: 25px;
}
.sectionLabel {
display: block;
margin-bottom: 15px;
font-weight: bold;
color: #333;
font-size: 16px;
}
.modalLabel {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #333;
font-size: 14px;
}
.modalInput {
width: 100%;
padding: 12px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 16px;
outline: none;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.modalInput:focus {
border-color: #007bff;
}
.templateGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 12px;
}
.templateCard {
background-color: #f8f9fa;
border-radius: 12px;
padding: 15px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.templateCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.templateCardActive {
border-color: #007bff;
background-color: #e3f2fd;
}
.customTemplateCard {
background-color: #fff3cd;
}
.customTemplateActive {
border-color: #ffc107;
background-color: #fff3cd;
}
.templateContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.templateIconContainer {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #e0f7fa;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.2s ease;
}
.templateIconActive {
background-color: #007bff;
color: white;
}
.customIconActive {
background-color: #ffc107;
color: white;
}
.templateName {
font-size: 12px;
font-weight: bold;
color: #333;
text-align: center;
}
.modalFooter {
display: flex;
gap: 10px;
padding: 20px;
border-top: 1px solid #e9ecef;
}
.cancelButton {
flex: 1;
padding: 12px;
border: 2px solid #e9ecef;
border-radius: 8px;
background-color: white;
cursor: pointer;
font-size: 16px;
font-weight: bold;
color: #666;
transition: all 0.2s ease;
}
.cancelButton:hover {
border-color: #007bff;
color: #007bff;
}
.submitButton {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: background-color 0.2s ease;
}
.submitButton:hover:not(:disabled) {
background-color: #0056b3;
}
.submitButton:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
/* ============================
Expectation Form Styles
============================ */
.expectationFormContainer {
margin-top: 10px;
}
.fieldRow {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 8px;
}
.fieldInput {
flex: 2;
padding: 10px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.3s ease;
}
.fieldInput:focus {
border-color: #007bff;
}
.fieldSelect {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
background-color: white;
cursor: pointer;
}
.fieldSelect:focus {
border-color: #007bff;
}
.removeFieldButton {
background-color: #dc3545;
color: white;
border: none;
border-radius: 6px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.2s ease;
}
.removeFieldButton:hover {
background-color: #c82333;
}
.addFieldButton {
background-color: #007bff;
color: white;
border: none;
border-radius: 8px;
padding: 10px 15px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-top: 10px;
transition: background-color 0.2s ease;
}
.addFieldButton:hover {
background-color: #0056b3;
}
/* ============================
Legacy Selection Styles (for backward compatibility)
============================ */
.selectionContainer {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 140px);
padding: 20px;
box-sizing: border-box;
}
.selectionContent {
background-color: white;
border-radius: 16px;
padding: 30px;
text-align: center;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
max-width: 600px;
width: 100%;
}
.selectionHeader {
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.selectionTitle {
font-size: 15px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
.selectionSubtitle {
font-size: 10px;
color: #666;
margin-bottom: 30px;
}
.documentGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 20px;
justify-content: center;
}
.documentCard {
background-color: #f8f9fa;
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
border: 1px solid #e9ecef;
transition: transform 0.2s, box-shadow 0.2s;
}
.documentCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.documentIconContainer {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #e0f7fa;
display: flex;
justify-content: center;
align-items: center;
}
.documentIconContainerFilled {
background-color: #f0f0f0;
}
.documentIcon {
font-size: 30px;
}
.plusIcon {
font-size: 40px;
color: #43a0a7;
font-weight: 200;
}
.documentLabel {
font-size: 15px;
font-weight: bold;
color: #333;
text-transform: capitalize;
}
.paragraf {
font-size: 16px;
color: #666;
margin-bottom: 30px;
line-height: 1.5;
}
/* ============================
Responsive Design - UPDATED untuk design yang lebih rapi
============================ */
@media (max-width: 768px) {
.dashboardContainer {
padding: 16px;
}
.dashboardHeaderWithEdit {
margin-bottom: 24px;
padding-bottom: 16px;
}
.dashboardHeader {
font-size: 26px;
}
.paragraf {
font-size: 15px;
}
.dashboardGrid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.dashboardCard {
height: 140px;
padding: 20px;
}
.dashboardIconContainer {
width: 44px;
height: 44px;
border-radius: 12px;
}
.dashboardCardTitle {
font-size: 14px;
}
.dashboardCardSubtitle {
font-size: 11px;
}
.editButton {
padding: 10px 20px;
font-size: 13px;
min-width: 80px;
margin-top: 16px;
}
.deleteIcon {
width: 24px;
height: 24px;
font-size: 16px;
top: -8px;
right: -8px;
}
}
@media (max-width: 480px) {
.dashboardContainer {
padding: 12px;
}
.dashboardHeader {
font-size: 24px;
}
.paragraf {
font-size: 14px;
}
.dashboardGrid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.dashboardCard {
height: 130px;
padding: 16px;
}
.dashboardIconContainer {
width: 40px;
height: 40px;
border-radius: 10px;
}
.dashboardCardTitle {
font-size: 13px;
}
.dashboardCardSubtitle {
font-size: 10px;
}
.editButton {
padding: 8px 16px;
font-size: 12px;
min-width: 70px;
margin-top: 12px;
}
.deleteIcon {
width: 22px;
height: 22px;
font-size: 14px;
top: -6px;
right: -6px;
}
}

View File

@@ -1,429 +1,381 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import styles from "./FileListComponent.module.css";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { saveAs } from "file-saver";
import styles from "./FileListComponent.module.css";
const FileListComponent = ({ const FileListComponent = ({
setTotalFilesSentToday, setOfficerPerformanceData, // optional: kirim seri ke parent (compat)
setTotalFilesSentMonth, onTypesLoaded, // NEW: kirim daftar tipe & seri agregat per tipe
setTotalFilesSentOverall, onPerformanceReady, // NEW: kirim seri per-bulan saat tipe dibuka
setOfficerPerformanceData,
}) => { }) => {
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState(null); const [selectedType, setSelectedType] = useState(null);
const [successMessage, setSuccessMessage] = useState(""); const [entries, setEntries] = useState([]);
const [loadingEntries, setLoadingEntries] = useState(false);
const [selectedEntry, setSelectedEntry] = useState(null);
const [showModal, setShowModal] = useState(false);
const getOrganizationId = () => {
try {
const orgData = localStorage.getItem("selected_organization");
if (!orgData) return null;
const parsed = JSON.parse(orgData);
return parsed.organization_id || null;
} catch (e) {
console.error("Gagal membaca organization_id:", e);
return null;
}
};
// 👉 Download Excel dari daftar entries yang sedang dibuka
const downloadExcel = () => {
if (!entries.length) {
alert("Tidak ada data untuk diunduh.");
return;
}
const flattened = entries.map((e) => ({
nama_tipe: e.nama_tipe,
...e.data,
}));
const worksheet = XLSX.utils.json_to_sheet(flattened);
// Auto width kolom
const objectMaxLength = [];
flattened.forEach((row) => {
Object.keys(row).forEach((key, colIndex) => {
const value = row[key] ? row[key].toString() : "";
objectMaxLength[colIndex] = Math.max(
objectMaxLength[colIndex] || key.length,
value.length
);
});
});
worksheet["!cols"] = objectMaxLength.map((w) => ({ wch: w + 2 }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
const excelBuffer = XLSX.write(workbook, { bookType: "xlsx", type: "array" });
const blob = new Blob([excelBuffer], { type: "application/octet-stream" });
saveAs(blob, `data_${selectedType?.nama || "dokumen"}.xlsx`);
};
// Fetch daftar tipe dari webhook /files
useEffect(() => { useEffect(() => {
const fetchFiles = async () => { const fetchFiles = async () => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const orgId = getOrganizationId();
try { try {
const response = await fetch( const response = await fetch(
"https://bot.kediritechnopark.com/webhook/files", "https://bot.kediritechnopark.com/webhook/solid-data/files",
{ {
method: "GET", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ organization_id: orgId }),
} }
); );
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const text = await response.text(); const text = await response.text();
if (!text) { if (!text) {
throw new Error("Server membalas kosong."); setFiles([]);
return;
} }
const data = JSON.parse(text); let data;
try {
if (!data.success || !Array.isArray(data.data)) { data = JSON.parse(text);
throw new Error("Format respons tidak valid."); } catch (err) {
console.error("Respons bukan JSON valid:", text);
setFiles([]);
return;
} }
const fileData = data.data; const fileData = Array.isArray(data) ? data : data?.data || [];
// 1. Set ke state
setFiles(fileData); setFiles(fileData);
// 2. Hitung total file hari ini // === Kirim daftar tipe & seri agregat per tipe ke parent ===
const today = new Date().toISOString().slice(0, 10); const typeOptions = fileData.map((f) => ({
const totalToday = fileData.filter((f) => id: f.data_type_id,
f.created_at.startsWith(today) name: f.nama_tipe,
).length; }));
setTotalFilesSentToday(totalToday);
// 3. Hitung total bulan ini const byTypeSeries = fileData.map((f) => ({
const now = new Date(); label: f.nama_tipe, // sumbu X
const currentMonth = now.getMonth(); count: Number(f.total_entries || 0), // tinggi bar
const currentYear = now.getFullYear(); }));
const totalThisMonth = fileData.filter((f) => {
const d = new Date(f.created_at);
return (
d.getMonth() === currentMonth && d.getFullYear() === currentYear
);
}).length;
setTotalFilesSentMonth(totalThisMonth);
// 4. Total keseluruhan if (typeof onTypesLoaded === "function") {
setTotalFilesSentOverall(fileData.length); onTypesLoaded(typeOptions, byTypeSeries);
// 5. Grafik performa per bulan (dinamis)
const dateObjects = fileData.map((item) => new Date(item.created_at));
if (dateObjects.length > 0) {
const minDate = new Date(Math.min(...dateObjects));
const maxDate = new Date(Math.max(...dateObjects));
const monthlyDataMap = {};
let current = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
const end = new Date(maxDate.getFullYear(), maxDate.getMonth(), 1);
while (current <= end) {
const monthKey = `${current.getFullYear()}-${String(
current.getMonth() + 1
).padStart(2, "0")}`;
monthlyDataMap[monthKey] = 0;
current.setMonth(current.getMonth() + 1);
} }
// (opsional) kompatibel: tampilkan juga langsung di grafik
fileData.forEach((item) => { if (typeof setOfficerPerformanceData === "function") {
const d = new Date(item.created_at); setOfficerPerformanceData(byTypeSeries);
const monthKey = `${d.getFullYear()}-${String(
d.getMonth() + 1
).padStart(2, "0")}`;
if (monthlyDataMap[monthKey] !== undefined) {
monthlyDataMap[monthKey]++;
} }
}); } catch (e) {
console.error("Gagal fetch files:", e);
const performanceArray = Object.entries(monthlyDataMap).map( setFiles([]);
([month, count]) => {
const [year, monthNum] = month.split("-");
const dateObj = new Date(`${month}-01`);
const label = new Intl.DateTimeFormat("id-ID", {
month: "long",
year: "numeric",
}).format(dateObj); // hasil: "Juli 2025"
return { month: label, count };
}
);
setOfficerPerformanceData(performanceArray);
}
} catch (error) {
console.error("Gagal mengambil data dari server:", error.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchFiles(); fetchFiles();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const formatPhoneNumber = (phone) => // Fetch entries per data_type_id (saat user klik suatu tipe)
phone?.replace(/(\d{4})(\d{4})(\d{4})/, "$1-$2-$3"); const fetchEntries = async (dataTypeId, nama_tipe, entryName, expectation) => {
const handleRowClick = async (file) => { let resolvedEntryName = entryName;
const token = localStorage.getItem("token"); if (!resolvedEntryName && expectation && Object.keys(expectation).length > 0) {
resolvedEntryName = Object.keys(expectation)[0];
if (!token) {
alert("Token tidak ditemukan. Silakan login kembali.");
return;
} }
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
setEntries([]);
setLoadingEntries(true);
try { try {
const token = localStorage.getItem("token");
const response = await fetch( const response = await fetch(
`https://bot.kediritechnopark.com/webhook/6915ea36-e1f4-49ad-a7f1-a27ce0bf2279/ktp/${file.nik}`, "https://bot.kediritechnopark.com/webhook/solid-data/files/entry",
{ {
method: "GET", method: "POST",
headers: { headers: {
Authorization: token, // atau `Bearer ${token}` jika diperlukan
"Content-Type": "application/json", "Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ data_type_id: dataTypeId }),
} }
); );
if (!response.ok) { if (!response.ok) throw new Error("Gagal ambil entries");
throw new Error(`HTTP error! Status: ${response.status}`);
}
const text = await response.text(); const text = await response.text();
if (!text) { let data;
throw new Error("Respons kosong dari server."); try {
data = JSON.parse(text);
} catch (e) {
console.error("Respons bukan JSON valid:", text);
data = [];
} }
const data = JSON.parse(text); const entryList = Array.isArray(data) ? data : data?.data || [];
if (data.error) { // Fallback nama field untuk judul kartu
alert(data.error); if (!resolvedEntryName && entryList.length > 0) {
return; resolvedEntryName = Object.keys(entryList[0].data || {})[0] || null;
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
} }
const item = data[0]; setEntries(entryList);
if (!item) { // ====== Hitung seri per-bulan utk tipe yang dibuka ======
alert("Data tidak ditemukan."); const parseDate = (v) => {
return; if (!v) return null;
const d = new Date(v);
return isNaN(d) ? null : d;
};
const pickDate = (entry) => {
const cands = [
entry.created_at,
entry.updated_at,
entry.data?.tanggal,
entry.data?.tgl,
entry.data?.date,
entry.data?.created_at,
];
for (const c of cands) {
const d = parseDate(c);
if (d) return d;
}
return null;
};
const mmKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const counts = new Map();
for (const e of entryList) {
const d = pickDate(e);
if (!d) continue;
const k = mmKey(d);
counts.set(k, (counts.get(k) || 0) + 1);
} }
// Validasi jika ada image URL const monthlySeries = Array.from(counts.entries())
if (item.foto_url && !item.foto_url.match(/\.(jpg|jpeg|png|webp)$/i)) { .sort((a, b) => (a[0] < b[0] ? -1 : 1))
console.warn( .map(([k, v]) => ({ label: k, count: v })); // "label" = YYYY-MM
"URL foto bukan format gambar yang didukung:",
item.foto_url
);
}
setSelectedFile(item); // tampilkan di modal misalnya if (typeof onPerformanceReady === "function") {
} catch (error) { onPerformanceReady(nama_tipe, monthlySeries);
console.error("Gagal mengambil detail:", error.message || error);
alert("Gagal mengambil detail. Pastikan data tersedia.");
} }
// (opsional) bila ingin langsung tampilkan seri bulanan ini
// if (typeof setOfficerPerformanceData === "function") {
// setOfficerPerformanceData(monthlySeries);
// }
} catch (err) {
console.error("Gagal fetch entries:", err);
setEntries([]);
} finally {
setLoadingEntries(false);
}
};
const openEntryModal = (entry) => {
setSelectedEntry(entry);
setShowModal(true);
}; };
const closeModal = () => { const closeModal = () => {
setSelectedFile(null); setShowModal(false);
setSelectedEntry(null);
}; };
const exportToExcel = (data) => { const backToTypes = () => {
const domain = window.location.origin; setSelectedType(null);
setEntries([]);
// Step 1: Transform data
const modifiedData = data.map((item) => ({
ID: item.id,
NIK: item.nik,
Nama: item.nama_lengkap,
NoHP: item.no_hp,
Email: item.email,
Tanggal_Lahir: new Date(item.tanggal_lahir).toLocaleString("id-ID", {
day: "2-digit",
month: "long",
year: "numeric",
}),
CreatedAt: new Date(item.created_at).toLocaleString("id-ID", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
ImageURL: `${domain}/${item.nik}`, // Will become a hyperlink
}));
// Step 2: Create worksheet from data
const worksheet = XLSX.utils.json_to_sheet(modifiedData);
// Step 3: Add hyperlinks to ImageURL column
modifiedData.forEach((item, index) => {
const cellAddress = `G${index + 2}`; // G column, +2 because of header
if (worksheet[cellAddress]) {
worksheet[cellAddress].l = {
Target: item.ImageURL,
Tooltip: "View Image",
};
}
});
// Step 4: Optional - add column widths
worksheet["!cols"] = [
{ wch: 5 }, // ID
{ wch: 15 }, // NIK
{ wch: 25 }, // Nama
{ wch: 15 }, // NoHP
{ wch: 30 }, // Email
{ wch: 25 }, // CreatedAt
{ wch: 40 }, // ImageURL
];
// Step 5: Optional - enable filter and freeze header
worksheet["!autofilter"] = { ref: "A1:G1" };
// Step 6: Create and export workbook
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
XLSX.writeFile(workbook, "data-export.xlsx");
}; };
if (loading) {
return ( return (
<div className={styles.fileListSection}> <div className={styles.container}>
<div className={styles.emptyState}> <h2 className={styles.title}>📑 Daftar Jenis Dokumen</h2>
{!selectedType ? (
<>
{loading ? (
<div className={styles.loading}>
<div className={styles.spinner}></div> <div className={styles.spinner}></div>
<div className={styles.emptyStateTitle}>Memuat file...</div> <p>Sedang memuat...</p>
</div> </div>
</div> ) : files.length === 0 ? (
);
}
return (
<div className={styles.fileListSection}>
<div className={styles.fileListHeader}>
<h2 className={styles.fileListTitle}>📁 Daftar Anggota</h2>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<button
onClick={() => {
exportToExcel(files);
}}
className={styles.downloadButton}
>
Unduh Excel
</button>
<span className={styles.fileCount}>{files.length} file tersedia</span>
</div>
</div>
{successMessage && (
<div className={styles.successMessage}>
<span></span>
{successMessage}
</div>
)}
<div className={styles.tableContainer}>
{files.length === 0 ? (
<div className={styles.emptyState}> <div className={styles.emptyState}>
<div className={styles.emptyStateTitle}>Belum ada data</div> <div className={styles.emptyStateTitle}>Belum ada data</div>
<p className={styles.emptyStateText}> <p className={styles.emptyStateText}>
Tidak ada data KTP yang tersedia saat ini. Tidak ada jenis dokumen yang tersedia saat ini.
</p> </p>
</div> </div>
) : ( ) : (
<table className={styles.fileTable}> <ul className={styles.typeList}>
<thead>
<tr>
<th>ID</th>
<th>NIK</th>
<th className={styles.nameColumn}>Nama Lengkap</th>
<th>No. HP</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{files.map((file, index) => ( {files.map((file, index) => (
<tr <li
key={file.id} key={file.data_type_id}
onClick={() => handleRowClick(file)} className={styles.typeItem}
className={styles.tableRow} onClick={() =>
fetchEntries(
file.data_type_id,
file.nama_tipe,
file.entry_name,
file.expectation
)
}
> >
<td>{index + 1}</td> <div className={styles.typeInfo}>
<td>{file.nik}</td> <div className={styles.typeNumber}>{index + 1}</div>
<td className={styles.nameColumn}>{file.nama_lengkap}</td> <div className={styles.typeDetails}>
<td>{formatPhoneNumber(file.no_hp)}</td> <div className={styles.typeName}>{file.nama_tipe}</div>
<td>{file.email}</td> <div className={styles.typeCount}>
</tr> {file.total_entries} data tersedia
</div>
</div>
</div>
<div className={styles.typeArrow}></div>
</li>
))} ))}
</tbody> </ul>
</table> )}
</>
) : (
<div className={styles.entrySection}>
<button className={styles.backButton} onClick={backToTypes}>
Kembali ke Daftar Jenis
</button>
<h3 className={styles.entryTitle}>
📂 Isi Dokumen: {selectedType.nama}
</h3>
{/* 👉 Tombol Download Excel */}
{entries.length > 0 && (
<button className={styles.downloadButton} onClick={downloadExcel}>
Download Excel
</button>
)}
{loadingEntries ? (
<div className={styles.loading}>
<div className={styles.spinner}></div>
<p>Sedang memuat data...</p>
</div>
) : entries.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyStateTitle}>Belum ada entry</div>
<p className={styles.emptyStateText}>
Belum ada entry untuk dokumen ini.
</p>
</div>
) : (
<ul className={styles.entryList}>
{entries.map((entry, index) => (
<li
key={entry.data_id}
className={styles.entryItem}
onClick={() => openEntryModal(entry)}
>
<div className={styles.entryInfo}>
<div className={styles.entryNumber}>{index + 1}</div>
<div className={styles.entryDetails}>
<div className={styles.entryName}>
{entry.data?.[selectedType.entryName] || "Data tidak tersedia"}
</div>
<div className={styles.entryHint}>
Klik untuk melihat detail lengkap
</div>
</div>
</div>
<div className={styles.entryArrow}></div>
</li>
))}
</ul>
)} )}
</div> </div>
{/* Modal Detail */}
{selectedFile && (
<div className={styles.modalOverlay} onClick={closeModal}>
<div
className={styles.modalContent}
onClick={(e) => e.stopPropagation()}
>
{/* Foto KTP */}
{selectedFile.data && (
<img
src={`data:image/jpeg;base64,${selectedFile.data}`}
alt={`Foto KTP - ${selectedFile.nik}`}
style={{
width: "100%",
maxHeight: "300px",
objectFit: "contain",
marginBottom: "1rem",
borderRadius: "8px",
boxShadow: "0 2px 6px rgba(0,0,0,0.2)",
}}
/>
)} )}
<h3>🪪 Detail Data KTP</h3> {/* Modal untuk detail entry */}
<table className={styles.detailTable}> {showModal && selectedEntry && (
<tbody> <div className={styles.modalOverlay} onClick={closeModal}>
<tr> <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<td>NIK</td> <div className={styles.modalHeader}>
<td>{selectedFile.nik}</td> <h3 className={styles.modalTitle}>
</tr> Detail Data:{" "}
<tr> {selectedEntry.data?.[selectedType.entryName] ||
<td>Nama Lengkap</td> selectedEntry.data?.nama ||
<td>{selectedFile.nama_lengkap}</td> selectedEntry.data?.name ||
</tr> "Data"}
<tr> </h3>
<td>Tempat Lahir</td>
<td>{selectedFile.tempat_lahir}</td>
</tr>
<tr>
<td>Tanggal Lahir</td>
<td>{selectedFile.tanggal_lahir}</td>
</tr>
<tr>
<td>Jenis Kelamin</td>
<td>{selectedFile.jenis_kelamin}</td>
</tr>
<tr>
<td>Alamat</td>
<td>{selectedFile.alamat}</td>
</tr>
<tr>
<td>RT/RW</td>
<td>{selectedFile.rt_rw}</td>
</tr>
<tr>
<td>Kelurahan/Desa</td>
<td>{selectedFile.kel_desa}</td>
</tr>
<tr>
<td>Kecamatan</td>
<td>{selectedFile.kecamatan}</td>
</tr>
<tr>
<td>Agama</td>
<td>{selectedFile.agama}</td>
</tr>
<tr>
<td>Status Perkawinan</td>
<td>{selectedFile.status_perkawinan}</td>
</tr>
<tr>
<td>Pekerjaan</td>
<td>{selectedFile.pekerjaan}</td>
</tr>
<tr>
<td>Kewarganegaraan</td>
<td>{selectedFile.kewarganegaraan}</td>
</tr>
<tr>
<td>No HP</td>
<td>{selectedFile.no_hp}</td>
</tr>
<tr>
<td>Email</td>
<td>{selectedFile.email}</td>
</tr>
<tr>
<td>Berlaku Hingga</td>
<td>{selectedFile.berlaku_hingga}</td>
</tr>
<tr>
<td>Tanggal Pembuatan</td>
<td>{selectedFile.pembuatan}</td>
</tr>
<tr>
<td>Kota Pembuatan</td>
<td>{selectedFile.kota_pembuatan}</td>
</tr>
</tbody>
</table>
<button className={styles.closeButton} onClick={closeModal}> <button className={styles.closeButton} onClick={closeModal}>
Tutup ×
</button> </button>
</div> </div>
<div className={styles.modalContent}>
<div className={styles.detailGrid}>
{Object.entries(selectedEntry.data || {}).map(([key, value]) => (
<div key={key} className={styles.detailItem}>
<div className={styles.detailLabel}>
{key
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</div>
<div className={styles.detailValue}>{value || "-"}</div>
</div>
))}
</div>
</div>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,169 +1,95 @@
/* FileListComponent.module.css - Updated to match Dashboard design */ /* FileListComponent.module.css - Brand Blue/Indigo & Mobile-First */
/* Use the same color palette as Dashboard */ /* ===== CSS Variables ===== */
:root { :root {
--primary-blue: #3b82f6; --brand-primary: #2961eb;
--secondary-blue: #60a5fa; --brand-primary-700: #1d4ed8;
--dark-blue: #1e40af; --brand-secondary: #4f46e5;
--neutral-50: #fafafa; --brand-secondary-700: #4338ca;
--neutral-100: #f5f5f5; --brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
--neutral-200: #e5e5e5;
--neutral-300: #d4d4d4; --neutral-25: #fcfcfd;
--neutral-500: #737373; --neutral-50: #f9fafb;
--neutral-700: #404040; --neutral-100: #f3f4f6;
--neutral-800: #262626; --neutral-200: #e5e7eb;
--neutral-900: #171717; --neutral-300: #d1d5db;
--neutral-400: #9ca3af;
--neutral-600: #475569;
--neutral-700: #374151;
--neutral-800: #1f2937;
--white: #ffffff; --white: #ffffff;
--success-green: #10b981;
--warning-amber: #f59e0b;
--error-red: #ef4444;
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #64748b; --text-secondary: #64748b;
--text-light: #ffffff; --text-on-brand: #ffffff;
--border-light: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --border-light: #e5e7eb;
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --shadow-sm: 0 1px 2px rgba(0,0,0,.06);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), --shadow-md: 0 4px 10px rgba(2,6,23,.08);
0 4px 6px -4px rgb(0 0 0 / 0.1); --shadow-lg: 0 12px 22px rgba(2,6,23,.12);
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
} }
/* File List Section */ /* ===== Container ===== */
.fileListSection { .container {
background-color: var(--white); background: var(--white);
padding: 2rem;
border-radius: 1rem;
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: 1rem;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
margin: 2rem auto; padding: 1.5rem;
max-width: 1200px;
width: 100%;
overflow: hidden;
max-height: 600px;
height: auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
transition: all 0.2s ease;
}
.fileListSection:hover {
box-shadow: var(--shadow-md);
}
.fileListHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
flex-shrink: 0;
width: 100%;
}
.fileListTitle {
margin: 0; margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.025em;
}
.fileCount {
font-size: 0.875rem;
color: #ffffff;
font-weight: 500;
background-color: #ef4444;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
border: 1px solid var(--border-light);
}
.successMessage {
background-color: rgb(16 185 129 / 0.1);
color: var(--success-green);
border: 1px solid rgb(16 185 129 / 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
width: 100%; width: 100%;
min-width: 0;
overflow: hidden;
box-sizing: border-box;
} }
.tableContainer { /* ===== Title ===== */
flex: 1; .title {
overflow: auto; margin: 0 0 1.5rem 0;
border-radius: 0.75rem; font-size: clamp(1.125rem, 1vw + 0.8rem, 1.5rem);
border: 1px solid var(--border-light); font-weight: 800;
background-color: var(--white); letter-spacing: -0.015em;
width: 100%; color: var(--neutral-800);
background: var(--brand-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
.fileTable { /* ===== Loading State ===== */
width: 100%; .loading {
min-width: 600px;
table-layout: auto;
border-collapse: collapse;
font-size: 0.875rem;
background-color: var(--white);
}
.fileTable th {
background-color: #ef4444;
padding: 0.75rem;
text-align: center; text-align: center;
font-weight: 600; padding: 3rem 1rem;
color: #ffffff; color: var(--text-secondary);
border-bottom: 1px solid var(--border-light);
white-space: nowrap;
position: sticky;
top: 0;
z-index: 1;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.fileTable td { .spinner {
padding: 0.75rem; width: 2rem;
border-bottom: 1px solid var(--border-light); height: 2rem;
color: var(--text-primary); border: 3px solid var(--neutral-300);
vertical-align: middle; border-top: 3px solid var(--brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
} }
.tableRow { @keyframes spin {
cursor: pointer; to { transform: rotate(360deg); }
transition: background-color 0.2s ease;
}
.tableRow:hover {
background-color: var(--neutral-50);
}
.nameColumn {
font-weight: 500;
color: var(--text-primary);
min-width: 200px;
} }
/* ===== Empty State ===== */
.emptyState { .emptyState {
text-align: center; text-align: center;
padding: 3rem 2rem; padding: 3rem 1rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
.emptyStateTitle { .emptyStateTitle {
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 700;
color: var(--neutral-800);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 600;
} }
.emptyStateText { .emptyStateText {
@@ -172,327 +98,514 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.spinner { /* ===== Type List ===== */
width: 2rem; .typeList {
height: 2rem; list-style: none;
border: 3px solid var(--neutral-300); margin: 0;
border-top: 3px solid #ef4444; padding: 0;
border: 1px solid var(--border-light);
border-radius: 0.75rem;
background: var(--white);
overflow: hidden;
}
.typeItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.15s ease;
min-height: 60px;
}
.typeItem:last-child {
border-bottom: none;
}
.typeItem:hover {
background: rgba(79, 70, 229, 0.05);
transform: translateX(4px);
}
.typeInfo {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.typeNumber {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--brand-gradient);
color: var(--text-on-brand);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; font-size: 0.75rem;
margin: 0 auto 1rem; font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(41, 97, 235, 0.2);
} }
@keyframes spin { .typeDetails {
0% { display: flex;
transform: rotate(0deg); flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.typeName {
font-weight: 700;
color: var(--neutral-800);
font-size: 0.95rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.typeCount {
font-size: 0.8rem;
color: var(--text-secondary);
font-style: italic;
}
.typeArrow {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
flex-shrink: 0;
}
/* ===== Entry Section ===== */
.entrySection {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
} }
100% { to {
transform: rotate(360deg); opacity: 1;
transform: translateY(0);
} }
} }
/* Custom Scrollbar */ .backButton {
.tableContainer::-webkit-scrollbar { background: var(--neutral-600);
color: var(--text-on-brand);
border: none;
padding: 0.75rem 1.25rem;
border-radius: 0.6rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 700;
margin-bottom: 1.5rem;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 2px 4px rgba(71, 85, 105, 0.2);
}
.backButton:hover {
background: var(--neutral-700);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(71, 85, 105, 0.3);
}
.entryTitle {
margin: 0 0 1rem 0;
font-size: clamp(1rem, 0.9vw + 0.7rem, 1.25rem);
font-weight: 700;
color: var(--neutral-800);
}
/* ===== Entry List ===== */
.entryList {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border-light);
border-radius: 0.75rem;
background: var(--white);
max-height: 400px;
overflow-y: auto;
}
.entryItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.15s ease;
min-height: 60px;
}
.entryItem:last-child {
border-bottom: none;
}
.entryItem:hover {
background: rgba(41, 97, 235, 0.05);
transform: translateX(4px);
}
.entryInfo {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.entryNumber {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--brand-gradient);
color: var(--text-on-brand);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);
}
.entryDetails {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.entryName {
font-weight: 700;
color: var(--neutral-800);
font-size: 0.9rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entryHint {
font-size: 0.75rem;
color: var(--text-secondary);
font-style: italic;
}
.entryArrow {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
flex-shrink: 0;
}
/* ===== Scrollbar Styling ===== */
.entryList::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px;
} }
.tableContainer::-webkit-scrollbar-track { .entryList::-webkit-scrollbar-track {
background: var(--neutral-100); background: var(--neutral-100);
border-radius: 4px; border-radius: 4px;
} }
.tableContainer::-webkit-scrollbar-thumb { .entryList::-webkit-scrollbar-thumb {
background: #ef4444; background: var(--neutral-300);
border-radius: 4px; border-radius: 4px;
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.tableContainer::-webkit-scrollbar-thumb:hover { .entryList::-webkit-scrollbar-thumb:hover {
background: #dc2626; background: var(--neutral-400);
} }
.tableContainer::-webkit-scrollbar-corner { /* ===== Modal ===== */
background: var(--neutral-100);
}
.tableContainer {
scrollbar-width: thin;
scrollbar-color: #ef4444 var(--neutral-100);
}
/* Modal Styles - Matching Dashboard Design */
.modalOverlay { .modalOverlay {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
justify-content: center;
z-index: 1000; z-index: 1000;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
padding: 1rem;
box-sizing: border-box;
animation: fadeIn 0.3s ease-out;
} }
.modalContent { @keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: var(--white); background: var(--white);
padding: 2rem;
border-radius: 1rem; border-radius: 1rem;
max-width: 600px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
}
.modalContent h3 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.025em;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-light);
}
.detailTable {
width: 100%; width: 100%;
border-collapse: collapse; max-width: 900px;
margin-bottom: 1.5rem; max-height: 85vh;
font-size: 0.875rem; overflow: hidden;
text-align: left; animation: modalSlideIn 0.3s ease-out;
display: flex;
flex-direction: column;
} }
.detailTable tr:nth-child(even) { @keyframes modalSlideIn {
background-color: var(--neutral-50); from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
} }
.detailTable td { .modalHeader {
padding: 0.75rem; display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1.5rem 0 1.5rem;
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light);
vertical-align: top; padding-bottom: 1rem;
margin-bottom: 1.5rem;
flex-shrink: 0;
} }
.detailTable td:first-child { .modalTitle {
font-weight: 600; font-size: clamp(1.125rem, 1vw + 0.8rem, 1.375rem);
color: var(--text-secondary); font-weight: 800;
width: 35%; color: var(--neutral-800);
text-transform: uppercase; margin: 0;
font-size: 0.75rem; letter-spacing: -0.015em;
letter-spacing: 0.05em; overflow: hidden;
} text-overflow: ellipsis;
white-space: nowrap;
.detailTable td:last-child { padding-right: 1rem;
color: var(--text-primary);
font-weight: 500;
} }
.closeButton { .closeButton {
background-color: #ef4444; background: #ef4444;
color: var(--text-light); color: var(--text-on-brand);
border: none; border: none;
padding: 0.75rem 1.5rem; border-radius: 50%;
border-radius: 0.5rem; width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 1.25rem;
font-weight: 600; font-weight: 700;
width: 100%; transition: all 0.15s ease;
transition: all 0.2s ease; flex-shrink: 0;
letter-spacing: 0.025em; box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
} }
.closeButton:hover { .closeButton:hover {
background-color: #dc2626; background: #dc2626;
transform: translateY(-1px); transform: scale(1.05);
box-shadow: var(--shadow-md); box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
} }
.closeButton:active { .modalContent {
transform: translateY(0); padding: 0 1.5rem 1.5rem 1.5rem;
overflow-y: auto;
flex: 1;
} }
/* Responsive Design */ .detailGrid {
@media (max-width: 768px) { display: grid;
.fileListSection { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
padding: 1.5rem; gap: 1rem;
margin: 1rem; }
max-width: 100%;
height: auto;
max-height: 70vh;
}
.fileListHeader { .detailItem {
display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; gap: 0.375rem;
gap: 0.75rem; }
.detailLabel {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detailValue {
font-size: 0.875rem;
color: var(--neutral-800);
font-weight: 600;
padding: 0.75rem;
background: var(--neutral-50);
border-radius: 0.5rem;
border: 1px solid var(--border-light);
min-height: 44px;
display: flex;
align-items: center;
word-break: break-word;
overflow-wrap: break-word;
}
/* ===== Responsive Design ===== */
/* Mobile First - Already defined above */
/* Tablet */
@media (min-width: 768px) {
.container {
padding: 2rem;
margin: 2rem auto;
max-width: 1200px;
} }
.fileListTitle { .typeItem,
font-size: 1.125rem; .entryItem {
padding: 1.25rem;
min-height: 70px;
} }
/* Mobile: Show only NIK and Name columns */ .typeNumber,
.fileTable { .entryNumber {
min-width: 100%; width: 36px;
} height: 36px;
.fileTable th:not(:nth-child(2)):not(:nth-child(3)),
.fileTable td:not(:nth-child(2)):not(:nth-child(3)) {
display: none;
}
.fileTable th,
.fileTable td {
padding: 0.75rem 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
.fileTable th:nth-child(2) { .typeName,
width: 40%; .entryName {
font-size: 1rem;
} }
.fileTable th:nth-child(3) { .modalHeader {
width: 60%; padding: 2rem 2rem 0 2rem;
margin-bottom: 2rem;
} }
.nameColumn {
min-width: unset;
}
/* Modal responsive */
.modalContent { .modalContent {
padding: 1.5rem; padding: 0 2rem 2rem 2rem;
width: 95%;
max-height: 90vh;
border-radius: 0.75rem;
}
.modalContent h3 {
font-size: 1.125rem;
}
.detailTable {
font-size: 0.8125rem;
}
.detailTable td {
padding: 0.625rem 0.5rem;
}
.detailTable td:first-child {
width: 40%;
} }
} }
/* Desktop */
@media (min-width: 1024px) {
.container {
padding: 2.5rem;
margin: 2.5rem auto;
}
.detailGrid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.entryList {
max-height: 500px;
}
}
/* Small Mobile */
@media (max-width: 480px) { @media (max-width: 480px) {
.fileListSection { .container {
padding: 1rem; padding: 1rem;
margin: 0.5rem; margin: 0.5rem;
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.fileListTitle { .typeItem,
font-size: 1rem; .entryItem {
padding: 0.75rem;
min-height: 56px;
} }
.fileCount { .typeNumber,
font-size: 0.75rem; .entryNumber {
padding: 0.25rem 0.5rem; width: 28px;
height: 28px;
font-size: 0.7rem;
} }
.fileTable th, .typeName,
.fileTable td { .entryName {
padding: 0.5rem 0.375rem; font-size: 0.85rem;
font-size: 0.8125rem;
} }
.modalContent { .modalHeader {
padding: 1rem; padding: 1rem 1rem 0 1rem;
width: 98%;
border-radius: 0.5rem;
}
.modalContent h3 {
font-size: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.detailTable { .modalContent {
font-size: 0.75rem; padding: 0 1rem 1rem 1rem;
} }
.detailTable td { .detailGrid {
padding: 0.5rem 0.375rem; grid-template-columns: 1fr;
gap: 0.75rem;
} }
.closeButton { .detailValue {
padding: 0.625rem 1rem; padding: 0.625rem;
font-size: 0.8125rem; font-size: 0.8125rem;
} }
}
/* Tablet and Desktop enhancements */
@media (min-width: 769px) {
.fileListSection {
padding: 2.5rem;
margin: 2.5rem auto;
}
.fileListTitle {
font-size: 1.5rem;
}
.fileTable th,
.fileTable td {
padding: 1rem 0.75rem;
}
.modalContent {
padding: 2.5rem;
border-radius: 1rem;
}
.modalContent h3 {
font-size: 1.5rem;
margin-bottom: 2rem;
}
.detailTable {
font-size: 0.875rem;
}
.detailTable td {
padding: 1rem 0.75rem;
}
.closeButton { .closeButton {
padding: 0.875rem 2rem; width: 32px;
display: block; height: 32px;
font-size: 1.1rem;
} }
} }
@media (min-width: 1024px) { /* Prevent text selection on interactive elements */
.fileListSection { .typeItem,
padding: 3rem; .entryItem,
margin: 3rem auto; .backButton,
.closeButton {
user-select: none;
}
/* Focus styles for accessibility */
.typeItem:focus,
.entryItem:focus,
.backButton:focus,
.closeButton:focus {
outline: none;
box-shadow: var(--focus-ring);
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.typeNumber,
.entryNumber {
border: 2px solid var(--text-primary);
}
.modal {
border: 2px solid var(--text-primary);
} }
} }
.downloadButton {
background-color: #00adef;
color: white;
border: none;
padding: 6px 12px;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
transition: background-color 0.3s ease;
}
.downloadButton:hover {
background-color: #008fc4;
}

View File

@@ -1,276 +1,276 @@
import React, { useEffect, useState } from "react"; // import React, { useEffect, useState } from "react";
const fieldLabels = { // const fieldLabels = {
nik: "NIK", // nik: "NIK",
fullName: "Nama Lengkap", // fullName: "Nama Lengkap",
birthPlace: "Tempat Lahir", // birthPlace: "Tempat Lahir",
birthDate: "Tanggal Lahir", // birthDate: "Tanggal Lahir",
gender: "Jenis Kelamin", // gender: "Jenis Kelamin",
address: "Alamat", // address: "Alamat",
neighborhoodCode: "RT/RW", // neighborhoodCode: "RT/RW",
village: "Kelurahan/Desa", // village: "Kelurahan/Desa",
subDistrict: "Kecamatan", // subDistrict: "Kecamatan",
religion: "Agama", // religion: "Agama",
maritalStatus: "Status Perkawinan", // maritalStatus: "Status Perkawinan",
occupation: "Pekerjaan", // occupation: "Pekerjaan",
nationality: "Kewarganegaraan", // nationality: "Kewarganegaraan",
validUntil: "Berlaku Hingga", // validUntil: "Berlaku Hingga",
issuedCity: "Kota Terbit", // issuedCity: "Kota Terbit",
issuedDate: "Tanggal Terbit", // issuedDate: "Tanggal Terbit",
phoneNumber: "No. HP", // phoneNumber: "No. HP",
email: "Email", // email: "Email",
}; // };
function Modal({ isOpen, onClose, loading, fileTemp, onSave, onDelete }) { // function Modal({ isOpen, onClose, loading, fileTemp, onSave, onDelete }) {
const [formData, setFormData] = useState({}); // const [formData, setFormData] = useState({});
const [step, setStep] = useState(0); // const [step, setStep] = useState(0);
// ❗Field yang disembunyikan, bisa diisi sesuai kebutuhan // // ❗Field yang disembunyikan, bisa diisi sesuai kebutuhan
const disabledFields = []; // const disabledFields = [];
useEffect(() => { // useEffect(() => {
if (fileTemp) { // if (fileTemp) {
setFormData(Array.isArray(fileTemp) ? fileTemp[0] : fileTemp); // setFormData(Array.isArray(fileTemp) ? fileTemp[0] : fileTemp);
setStep(0); // setStep(0);
} else { // } else {
setFormData({}); // setFormData({});
} // }
}, [fileTemp]); // }, [fileTemp]);
if (!isOpen) return null; // if (!isOpen) return null;
const handleChange = (key, newValue, isDate = false) => { // const handleChange = (key, newValue, isDate = false) => {
setFormData((prev) => ({ // setFormData((prev) => ({
...prev, // ...prev,
[key]: isDate ? { ...prev[key], value: newValue } : newValue, // [key]: isDate ? { ...prev[key], value: newValue } : newValue,
})); // }));
}; // };
const formatDate = (value) => { // const formatDate = (value) => {
if (!value) return ""; // if (!value) return "";
const d = new Date(value); // const d = new Date(value);
return isNaN(d) ? "" : d.toISOString().split("T")[0]; // return isNaN(d) ? "" : d.toISOString().split("T")[0];
}; // };
const renderInput = (key, value) => { // const renderInput = (key, value) => {
if (value && typeof value === "object" && value.type === "dateTime") { // if (value && typeof value === "object" && value.type === "dateTime") {
return ( // return (
<input // <input
type="date" // type="date"
value={formatDate(value.value)} // value={formatDate(value.value)}
onChange={(e) => handleChange(key, e.target.value, true)} // onChange={(e) => handleChange(key, e.target.value, true)}
style={styles.input} // style={styles.input}
/> // />
); // );
} // }
if (key === "address") { // if (key === "address") {
return ( // return (
<textarea // <textarea
rows={2} // rows={2}
value={value || ""} // value={value || ""}
onChange={(e) => handleChange(key, e.target.value)} // onChange={(e) => handleChange(key, e.target.value)}
style={{ ...styles.input, resize: "vertical" }} // style={{ ...styles.input, resize: "vertical" }}
/> // />
); // );
} // }
return ( // return (
<input // <input
type="text" // type="text"
value={value != null ? value : ""} // value={value != null ? value : ""}
onChange={(e) => handleChange(key, e.target.value)} // onChange={(e) => handleChange(key, e.target.value)}
style={styles.input} // style={styles.input}
/> // />
); // );
}; // };
// Langkah-langkah form (per halaman) // // Langkah-langkah form (per halaman)
const rawSteps = [ // const rawSteps = [
["nik", "fullName", "birthPlace", "birthDate"], // ["nik", "fullName", "birthPlace", "birthDate"],
["gender", "address", "neighborhoodCode", "village", "subDistrict"], // ["gender", "address", "neighborhoodCode", "village", "subDistrict"],
["religion", "maritalStatus", "occupation"], // ["religion", "maritalStatus", "occupation"],
[ // [
"nationality", // "nationality",
"validUntil", // "validUntil",
"issuedCity", // "issuedCity",
"issuedDate", // "issuedDate",
"phoneNumber", // "phoneNumber",
"email", // "email",
], // ],
]; // ];
// Filter field yang disable/hide // // Filter field yang disable/hide
const steps = rawSteps.map((fields) => // const steps = rawSteps.map((fields) =>
fields.filter((key) => !disabledFields.includes(key)) // fields.filter((key) => !disabledFields.includes(key))
); // );
// Filter langkah kosong // // Filter langkah kosong
const visibleSteps = steps.filter((step) => step.length > 0); // const visibleSteps = steps.filter((step) => step.length > 0);
return ( // return (
<div style={styles.overlay} onClick={onClose}> // <div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}> // <div style={styles.modal} onClick={(e) => e.stopPropagation()}>
{loading ? ( // {loading ? (
<div style={styles.spinnerContainer}> // <div style={styles.spinnerContainer}>
<div style={styles.spinner} /> // <div style={styles.spinner} />
<style>{spinnerStyle}</style> // <style>{spinnerStyle}</style>
</div> // </div>
) : ( // ) : (
Object.keys(formData).length > 0 && ( // Object.keys(formData).length > 0 && (
<> // <>
<h4> // <h4>
Verifikasi Data (Langkah {step + 1} dari {visibleSteps.length}) // Verifikasi Data (Langkah {step + 1} dari {visibleSteps.length})
</h4> // </h4>
<table style={styles.table}> // <table style={styles.table}>
<tbody> // <tbody>
{visibleSteps[step].map((key) => ( // {visibleSteps[step].map((key) => (
<tr key={key} style={styles.tableRow}> // <tr key={key} style={styles.tableRow}>
<td style={styles.tableLabel}> // <td style={styles.tableLabel}>
{fieldLabels[key] || key} // {fieldLabels[key] || key}
</td> // </td>
<td style={styles.tableInput}> // <td style={styles.tableInput}>
{renderInput(key, formData[key])} // {renderInput(key, formData[key])}
</td> // </td>
</tr> // </tr>
))} // ))}
</tbody> // </tbody>
</table> // </table>
<div // <div
style={{ // style={{
display: "flex", // display: "flex",
justifyContent: "space-between", // justifyContent: "space-between",
marginTop: 10, // marginTop: 10,
}} // }}
> // >
<button // <button
disabled={step === 0} // disabled={step === 0}
onClick={() => setStep((s) => s - 1)} // onClick={() => setStep((s) => s - 1)}
style={{ // style={{
...styles.saveButton, // ...styles.saveButton,
opacity: step === 0 ? 0.5 : 1, // opacity: step === 0 ? 0.5 : 1,
}} // }}
> // >
&lt; Sebelumnya // &lt; Sebelumnya
</button> // </button>
<button // <button
disabled={step === visibleSteps.length - 1} // disabled={step === visibleSteps.length - 1}
onClick={() => setStep((s) => s + 1)} // onClick={() => setStep((s) => s + 1)}
style={{ // style={{
...styles.saveButton, // ...styles.saveButton,
opacity: step === visibleSteps.length - 1 ? 0.5 : 1, // opacity: step === visibleSteps.length - 1 ? 0.5 : 1,
}} // }}
> // >
Selanjutnya &gt; // Selanjutnya &gt;
</button> // </button>
</div> // </div>
<div style={styles.actions}> // <div style={styles.actions}>
<button // <button
onClick={() => onSave(formData)} // onClick={() => onSave(formData)}
style={styles.saveButton} // style={styles.saveButton}
> // >
Simpan ke Galeri // Simpan ke Galeri
</button> // </button>
<button onClick={onDelete} style={styles.deleteButton}> // <button onClick={onDelete} style={styles.deleteButton}>
Hapus // Hapus
</button> // </button>
</div> // </div>
</> // </>
) // )
)} // )}
</div> // </div>
</div> // </div>
); // );
} // }
// Styles dan spinner animation // // Styles dan spinner animation
const styles = { // const styles = {
overlay: { // overlay: {
position: "fixed", // position: "fixed",
inset: 0, // inset: 0,
backgroundColor: "rgba(0,0,0,0.5)", // backgroundColor: "rgba(0,0,0,0.5)",
display: "flex", // display: "flex",
justifyContent: "center", // justifyContent: "center",
alignItems: "center", // alignItems: "center",
zIndex: 1000, // zIndex: 1000,
}, // },
modal: { // modal: {
backgroundColor: "white", // backgroundColor: "white",
borderRadius: 8, // borderRadius: 8,
padding: 20, // padding: 20,
minWidth: 350, // minWidth: 350,
maxWidth: "90vw", // maxWidth: "90vw",
maxHeight: "80vh", // maxHeight: "80vh",
overflowY: "auto", // overflowY: "auto",
boxShadow: "0 2px 10px rgba(0,0,0,0.3)", // boxShadow: "0 2px 10px rgba(0,0,0,0.3)",
}, // },
spinnerContainer: { // spinnerContainer: {
textAlign: "center", // textAlign: "center",
padding: 40, // padding: 40,
}, // },
spinner: { // spinner: {
border: "4px solid #f3f3f3", // border: "4px solid #f3f3f3",
borderTop: "4px solid #3498db", // borderTop: "4px solid #3498db",
borderRadius: "50%", // borderRadius: "50%",
width: 40, // width: 40,
height: 40, // height: 40,
animation: "spin 1s linear infinite", // animation: "spin 1s linear infinite",
margin: "0 auto", // margin: "0 auto",
}, // },
table: { // table: {
width: "100%", // width: "100%",
borderCollapse: "collapse", // borderCollapse: "collapse",
}, // },
tableRow: { // tableRow: {
borderBottom: "1px solid #eee", // borderBottom: "1px solid #eee",
}, // },
tableLabel: { // tableLabel: {
padding: "8px 10px", // padding: "8px 10px",
fontWeight: "bold", // fontWeight: "bold",
width: "30%", // width: "30%",
verticalAlign: "top", // verticalAlign: "top",
textTransform: "capitalize", // textTransform: "capitalize",
}, // },
tableInput: { // tableInput: {
padding: "8px 10px", // padding: "8px 10px",
}, // },
input: { // input: {
padding: 6, // padding: 6,
borderRadius: 4, // borderRadius: 4,
border: "1px solid #ccc", // border: "1px solid #ccc",
width: "100%", // width: "100%",
}, // },
actions: { // actions: {
marginTop: 20, // marginTop: 20,
textAlign: "right", // textAlign: "right",
}, // },
saveButton: { // saveButton: {
marginRight: 10, // marginRight: 10,
backgroundColor: "green", // backgroundColor: "green",
color: "white", // color: "white",
border: "none", // border: "none",
padding: "8px 14px", // padding: "8px 14px",
borderRadius: 4, // borderRadius: 4,
cursor: "pointer", // cursor: "pointer",
}, // },
deleteButton: { // deleteButton: {
backgroundColor: "red", // backgroundColor: "red",
color: "white", // color: "white",
border: "none", // border: "none",
padding: "8px 14px", // padding: "8px 14px",
borderRadius: 4, // borderRadius: 4,
cursor: "pointer", // cursor: "pointer",
}, // },
}; // };
const spinnerStyle = ` // const spinnerStyle = `
@keyframes spin { // @keyframes spin {
0% { transform: rotate(0deg); } // 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } // 100% { transform: rotate(360deg); }
} // }
`; // `;
export default Modal; // export default Modal;

123
src/KTPPDF.js Normal file
View File

@@ -0,0 +1,123 @@
// components/KTPPDF.js
import React from "react";
import FileListComponent from "./FileListComponent";
import {
Page,
Text,
Image,
Document,
StyleSheet,
View,
} from "@react-pdf/renderer";
const styles = StyleSheet.create({
page: { padding: 30, fontSize: 12 },
section: { marginBottom: 10 },
title: { fontSize: 18, marginBottom: 10 },
image: {
width: 180,
height: 120,
marginBottom: 10,
objectFit: "contain",
border: "1 solid #000",
},
label: { fontWeight: "bold" },
});
const getImageSrc = (base64) => {
if (!base64) return null;
const cleaned = base64.replace(/\s/g, "");
if (cleaned.startsWith("iVBOR")) {
return `data:image/png;base64,${cleaned}`;
} else if (cleaned.startsWith("/9j/")) {
return `data:image/jpeg;base64,${cleaned}`;
} else if (cleaned.startsWith("UklGR")) {
return `data:image/webp;base64,${cleaned}`;
} else {
return `data:image/*;base64,${cleaned}`;
}
};
const KTPPDF = ({ data }) => (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.title}>Biodata Anggota</Text>
{data.data ? (
<Image style={styles.image} src={getImageSrc(data.data)} />
) : data.fallbackImage ? (
<Image style={styles.image} src={data.fallbackImage} />
) : (
<Text>Tidak ada foto KTP tersedia</Text>
)}
<View style={styles.section}>
<Text>
<Text style={styles.label}>NIK:</Text> {data.nik}
</Text>
<Text>
<Text style={styles.label}>Nama Lengkap:</Text> {data.nama_lengkap}
</Text>
<Text>
<Text style={styles.label}>Tempat Lahir:</Text>{" "}
{data.tempat_lahir || "-"}
</Text>
<Text>
<Text style={styles.label}>Tanggal Lahir:</Text>{" "}
{data.tanggal_lahir || "-"}
</Text>
<Text>
<Text style={styles.label}>Jenis Kelamin:</Text>{" "}
{data.jenis_kelamin || "-"}
</Text>
<Text>
<Text style={styles.label}>Alamat:</Text> {data.alamat || "-"}
</Text>
<Text>
<Text style={styles.label}>RT/RW:</Text> {data.rt_rw || "-"}
</Text>
<Text>
<Text style={styles.label}>Kel/Desa:</Text> {data.kel_desa || "-"}
</Text>
<Text>
<Text style={styles.label}>Kecamatan:</Text> {data.kecamatan || "-"}
</Text>
<Text>
<Text style={styles.label}>Agama:</Text> {data.agama || "-"}
</Text>
<Text>
<Text style={styles.label}>Status Perkawinan:</Text>{" "}
{data.status_perkawinan || "-"}
</Text>
<Text>
<Text style={styles.label}>Pekerjaan:</Text> {data.pekerjaan || "-"}
</Text>
<Text>
<Text style={styles.label}>Kewarganegaraan:</Text>{" "}
{data.kewarganegaraan || "-"}
</Text>
<Text>
<Text style={styles.label}>No HP:</Text> {data.no_hp || "-"}
</Text>
<Text>
<Text style={styles.label}>Email:</Text> {data.email || "-"}
</Text>
<Text>
<Text style={styles.label}>Berlaku Hingga:</Text>{" "}
{data.berlaku_hingga || "-"}
</Text>
<Text>
<Text style={styles.label}>Tanggal Pembuatan:</Text>{" "}
{data.pembuatan || "-"}
</Text>
<Text>
<Text style={styles.label}>Kota Pembuatan:</Text>{" "}
{data.kota_pembuatan || "-"}
</Text>
</View>
</Page>
</Document>
);
export default KTPPDF;

View File

@@ -1,6 +1,6 @@
.overlay-box { .overlay-box {
position: absolute; position: absolute;
border: 3px dashed red; border: 3px dashed #43a0a7;
width: 80%; /* atau sesuaikan */ width: 80%; /* atau sesuaikan */
aspect-ratio: 85.6 / 53.98; aspect-ratio: 85.6 / 53.98;
top: 50%; top: 50%;

File diff suppressed because it is too large Load Diff

View File

@@ -1,84 +1,53 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import {
User, Eye, EyeOff, Plus, X, RefreshCw, FileText, Users, Baby, Settings, LogOut, Camera
} from "lucide-react";
import styles from "./Login.module.css"; import styles from "./Login.module.css";
const Login = () => { /* ===========================================================
const [formData, setFormData] = useState({ LOGIN PAGE
username: "", =========================================================== */
password: "", export default function LoginPage({ onLoggedIn }) {
});
const [error, setError] = useState(""); const login = () => {
const baseUrl = "https://kediritechnopark.com/";
const modal = "product";
const productId = 9;
const handleChange = (e) => { const authorizedUri = "http://localhost:3000/dashboard?token=";
setFormData({ ...formData, [e.target.name]: e.target.value }); const unauthorizedUri = `${baseUrl}?modal=${modal}&product_id=${productId}`;
};
const handleSubmit = async (e) => { const url =
e.preventDefault(); `${baseUrl}?modal=${modal}&product_id=${productId}` +
`&authorized_uri=${encodeURIComponent(authorizedUri)}` +
`&unauthorized_uri=${encodeURIComponent(unauthorizedUri)}`;
try { window.location.href = url;
const loginResponse = await fetch(
"https://bot.kediritechnopark.com/webhook/login/psi",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
}
);
const loginDataRaw = await loginResponse.json();
const loginData = Array.isArray(loginDataRaw)
? loginDataRaw[0]
: loginDataRaw;
if (loginData?.success && loginData?.token) {
localStorage.setItem("token", loginData.token);
window.location.href = "/";
} else {
setError(loginData?.message || "Username atau password salah");
}
} catch (err) {
console.error("Login Error:", err);
setError("Gagal terhubung ke server");
}
}; };
return ( return (
<div className={styles.loginContainer}> <div className={styles.loginContainer}>
<div className={styles.loginBox}> <div className={styles.loginCard}>
<img src="/PSI.png" alt="Logo" className={styles.logo} /> {/* Logo/Brand */}
<h1 className={styles.h1}>Kawal PSI</h1>
<p className={styles.subtitle}> {/* Login Form */}
Silakan masuk untuk melanjutkan ke dashboard <div className={styles.loginForm}>
</p>
<form onSubmit={handleSubmit} className={styles.form}> <div className={styles.brandSection}>
<input <div className={styles.logoIcon}>
type="text" <FileText className={styles.logoIconSvg} />
name="username" </div>
placeholder="Username" <h1 className={styles.brandTitle}>SOLID DATA</h1>
value={formData.username} <p className={styles.brandSubtitle}>Kelola data dokumen Anda dengan mudah</p>
onChange={handleChange} </div>
className={styles.input} <button
/> className={styles.loginButton}
<input onClick={login}
type="password" >
name="password" Masuk
placeholder="Password"
value={formData.password}
onChange={handleChange}
className={styles.input}
/>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" className={styles.button}>
Login
</button> </button>
</form> </div>
<div className={styles.footer}>&copy; 2025 Kediri Technopark</div>
</div> </div>
</div> </div>
); );
}; }
export default Login;

File diff suppressed because it is too large Load Diff

163
src/PickOrganization.js Normal file
View File

@@ -0,0 +1,163 @@
// PickOrganization.js
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Building2, ArrowRight, Loader2, AlertCircle } from "lucide-react";
import styles from "./PickOrganization.module.css";
// ====== KONFIG BACKEND ======
// Webhook n8n untuk mengambil daftar organisasi berdasarkan token JWT
const LIST_ENDPOINT = "https://bot.kediritechnopark.com/webhook/soliddata/get-organization";
// Webhook n8n untuk memilih organisasi
const SELECT_ENDPOINT = "https://bot.kediritechnopark.com/webhook/soliddata/pick-organization";
// Fungsi GET organisasi dari backend N8N
async function getOrganizationsFromBackend() {
const token = localStorage.getItem("token");
if (!token) {
throw new Error("Token tidak ditemukan. Silakan login.");
}
const response = await fetch(LIST_ENDPOINT, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Gagal mengambil data organisasi. Status: ${response.status}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error("Respon bukan array. Format data organisasi tidak valid.");
}
return data;
}
export default function PickOrganization() {
const [orgs, setOrgs] = useState([]);
const [loading, setLoading] = useState(true);
const [posting, setPosting] = useState(false);
const [error, setError] = useState("");
const navigate = useNavigate();
// Load daftar organisasi dari backend menggunakan JWT
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
navigate("/login");
return;
}
(async () => {
setLoading(true);
setError("");
try {
const data = await getOrganizationsFromBackend();
setOrgs(data);
} catch (e) {
console.error(e);
setError(e.message || "Terjadi kesalahan saat memuat organisasi.");
} finally {
setLoading(false);
}
})();
}, [navigate]);
// Saat user memilih salah satu organisasi
const handleSelect = async (org) => {
const token = localStorage.getItem("token");
if (!token) {
navigate("/login");
return;
}
const chosen = {
organization_id: org.organization_id,
nama_organization: org.nama_organization,
};
// simpan lokal untuk dipakai di halaman lain
localStorage.setItem("selected_organization", JSON.stringify(chosen));
setPosting(true);
try {
await fetch(SELECT_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(chosen),
}).catch(() => {}); // abaikan error jaringan/timeout, tetap navigate
// Lanjut ke dashboard spesifik org
navigate(`/dashboard/${org.organization_id}`);
} finally {
setPosting(false);
}
};
if (loading) {
return (
<div className={styles.center}>
<Loader2 className={styles.spin} />
<span>Memuat organisasi</span>
</div>
);
}
if (error) {
return (
<div className={styles.errorWrap}>
<AlertCircle />
<div>
<h3>Gagal memuat organisasi</h3>
<p>{error}</p>
<button className={styles.retryBtn} onClick={() => window.location.reload()}>
Coba lagi
</button>
</div>
</div>
);
}
return (
<div className={styles.wrap}>
<h1 className={styles.title}>Pilih Organisasi</h1>
<p className={styles.subtitle}>Silakan pilih organisasi yang ingin Anda kelola.</p>
{orgs.length === 0 ? (
<div className={styles.empty}>
<Building2 />
<p>Tidak ada organisasi untuk akun ini.</p>
</div>
) : (
<div className={styles.grid}>
{orgs.map((org) => (
<button
key={org.organization_id}
className={styles.card}
onClick={() => handleSelect(org)}
disabled={posting}
aria-label={`Pilih organisasi ${org.nama_organization}`}
>
<div className={styles.cardIcon}><Building2 /></div>
<div className={styles.cardBody}>
<div className={styles.cardTitle}>{org.nama_organization}</div>
<div className={styles.cardMeta}>ID: {org.organization_id}</div>
</div>
<ArrowRight className={styles.cardArrow} />
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
.wrap { max-width: 840px; margin: 48px auto; padding: 0 16px; }
.title { font-size: 28px; font-weight: 700; margin-bottom: 8px; }
.subtitle { color: #666; margin-bottom: 24px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.card { display: flex; align-items: center; gap: 12px; border: 1px solid #eaeaea; border-radius: 14px; padding: 14px; background: #fff; cursor: pointer; transition: transform .08s ease, box-shadow .08s ease; }
.card:hover { box-shadow: 0 6px 18px rgba(0,0,0,0.06); transform: translateY(-1px); }
.card:disabled { opacity: 0.7; cursor: not-allowed; }
.cardIcon { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; background: #f5f7fb; }
.cardBody { text-align: left; flex: 1; }
.cardTitle { font-weight: 600; }
.cardMeta { font-size: 12px; color: #777; margin-top: 4px; }
.cardArrow { opacity: 0.7; }
.center { display: flex; gap: 8px; align-items: center; justify-content: center; height: 50vh; color: #333; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.errorWrap { max-width: 560px; margin: 80px auto; border: 1px solid #ffd7d7; background: #fff5f5; color: #7a1111; padding: 16px; border-radius: 12px; display: flex; gap: 12px; }
.retryBtn { margin-top: 8px; background:#111827; color:#fff; border:none; padding:8px 12px; border-radius:8px; cursor:pointer; }
.empty { display: grid; place-items: center; gap: 8px; height: 40vh; color: #555; }

260
src/ProfileTab.js Normal file
View File

@@ -0,0 +1,260 @@
import React, { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import dashboardStyles from "./Dashboard.module.css";
import profileStyles from "./ProfileTab.module.css";
const ProfileTab = () => {
const menuRef = useRef(null);
const navigate = useNavigate();
const [isEditing, setIsEditing] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [user, setUser] = useState({});
const [userTemp, setUserTemp] = useState({});
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.reload();
};
useEffect(() => {
const verifyTokenAndFetchData = async () => {
const token = localStorage.getItem("token");
if (!token) {
window.location.href = "/login";
return;
}
try {
const response = await fetch(
"https://bot.kediritechnopark.com/webhook/solid-data/dashboard",
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const data = await response.json();
if (!response.ok || !data[0].username) {
throw new Error("Unauthorized");
}
setUser(data[0]);
setUserTemp(data[0]);
} catch (error) {
console.error("Token tidak valid:", error.message);
localStorage.removeItem("token");
window.location.href = "/login";
}
};
verifyTokenAndFetchData();
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setUser((prev) => ({ ...prev, [name]: value }));
};
const handleSave = async () => {
try {
if (!user.oldPassword || !user.newPassword) {
alert("Password lama dan baru tidak boleh kosong.");
return;
}
const payload = {
username: user.username,
oldPassword: user.oldPassword,
newPassword: user.newPassword,
};
const response = await fetch(
"https://bot.kediritechnopark.com/webhook/solid-data/reset-password",
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify(payload),
}
);
if (!response.ok) throw new Error("Gagal menyimpan profil");
alert("Berhasil mengubah password");
setIsEditing(false);
} catch (error) {
console.error("Error saat menyimpan profil:", error);
alert("Terjadi kesalahan saat menyimpan profil.");
}
};
const handleCancel = () => {
setIsEditing(false);
setUser(userTemp);
};
return (
<div className={dashboardStyles.dashboardContainer}>
<div className={dashboardStyles.dashboardHeader}>
<div className={dashboardStyles.logoAndTitle}>
<img src="/ikasapta.png" alt="Bot Avatar" />
<h1 className={dashboardStyles.h1}>SOLID</h1>
<h1 className={dashboardStyles.h1} styles="color: #43a0a7;">
DATA
</h1>
</div>
<div className={dashboardStyles.dropdownContainer} ref={menuRef}>
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={dashboardStyles.dropdownToggle}
aria-expanded={isMenuOpen ? "true" : "false"}
aria-haspopup="true"
>
<svg
width="15"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{isMenuOpen && (
<div className={dashboardStyles.dropdownMenu}>
<button
onClick={() => {
navigate("/dashboard");
setIsMenuOpen(false);
}}
className={dashboardStyles.dropdownItem}
>
Dashboard
</button>
<button
onClick={() => {
navigate("/scan");
setIsMenuOpen(false);
}}
className={dashboardStyles.dropdownItem}
>
Scan
</button>
<button
onClick={() => {
handleLogout();
setIsMenuOpen(false);
}}
className={dashboardStyles.dropdownItem}
>
Logout
</button>
</div>
)}
</div>
</div>
<div className={profileStyles.mainContent}>
<div className={profileStyles.profileSection}>
<div className={profileStyles.profileCard}>
<div className={profileStyles.profileHeader}>
<h2>Account</h2>
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className={profileStyles.editButton}
>
Change Password
</button>
) : (
<div className={profileStyles.actionButtons}>
<button
onClick={handleCancel}
className={profileStyles.cancelButton}
>
Cancel
</button>
<button onClick={handleSave} className={profileStyles.saveButton}>
Save Changes
</button>
</div>
)}
</div>
<div className={profileStyles.profileForm}>
{!isEditing && (
<div className={profileStyles.inputGroup}>
<label className={profileStyles.inputLabel}>Username</label>
<input
type="text"
name="username"
value={user.username}
className={`${profileStyles.input} ${
!isEditing ? profileStyles.readOnly : ""
}`}
disabled
/>
</div>
)}
{isEditing && (
<>
<div className={profileStyles.inputGroup}>
<label className={profileStyles.inputLabel}>
Current Password
</label>
<input
type="password"
name="oldPassword"
onChange={handleChange}
className={profileStyles.input}
placeholder="Enter current password"
/>
</div>
<div className={profileStyles.inputGroup}>
<label className={profileStyles.inputLabel}>New Password</label>
<input
type="password"
name="newPassword"
onChange={handleChange}
className={profileStyles.input}
placeholder="Enter new password"
/>
</div>
</>
)}
</div>
</div>
</div>
</div>
<div className={dashboardStyles.footer}>
© 2025 Kediri Technopark Dashboard SOLID DATA
</div>
</div>
);
};
export default ProfileTab;

550
src/ProfileTab.module.css Normal file
View File

@@ -0,0 +1,550 @@
/* ProfileTab.module.css - Modern Design with Unified Header */
/* Modern Color Palette */
:root {
--primary-blue: #3b82f6;
--secondary-blue: #60a5fa;
--dark-blue: #1e40af;
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-300: #d4d4d4;
--neutral-500: #737373;
--neutral-700: #404040;
--neutral-800: #262626;
--neutral-900: #171717;
--white: #ffffff;
--success-green: #43a0a7;
--warning-amber: #f59e0b;
--error-red: #ef4444;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-light: #ffffff;
--border-light: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}
/* Base Styles */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
color: var(--text-primary);
background-color: var(--neutral-50);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.dashboardContainer {
background-color: var(--neutral-50);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* --- UNIFIED HEADER (sama dengan Dashboard.css) --- */
.dashboardHeader {
background-color: var(--white);
color: var(--text-primary);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
border-bottom: 3px solid #43a0a7; /* Warna dari Dashboard.css */
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(8px);
}
.logoAndTitle {
display: flex;
align-items: center;
flex-shrink: 0;
}
.logoAndTitle img {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
margin-right: 0.75rem;
object-fit: cover;
}
.dashboardHeader .h1 {
margin: 2px; /* Sama dengan Dashboard.css */
font-size: 1.5rem;
font-weight: 700;
color: #43a0a7; /* Warna dari Dashboard.css */
letter-spacing: -0.025em;
}
.data {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #154666;
letter-spacing: -0.025em;
}
/* Dropdown Menu */
.dropdownContainer {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.userDisplayName {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.875rem;
}
.dropdownToggle {
background-color: var(--neutral-100);
color: var(--text-primary);
border: 1px solid var(--border-light);
padding: 0.5rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
min-width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.dropdownToggle:hover {
background-color: var(--neutral-200);
border-color: var(--neutral-300);
}
.dropdownMenu {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background-color: var(--white);
border-radius: 0.75rem;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-light);
z-index: 10;
display: flex;
flex-direction: column;
min-width: 10rem;
overflow: hidden;
padding: 0.5rem;
}
.dropdownItem {
background: none;
border: none;
padding: 0.75rem 1rem;
text-align: left;
cursor: pointer;
color: var(--text-primary);
transition: background-color 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
margin-bottom: 0.125rem;
}
.dropdownItem:hover {
background-color: var(--neutral-100);
}
.dropdownItem:last-child {
margin-bottom: 0;
}
/* --- MAIN CONTENT --- */
.mainContent {
flex-grow: 1;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Profile Section */
.profileSection {
display: flex;
flex-direction: column;
gap: 2rem;
}
.profileCard {
background-color: var(--white);
padding: 2rem;
border-radius: 1rem;
border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.profileCard:hover {
box-shadow: var(--shadow-md);
}
.profileHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-light);
}
.profileHeader h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.025em;
}
.editButton {
background-color: #43a0a7; /* Diseragamkan dengan warna header */
color: var(--text-light);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s ease;
letter-spacing: 0.025em;
}
.editButton:hover {
background-color: #357734;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.actionButtons {
display: flex;
gap: 0.75rem;
}
.cancelButton {
background-color: var(--neutral-200);
color: var(--text-primary);
border: 1px solid var(--border-light);
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s ease;
letter-spacing: 0.025em;
}
.cancelButton:hover {
background-color: var(--neutral-300);
transform: translateY(-1px);
}
.saveButton {
background-color: var(--success-green);
color: var(--text-light);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s ease;
letter-spacing: 0.025em;
}
.saveButton:hover {
background-color: #059669;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
/* Profile Form */
.profileForm {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.inputLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 0.025em;
}
.input {
padding: 0.75rem 1rem;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
font-size: 0.875rem;
transition: all 0.2s ease;
background-color: var(--white);
color: var(--text-primary);
}
.input:focus {
border-color: var(--primary-blue);
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
outline: none;
}
.readOnly {
background-color: var(--neutral-50);
border-color: var(--neutral-200);
pointer-events: none;
color: var(--text-secondary);
}
/* License Section */
.licenseSection {
margin-top: 1rem;
}
.licenseSection h2 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.025em;
}
.licenseCards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.licenseCard {
background: linear-gradient(
135deg,
#43a0a7 0%,
#357734 100%
); /* Diseragamkan dengan warna header */
color: var(--text-light);
padding: 1.5rem;
border-radius: 1rem;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.licenseCard::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
pointer-events: none;
}
.licenseCard:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.licenseType,
.licenseNumber,
.licenseValidity {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.licenseType:last-child,
.licenseNumber:last-child,
.licenseValidity:last-child {
margin-bottom: 0;
border-bottom: none;
padding-bottom: 0;
}
.licenseLabel {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.8;
}
.licenseValue {
font-size: 0.875rem;
font-weight: 600;
text-align: right;
}
.licenseStatus {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.statusBadge {
display: inline-block;
background-color: rgba(255, 255, 255, 0.2);
color: var(--text-light);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
backdrop-filter: blur(8px);
}
/* Footer */
.footer {
background-color: var(--white);
color: var(--text-secondary);
text-align: center;
padding: 1rem;
margin-top: auto;
font-size: 0.75rem;
border-top: 1px solid var(--border-light);
}
/* --- RESPONSIVE DESIGN --- */
@media (min-width: 768px) {
.dashboardHeader {
padding: 1rem 2rem;
}
.logoAndTitle img {
width: 3rem;
height: 3rem;
}
.dashboardHeader .h1 {
font-size: 1.75rem;
}
.userDisplayName {
font-size: 0.875rem;
}
.mainContent {
padding: 2.5rem 2rem;
gap: 2.5rem;
}
.profileCard {
padding: 2.5rem;
}
.profileForm {
grid-template-columns: repeat(2, 1fr);
}
.profileHeader {
flex-direction: row;
align-items: center;
}
.actionButtons {
flex-direction: row;
}
}
@media (min-width: 1024px) {
.dashboardHeader {
padding: 1.25rem 3rem;
}
.logoAndTitle img {
width: 3.5rem;
height: 3.5rem;
}
.dashboardHeader .h1 {
font-size: 2rem;
}
.mainContent {
padding: 3rem 2.5rem;
gap: 3rem;
}
.profileCard {
padding: 3rem;
}
.licenseCards {
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
}
}
@media (max-width: 767px) {
.dashboardHeader {
padding: 1rem;
}
.logoAndTitle img {
width: 2rem;
height: 2rem;
}
.dashboardHeader .h1 {
font-size: 1.25rem;
}
.mainContent {
padding: 1.5rem 1rem;
gap: 1.5rem;
}
.profileCard {
padding: 1.5rem;
}
.profileHeader {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.actionButtons {
flex-direction: column;
gap: 0.5rem;
}
.licenseCards {
grid-template-columns: 1fr;
}
}

0
src/QWEN.md Normal file
View File

View File

@@ -18,7 +18,7 @@ const ShowImage = () => {
try { try {
const response = await fetch( const response = await fetch(
`https://bot.kediritechnopark.com/webhook/0f4420a8-8517-49ba-8ec5-75adde117813/ktp/img/${nik}`, `https://bot.kediritechnopark.com/webhook/ed467164-05c0-4692-bb81-a8f13116bb1b/ktp/img/ikasapta/:nik/${nik}`,
{ {
method: "GET", method: "GET",
headers: { headers: {

30
src/SuccessPage.js Normal file
View File

@@ -0,0 +1,30 @@
import React, { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
const SuccessPage = () => {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
const params = new URLSearchParams(location.search);
const token = params.get('token');
if (token) {
localStorage.setItem('token', token);
// Redirect to dashboard or another protected route after setting the token
navigate('/dashboard');
} else {
// Handle case where no token is present, maybe redirect to login
navigate('/login');
}
}, [location, navigate]);
return (
<div>
<h1>Processing your request...</h1>
<p>If you are not redirected automatically, please check your URL.</p>
</div>
);
};
export default SuccessPage;

View File

@@ -3,11 +3,7 @@ import React from "react";
import styles from "./Header.module.css"; import styles from "./Header.module.css";
const Header = () => { const Header = () => {
return (
<div className={styles.header}>
<div className={styles.title}>Officers & Roles</div>
</div>
);
}; };
export default Header; export default Header;

View File

@@ -1,11 +1,14 @@
// Sidebar.js // Sidebar.js
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import styles from "./Sidebar.module.css"; import styles from "./Sidebar.module.css";
const Sidebar = () => { const Sidebar = () => {
return ( return (
<div className={styles.sidebar}> <div className={styles.sidebar}>
<div className={styles.logo}>Dashboard</div> <Link to="/dashboard" className={styles.logo}>
Dashboard
</Link>
<div className={styles.menu}> <div className={styles.menu}>
<div className={styles.menuItem}>Officers</div> <div className={styles.menuItem}>Officers</div>
<div className={styles.menuItem}>Roles</div> <div className={styles.menuItem}>Roles</div>