Compare commits
10 Commits
2a725d8f27
...
e3f18d60ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f18d60ff | ||
|
|
28c4c4d66b | ||
|
|
e30b1a8de8 | ||
|
|
79914fb7ef | ||
|
|
afe9b24f56 | ||
|
|
3206db6010 | ||
|
|
0ee9f230d6 | ||
|
|
3d7b5332b3 | ||
|
|
cd1477855f | ||
|
|
d5a7f485f2 |
436
package-lock.json
generated
436
package-lock.json
generated
@@ -8,14 +8,18 @@
|
||||
"name": "workspace",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lucide-react": "^0.539.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"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": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
|
||||
@@ -3279,6 +3448,14 @@
|
||||
"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": {
|
||||
"version": "10.4.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.3.8",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
|
||||
@@ -4976,6 +5177,14 @@
|
||||
"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": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
||||
@@ -5086,11 +5295,27 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
||||
"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": {
|
||||
"version": "4.25.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
|
||||
@@ -5426,6 +5651,14 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
@@ -5778,6 +6020,11 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -7855,6 +8107,12 @@
|
||||
"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": {
|
||||
"version": "1.0.4",
|
||||
"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": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -8694,6 +8968,19 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
|
||||
@@ -8888,6 +9175,11 @@
|
||||
"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": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -9674,6 +9966,14 @@
|
||||
"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": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
|
||||
@@ -10784,6 +11084,23 @@
|
||||
"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": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -10878,6 +11195,15 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
@@ -11317,6 +11648,14 @@
|
||||
"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": {
|
||||
"version": "6.1.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||
@@ -11692,6 +12036,11 @@
|
||||
"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": {
|
||||
"version": "6.0.1",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -14029,6 +14395,11 @@
|
||||
"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": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
@@ -14675,6 +15046,19 @@
|
||||
"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": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
@@ -15328,6 +15712,11 @@
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -16070,6 +16464,15 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
|
||||
@@ -16078,6 +16481,20 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
||||
@@ -16260,6 +16677,19 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||
@@ -17095,6 +17525,7 @@
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
@@ -17178,6 +17609,11 @@
|
||||
"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": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lucide-react": "^0.539.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.0.2",
|
||||
|
||||
BIN
public/PSI.png
BIN
public/PSI.png
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 KiB |
BIN
public/asfasf.png
Normal file
BIN
public/asfasf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 KiB |
BIN
public/ikasapta.png
Normal file
BIN
public/ikasapta.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/ikasapta1.png
Normal file
BIN
public/ikasapta1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
@@ -2,13 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<meta name="description" content="Website SOLID DATA" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
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`.
|
||||
-->
|
||||
|
||||
<title>React App</title>
|
||||
<title>SOLID DATA</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "IKASAPTA",
|
||||
"name": "IKASAPTA HUB",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"src": "ikasapta.png",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"src": "ikasapta.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"src": "ikasapta.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
|
||||
110
src/App.js
110
src/App.js
@@ -1,49 +1,107 @@
|
||||
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 SuccessPage from "./SuccessPage";
|
||||
import Dashboard from "./Dashboard";
|
||||
import Login from "./Login";
|
||||
import LoginPage from "./Login";
|
||||
import Expetation from "./DataTypePage";
|
||||
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 token = localStorage.getItem("token");
|
||||
return token ? element : <Navigate to="/login" />;
|
||||
return token ? element : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
// Komponen redirect berdasarkan sessionStorage
|
||||
const HomeRedirect = () => {
|
||||
const token = localStorage.getItem("token");
|
||||
const hasOpen = sessionStorage.getItem("hasOpen");
|
||||
// ===== Redirector: /dashboard → /dashboard/:organization_id (kalau ada), kalau tidak ada ke /pick-organization =====
|
||||
const RedirectToOrgDashboard = () => {
|
||||
const orgId = localStorage.getItem("organization_id");
|
||||
if (orgId) return <Navigate to={`/dashboard/${orgId}`} replace />;
|
||||
return <Navigate to="/pick-organization" replace />;
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
// Jika tidak ada sessionId (anggap sebagai session baru)
|
||||
if (!hasOpen) {
|
||||
sessionStorage.setItem("hasOpen", true);
|
||||
|
||||
return <Navigate to="/scan" />;
|
||||
}
|
||||
|
||||
// Jika sudah ada sessionId
|
||||
return <Navigate to="/dashboard" />;
|
||||
// ===== Redirector: /scan → /scan/:organization_id (kalau ada), kalau tidak ada ke /pick-organization =====
|
||||
const RedirectToOrgScan = () => {
|
||||
const orgId = localStorage.getItem("organization_id");
|
||||
if (orgId) return <Navigate to={`/scan/${orgId}`} replace />;
|
||||
return <Navigate to="/pick-organization" replace />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="App">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/scan" element={<CameraKtp />} />
|
||||
{/* Default → login */}
|
||||
<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
|
||||
path="/dashboard"
|
||||
element={<ProtectedRoute element={<RedirectToOrgDashboard />} />}
|
||||
/>
|
||||
|
||||
{/* Dashboard spesifik organisasi */}
|
||||
<Route
|
||||
path="/dashboard/:organization_id"
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
427
src/Dashboard.js
427
src/Dashboard.js
@@ -1,7 +1,6 @@
|
||||
// Dashboard.jsx
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import FileListComponent from "./FileListComponent";
|
||||
import {
|
||||
BarChart,
|
||||
@@ -12,93 +11,203 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
// Konsistenkan base URL (tanpa tanda minus)
|
||||
const API_BASE = "https://bot.kediritechnopark.com/webhook/soliddata";
|
||||
|
||||
const Dashboard = () => {
|
||||
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 menuRef = useRef(null);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const [user, setUser] = useState({});
|
||||
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
|
||||
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
|
||||
const [totalFileSentYear, setTotalFileSentYear] = 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(() => {
|
||||
const token = localStorage.getItem("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(() => {
|
||||
const verifyTokenAndFetchData = async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!token || !organizationId) return;
|
||||
|
||||
const toNum = (v) => {
|
||||
const n = typeof v === "number" ? v : Number(v ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://bot.kediritechnopark.com/webhook/dashboard/psi",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
// Fetch total scans data (pakai API_BASE yang konsisten)
|
||||
const totalScansRes = await fetch(
|
||||
`${API_BASE}/total-scans?organization_id=${encodeURIComponent(
|
||||
organizationId
|
||||
)}`,
|
||||
{ method: "GET", headers: authHeaders() }
|
||||
);
|
||||
const totalScansRaw = await totalScansRes.json();
|
||||
console.log("RAW total-scans payload:", totalScansRaw);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data[0].payload.username) {
|
||||
throw new Error("Unauthorized");
|
||||
if (!totalScansRes.ok) {
|
||||
console.error("Total Scans error:", totalScansRaw);
|
||||
} else {
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error("Token tidak valid:", error.message);
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
// Fetch dashboard (user, officer performance default)
|
||||
const res = await fetch(
|
||||
`${API_BASE}/dashboard?organization_id=${encodeURIComponent(
|
||||
organizationId
|
||||
)}`,
|
||||
{ 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();
|
||||
}, []);
|
||||
// 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 = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("organization_id"); // bersihkan juga orgId
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleAddOfficer = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (!organizationId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://bot.kediritechnopark.com/webhook/add-officer",
|
||||
{
|
||||
const res = await fetch(`${API_BASE}/add-officer`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -106,12 +215,45 @@ const Dashboard = () => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setErrorMessage("");
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message || "Gagal menambahkan officer");
|
||||
|
||||
await fetchOfficers();
|
||||
} catch (err) {
|
||||
setErrorMessage(err.message || "Gagal menambahkan officer");
|
||||
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(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
@@ -122,18 +264,36 @@ const Dashboard = () => {
|
||||
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 (
|
||||
<div className={styles.dashboardContainer}>
|
||||
<div className={styles.dashboardHeader}>
|
||||
<div className={styles.logoAndTitle}>
|
||||
<img src="/PSI.png" alt="Bot Avatar" />
|
||||
<h1 className={styles.h1}>Kawal PSI Dashboard</h1>
|
||||
<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}>
|
||||
<span className={styles.userDisplayName}>
|
||||
{user.username || "Guest"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={styles.dropdownToggle}
|
||||
@@ -149,6 +309,7 @@ const Dashboard = () => {
|
||||
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" />
|
||||
@@ -157,24 +318,48 @@ const Dashboard = () => {
|
||||
</button>
|
||||
{isMenuOpen && (
|
||||
<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
|
||||
onClick={() => {
|
||||
navigate("/profile");
|
||||
// Selalu bawa organizationId saat navigasi
|
||||
if (organizationId) {
|
||||
navigate(`/scan/${organizationId}`);
|
||||
} else {
|
||||
setErrorMessage("Organisasi tidak terdeteksi.");
|
||||
}
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className={styles.dropdownItem}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
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>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleLogout();
|
||||
@@ -182,15 +367,36 @@ const Dashboard = () => {
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ... sisanya tetap sama persis */}
|
||||
<div className={styles.mainContent}>
|
||||
{/* Summary Cards */}
|
||||
{errorMessage && (
|
||||
<div className={styles.error} style={{ marginBottom: 12 }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.summaryCardsContainer}>
|
||||
<div className={styles.summaryCard}>
|
||||
<h3>Hari Ini</h3>
|
||||
@@ -200,16 +406,55 @@ const Dashboard = () => {
|
||||
<h3>Bulan Ini</h3>
|
||||
<p>{totalFilesSentMonth.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<h3>Tahun Ini</h3>
|
||||
<p>{totalFileSentYear.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<h3>Total Keseluruhan</h3>
|
||||
<p>{totalFilesSentOverall.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid for Form (Admin) and Chart (Admin & Officer) */}
|
||||
<div className={styles.dashboardGrid}>
|
||||
{user.role === "admin" && (
|
||||
{user?.role === "admin" && (
|
||||
<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>
|
||||
<form onSubmit={handleAddOfficer} className={styles.form}>
|
||||
<label>
|
||||
@@ -245,35 +490,77 @@ const Dashboard = () => {
|
||||
)}
|
||||
|
||||
<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: </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 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={officerPerformanceData}>
|
||||
<XAxis dataKey="month" />
|
||||
<XAxis dataKey="label" /> {/* label = nama_tipe ATAU YYYY-MM */}
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#00adef" />
|
||||
<Bar dataKey="count" fill="var(--brand-primary)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className={styles.warning}>
|
||||
📋 Belum ada data performa untuk ditampilkan
|
||||
📋 Belum ada data upload untuk ditampilkan
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Tambahkan FileListComponent di sini */}
|
||||
<FileListComponent
|
||||
setTotalFilesSentToday={setTotalFilesSentToday}
|
||||
setTotalFilesSentMonth={setTotalFilesSentMonth}
|
||||
setTotalFilesSentOverall={setTotalFilesSentOverall}
|
||||
organizationId={organizationId}
|
||||
// Gunakan wrapper agar nilai dari child selalu angka valid
|
||||
setTotalFilesSentToday={safeSetToday}
|
||||
setTotalFilesSentMonth={safeSetMonth}
|
||||
setTotalFileSentYear={safeSetYear}
|
||||
setTotalFilesSentOverall={safeSetOverall}
|
||||
// tampilkan agregat saat /files selesai
|
||||
setOfficerPerformanceData={setOfficerPerformanceData}
|
||||
// === baru: terima daftar tipe + agregat & seri bulanan
|
||||
onTypesLoaded={handleTypesLoaded}
|
||||
onPerformanceReady={handlePerformanceReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
© 2025 Kediri Technopark • Dashboard PSI
|
||||
© 2025 Kediri Technopark • Dashboard SOLID DATA
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
--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;
|
||||
/* Brand */
|
||||
--brand-primary: #2961eb; /* blue-600 */
|
||||
--brand-primary-700: #1d4ed8; /* blue-700 */
|
||||
--brand-secondary: #4f46e5; /* indigo-600 */
|
||||
--brand-secondary-700: #4338ca;/* indigo-700 */
|
||||
--brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
|
||||
|
||||
/* Gradients for cards */
|
||||
--card-grad-1: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
|
||||
--card-grad-2: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
|
||||
--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;
|
||||
--success-green: #10b981;
|
||||
--warning-amber: #f59e0b;
|
||||
--error-red: #ef4444;
|
||||
|
||||
/* Text */
|
||||
--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 & Reset */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
--text-on-brand: #ffffff;
|
||||
|
||||
/* Borders & Shadows */
|
||||
--border-light: #e5e7eb;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
|
||||
--shadow-md: 0 4px 10px rgba(2,6,23,.08);
|
||||
--shadow-lg: 0 12px 22px rgba(2,6,23,.12);
|
||||
|
||||
/* States */
|
||||
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
|
||||
|
||||
/* Semantic */
|
||||
--error-red: #ef4444;
|
||||
}
|
||||
|
||||
/* ==== Base ==== */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
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);
|
||||
background: var(--neutral-50);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Root container: full width & height */
|
||||
.dashboardContainer {
|
||||
background-color: var(--neutral-50);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh; /* support mobile dynamic viewport */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--neutral-50);
|
||||
}
|
||||
|
||||
/* --- Header --- */
|
||||
/* ==== Header ==== */
|
||||
.dashboardHeader {
|
||||
background-color: var(--white);
|
||||
color: var(--text-primary);
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-bottom: 3px solid #ef4444;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(255,255,255,.95);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logoAndTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logoAndTitle { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
|
||||
.logoAndTitle img { width: 2.8rem; height: 2.8rem; object-fit: cover; border-radius: .6rem; box-shadow: var(--shadow-sm); }
|
||||
|
||||
.logoAndTitle img {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dashboardHeader .h1 {
|
||||
.h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ed4344;
|
||||
letter-spacing: -0.025em;
|
||||
font-size: clamp(1.25rem, 1.2vw + 1rem, 2rem);
|
||||
font-weight: 800; letter-spacing: -.02em;
|
||||
background: var(--brand-gradient);
|
||||
-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 */
|
||||
.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;
|
||||
.orgBadge {
|
||||
margin-left: .75rem;
|
||||
padding: .25rem .6rem;
|
||||
font-size: .75rem; font-weight: 700;
|
||||
color: var(--text-on-brand);
|
||||
background: var(--brand-secondary);
|
||||
border-radius: .5rem;
|
||||
border: 1px solid rgba(255,255,255,.5);
|
||||
}
|
||||
|
||||
/* ==== Dropdown ==== */
|
||||
.dropdownContainer { position: relative; display: flex; align-items: center; gap: .5rem; }
|
||||
.dropdownToggle {
|
||||
background-color: var(--neutral-100);
|
||||
color: var(--text-primary);
|
||||
min-width: 2.5rem; height: 2.5rem;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border-radius: .6rem;
|
||||
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);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
|
||||
}
|
||||
.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 {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
background-color: var(--white);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
position: absolute; right: 0; top: calc(100% + .5rem);
|
||||
min-width: 10rem; padding: .5rem;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border-light);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 10rem;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
border-radius: .75rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex; flex-direction: column; gap: .25rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: 0; background: transparent; text-align: left; cursor: pointer;
|
||||
padding: .65rem .75rem; border-radius: .5rem;
|
||||
font-size: .95rem; font-weight: 600; color: var(--neutral-800);
|
||||
transition: background .15s ease, transform .06s ease;
|
||||
}
|
||||
.dropdownItem:hover { background: rgba(37,99,235,.08); transform: translateY(-1px); }
|
||||
|
||||
.dropdownItem:hover {
|
||||
background-color: var(--neutral-100);
|
||||
}
|
||||
|
||||
.dropdownItem:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* --- Main Content --- */
|
||||
/* ==== Main: FULL WIDTH ==== */
|
||||
.mainContent {
|
||||
flex-grow: 1;
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
flex: 1 1 auto;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* prevent too-small cards */
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Gradient cards (3 variasi) */
|
||||
.summaryCard {
|
||||
background-color: var(--white);
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--card-grad-1);
|
||||
color: var(--text-on-brand);
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.2s ease;
|
||||
padding: 1.25rem 1.5rem;
|
||||
min-height: 120px;
|
||||
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 {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 28px rgba(37,99,235,.22);
|
||||
filter: saturate(1.05);
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
.summaryCard h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 .5rem 0;
|
||||
font-size: 1rem; letter-spacing: .06em; text-transform: uppercase;
|
||||
color: rgba(255,255,255,.92);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summaryCard p {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
margin: 0; line-height: 1;
|
||||
font-size: clamp(2rem, 2vw + .5rem, 2.2rem);
|
||||
font-weight: 900; color: #fff;
|
||||
background: none !important;
|
||||
-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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: 1fr; /* mobile: 1 kolom */
|
||||
gap: 2rem;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.formSection,
|
||||
.chartSection {
|
||||
background-color: var(--white);
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
.formSection, .chartSection {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.formSection h2,
|
||||
.chartSection h2 {
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
.formSection h2, .chartSection h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: clamp(1.05rem, .9vw + .7rem, 1.35rem);
|
||||
font-weight: 800; letter-spacing: -.015em;
|
||||
color: var(--neutral-800);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form label {
|
||||
text-align: left;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
display: block; margin-bottom: 1rem;
|
||||
font-size: .95rem; font-weight: 700; color: var(--neutral-800);
|
||||
}
|
||||
|
||||
.form input[type="text"],
|
||||
.form input[type="password"],
|
||||
.form select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-top: 0.375rem;
|
||||
width: 100%; margin-top: .35rem;
|
||||
padding: .7rem 1rem;
|
||||
font-size: .95rem; color: var(--neutral-800);
|
||||
background: var(--white);
|
||||
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);
|
||||
border-radius: .55rem;
|
||||
transition: border-color .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
|
||||
.form input[type="text"]:focus,
|
||||
.form input[type="password"]:focus,
|
||||
.form select:focus {
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
|
||||
outline: none;
|
||||
outline: none; border-color: var(--brand-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.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%;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.submitButton:hover {
|
||||
background-color: #d03b3b;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.submitButton:active {
|
||||
transform: translateY(0);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: .9rem 1.2rem;
|
||||
border: none; border-radius: .6rem; cursor: pointer;
|
||||
font-size: 1rem; font-weight: 800; letter-spacing: .02em;
|
||||
color: var(--text-on-brand);
|
||||
background: var(--brand-primary);
|
||||
box-shadow: 0 6px 18px rgba(37,99,235,.18);
|
||||
transition: transform .12s ease, box-shadow .2s ease, background .2s ease;
|
||||
}
|
||||
.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 */
|
||||
.success, .warning {
|
||||
margin-top: 1rem; padding: .85rem 1rem;
|
||||
border-radius: .6rem; font-size: .92rem; font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.success {
|
||||
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-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--brand-primary);
|
||||
background: rgba(37,99,235,.08);
|
||||
border-color: rgba(37,99,235,.18);
|
||||
}
|
||||
|
||||
.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 {
|
||||
background-color: #ef444417;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef444433;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--brand-secondary);
|
||||
background: rgba(79,70,229,.08);
|
||||
border-color: rgba(79,70,229,.18);
|
||||
}
|
||||
.error {
|
||||
margin-top: 1rem; padding: .85rem 1rem;
|
||||
border-radius: .6rem; font-size: .92rem; font-weight: 700;
|
||||
color: var(--error-red);
|
||||
background: rgba(239,68,68,.08);
|
||||
border: 1px solid rgba(239,68,68,.18);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: var(--white);
|
||||
color: var(--text-secondary);
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin-top: auto;
|
||||
font-size: 0.75rem;
|
||||
font-size: .85rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--white);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* Chart placeholder (kalau perlu) */
|
||||
.chartPlaceholder {
|
||||
background-color: var(--neutral-50);
|
||||
height: 20rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
border-radius: 0.75rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--neutral-50);
|
||||
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) {
|
||||
.dashboardHeader {
|
||||
padding: 1rem 2rem;
|
||||
.dashboardHeader { padding: 1rem 2rem; }
|
||||
.logoAndTitle img { width: 3rem; height: 3rem; }
|
||||
|
||||
.mainContent { padding: 2.25rem 2rem; gap: 2rem; }
|
||||
.dashboardGrid { grid-template-columns: 1.1fr 2fr; gap: 2rem; } /* 2 kolom, tetap lega */
|
||||
}
|
||||
|
||||
.logoAndTitle img {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
@media (min-width: 1280px) {
|
||||
.mainContent { padding: 2.5rem 2.25rem; gap: 2.25rem; }
|
||||
.dashboardGrid { grid-template-columns: 1fr 2fr; gap: 2.25rem; }
|
||||
}
|
||||
|
||||
.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: 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;
|
||||
}
|
||||
|
||||
.dashboardGrid {
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.formSection,
|
||||
.chartSection {
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.chartPlaceholder {
|
||||
height: 30rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Single column layout when only one section is present */
|
||||
@media (min-width: 768px) {
|
||||
/* ==== FIX: jika hanya ada 1 section, lebarkan full ==== */
|
||||
.dashboardGrid > *:only-child {
|
||||
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
441
src/DataTypePage.js
Normal 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
960
src/Expetation.js
Normal 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
773
src/Expetation.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,429 +1,381 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import styles from "./FileListComponent.module.css";
|
||||
import * as XLSX from "xlsx";
|
||||
import { saveAs } from "file-saver";
|
||||
import styles from "./FileListComponent.module.css";
|
||||
|
||||
const FileListComponent = ({
|
||||
setTotalFilesSentToday,
|
||||
setTotalFilesSentMonth,
|
||||
setTotalFilesSentOverall,
|
||||
setOfficerPerformanceData,
|
||||
setOfficerPerformanceData, // optional: kirim seri ke parent (compat)
|
||||
onTypesLoaded, // NEW: kirim daftar tipe & seri agregat per tipe
|
||||
onPerformanceReady, // NEW: kirim seri per-bulan saat tipe dibuka
|
||||
}) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
const [selectedType, setSelectedType] = useState(null);
|
||||
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(() => {
|
||||
const fetchFiles = async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
const orgId = getOrganizationId();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://bot.kediritechnopark.com/webhook/files",
|
||||
"https://bot.kediritechnopark.com/webhook/solid-data/files",
|
||||
{
|
||||
method: "GET",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"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();
|
||||
|
||||
if (!text) {
|
||||
throw new Error("Server membalas kosong.");
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!data.success || !Array.isArray(data.data)) {
|
||||
throw new Error("Format respons tidak valid.");
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (err) {
|
||||
console.error("Respons bukan JSON valid:", text);
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = data.data;
|
||||
|
||||
// 1. Set ke state
|
||||
const fileData = Array.isArray(data) ? data : data?.data || [];
|
||||
setFiles(fileData);
|
||||
|
||||
// 2. Hitung total file hari ini
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const totalToday = fileData.filter((f) =>
|
||||
f.created_at.startsWith(today)
|
||||
).length;
|
||||
setTotalFilesSentToday(totalToday);
|
||||
// === Kirim daftar tipe & seri agregat per tipe ke parent ===
|
||||
const typeOptions = fileData.map((f) => ({
|
||||
id: f.data_type_id,
|
||||
name: f.nama_tipe,
|
||||
}));
|
||||
|
||||
// 3. Hitung total bulan ini
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth();
|
||||
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);
|
||||
const byTypeSeries = fileData.map((f) => ({
|
||||
label: f.nama_tipe, // sumbu X
|
||||
count: Number(f.total_entries || 0), // tinggi bar
|
||||
}));
|
||||
|
||||
// 4. Total keseluruhan
|
||||
setTotalFilesSentOverall(fileData.length);
|
||||
|
||||
// 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);
|
||||
if (typeof onTypesLoaded === "function") {
|
||||
onTypesLoaded(typeOptions, byTypeSeries);
|
||||
}
|
||||
|
||||
fileData.forEach((item) => {
|
||||
const d = new Date(item.created_at);
|
||||
const monthKey = `${d.getFullYear()}-${String(
|
||||
d.getMonth() + 1
|
||||
).padStart(2, "0")}`;
|
||||
if (monthlyDataMap[monthKey] !== undefined) {
|
||||
monthlyDataMap[monthKey]++;
|
||||
// (opsional) kompatibel: tampilkan juga langsung di grafik
|
||||
if (typeof setOfficerPerformanceData === "function") {
|
||||
setOfficerPerformanceData(byTypeSeries);
|
||||
}
|
||||
});
|
||||
|
||||
const performanceArray = Object.entries(monthlyDataMap).map(
|
||||
([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);
|
||||
} catch (e) {
|
||||
console.error("Gagal fetch files:", e);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFiles();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const formatPhoneNumber = (phone) =>
|
||||
phone?.replace(/(\d{4})(\d{4})(\d{4})/, "$1-$2-$3");
|
||||
const handleRowClick = async (file) => {
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
if (!token) {
|
||||
alert("Token tidak ditemukan. Silakan login kembali.");
|
||||
return;
|
||||
// Fetch entries per data_type_id (saat user klik suatu tipe)
|
||||
const fetchEntries = async (dataTypeId, nama_tipe, entryName, expectation) => {
|
||||
let resolvedEntryName = entryName;
|
||||
if (!resolvedEntryName && expectation && Object.keys(expectation).length > 0) {
|
||||
resolvedEntryName = Object.keys(expectation)[0];
|
||||
}
|
||||
|
||||
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
|
||||
setEntries([]);
|
||||
setLoadingEntries(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
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: {
|
||||
Authorization: token, // atau `Bearer ${token}` jika diperlukan
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ data_type_id: dataTypeId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
if (!response.ok) throw new Error("Gagal ambil entries");
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
throw new Error("Respons kosong dari server.");
|
||||
let data;
|
||||
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) {
|
||||
alert(data.error);
|
||||
return;
|
||||
// Fallback nama field untuk judul kartu
|
||||
if (!resolvedEntryName && entryList.length > 0) {
|
||||
resolvedEntryName = Object.keys(entryList[0].data || {})[0] || null;
|
||||
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
|
||||
}
|
||||
|
||||
const item = data[0];
|
||||
setEntries(entryList);
|
||||
|
||||
if (!item) {
|
||||
alert("Data tidak ditemukan.");
|
||||
return;
|
||||
// ====== Hitung seri per-bulan utk tipe yang dibuka ======
|
||||
const parseDate = (v) => {
|
||||
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
|
||||
if (item.foto_url && !item.foto_url.match(/\.(jpg|jpeg|png|webp)$/i)) {
|
||||
console.warn(
|
||||
"URL foto bukan format gambar yang didukung:",
|
||||
item.foto_url
|
||||
);
|
||||
}
|
||||
const monthlySeries = Array.from(counts.entries())
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||
.map(([k, v]) => ({ label: k, count: v })); // "label" = YYYY-MM
|
||||
|
||||
setSelectedFile(item); // tampilkan di modal misalnya
|
||||
} catch (error) {
|
||||
console.error("Gagal mengambil detail:", error.message || error);
|
||||
alert("Gagal mengambil detail. Pastikan data tersedia.");
|
||||
if (typeof onPerformanceReady === "function") {
|
||||
onPerformanceReady(nama_tipe, monthlySeries);
|
||||
}
|
||||
// (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 = () => {
|
||||
setSelectedFile(null);
|
||||
setShowModal(false);
|
||||
setSelectedEntry(null);
|
||||
};
|
||||
|
||||
const exportToExcel = (data) => {
|
||||
const domain = window.location.origin;
|
||||
|
||||
// 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");
|
||||
const backToTypes = () => {
|
||||
setSelectedType(null);
|
||||
setEntries([]);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.fileListSection}>
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.title}>📑 Daftar Jenis Dokumen</h2>
|
||||
|
||||
{!selectedType ? (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner}></div>
|
||||
<div className={styles.emptyStateTitle}>Memuat file...</div>
|
||||
<p>Sedang memuat...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
) : files.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyStateTitle}>Belum ada data</div>
|
||||
<p className={styles.emptyStateText}>
|
||||
Tidak ada data KTP yang tersedia saat ini.
|
||||
Tidak ada jenis dokumen yang tersedia saat ini.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.fileTable}>
|
||||
<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>
|
||||
<ul className={styles.typeList}>
|
||||
{files.map((file, index) => (
|
||||
<tr
|
||||
key={file.id}
|
||||
onClick={() => handleRowClick(file)}
|
||||
className={styles.tableRow}
|
||||
<li
|
||||
key={file.data_type_id}
|
||||
className={styles.typeItem}
|
||||
onClick={() =>
|
||||
fetchEntries(
|
||||
file.data_type_id,
|
||||
file.nama_tipe,
|
||||
file.entry_name,
|
||||
file.expectation
|
||||
)
|
||||
}
|
||||
>
|
||||
<td>{index + 1}</td>
|
||||
<td>{file.nik}</td>
|
||||
<td className={styles.nameColumn}>{file.nama_lengkap}</td>
|
||||
<td>{formatPhoneNumber(file.no_hp)}</td>
|
||||
<td>{file.email}</td>
|
||||
</tr>
|
||||
<div className={styles.typeInfo}>
|
||||
<div className={styles.typeNumber}>{index + 1}</div>
|
||||
<div className={styles.typeDetails}>
|
||||
<div className={styles.typeName}>{file.nama_tipe}</div>
|
||||
<div className={styles.typeCount}>
|
||||
{file.total_entries} data tersedia
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.typeArrow}>→</div>
|
||||
</li>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<table className={styles.detailTable}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>NIK</td>
|
||||
<td>{selectedFile.nik}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Nama Lengkap</td>
|
||||
<td>{selectedFile.nama_lengkap}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
{/* Modal untuk detail entry */}
|
||||
{showModal && selectedEntry && (
|
||||
<div className={styles.modalOverlay} onClick={closeModal}>
|
||||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h3 className={styles.modalTitle}>
|
||||
Detail Data:{" "}
|
||||
{selectedEntry.data?.[selectedType.entryName] ||
|
||||
selectedEntry.data?.nama ||
|
||||
selectedEntry.data?.name ||
|
||||
"Data"}
|
||||
</h3>
|
||||
<button className={styles.closeButton} onClick={closeModal}>
|
||||
Tutup
|
||||
×
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -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 {
|
||||
--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;
|
||||
--brand-primary: #2961eb;
|
||||
--brand-primary-700: #1d4ed8;
|
||||
--brand-secondary: #4f46e5;
|
||||
--brand-secondary-700: #4338ca;
|
||||
--brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
|
||||
|
||||
--neutral-25: #fcfcfd;
|
||||
--neutral-50: #f9fafb;
|
||||
--neutral-100: #f3f4f6;
|
||||
--neutral-200: #e5e7eb;
|
||||
--neutral-300: #d1d5db;
|
||||
--neutral-400: #9ca3af;
|
||||
--neutral-600: #475569;
|
||||
--neutral-700: #374151;
|
||||
--neutral-800: #1f2937;
|
||||
--white: #ffffff;
|
||||
--success-green: #10b981;
|
||||
--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);
|
||||
--text-on-brand: #ffffff;
|
||||
|
||||
--border-light: #e5e7eb;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
|
||||
--shadow-md: 0 4px 10px rgba(2,6,23,.08);
|
||||
--shadow-lg: 0 12px 22px rgba(2,6,23,.12);
|
||||
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
|
||||
}
|
||||
|
||||
/* File List Section */
|
||||
.fileListSection {
|
||||
background-color: var(--white);
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
/* ===== Container ===== */
|
||||
.container {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin: 2rem auto;
|
||||
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 {
|
||||
padding: 1.5rem;
|
||||
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%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border-light);
|
||||
background-color: var(--white);
|
||||
width: 100%;
|
||||
/* ===== Title ===== */
|
||||
.title {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: clamp(1.125rem, 1vw + 0.8rem, 1.5rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--neutral-800);
|
||||
background: var(--brand-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.fileTable {
|
||||
width: 100%;
|
||||
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;
|
||||
/* ===== Loading State ===== */
|
||||
.loading {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
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;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fileTable td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--neutral-300);
|
||||
border-top: 3px solid var(--brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tableRow:hover {
|
||||
background-color: var(--neutral-50);
|
||||
}
|
||||
|
||||
.nameColumn {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
min-width: 200px;
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== Empty State ===== */
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--neutral-800);
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.emptyStateText {
|
||||
@@ -172,327 +98,514 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--neutral-300);
|
||||
border-top: 3px solid #ef4444;
|
||||
/* ===== Type List ===== */
|
||||
.typeList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
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%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px rgba(41, 97, 235, 0.2);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
.typeDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
|
||||
.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);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.tableContainer::-webkit-scrollbar {
|
||||
.backButton {
|
||||
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;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.tableContainer::-webkit-scrollbar-track {
|
||||
.entryList::-webkit-scrollbar-track {
|
||||
background: var(--neutral-100);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tableContainer::-webkit-scrollbar-thumb {
|
||||
background: #ef4444;
|
||||
.entryList::-webkit-scrollbar-thumb {
|
||||
background: var(--neutral-300);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.tableContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: #dc2626;
|
||||
.entryList::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--neutral-400);
|
||||
}
|
||||
|
||||
.tableContainer::-webkit-scrollbar-corner {
|
||||
background: var(--neutral-100);
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #ef4444 var(--neutral-100);
|
||||
}
|
||||
|
||||
/* Modal Styles - Matching Dashboard Design */
|
||||
/* ===== Modal ===== */
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
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);
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
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%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
max-width: 900px;
|
||||
max-height: 85vh;
|
||||
overflow: hidden;
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detailTable tr:nth-child(even) {
|
||||
background-color: var(--neutral-50);
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.detailTable td {
|
||||
padding: 0.75rem;
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0 1.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
vertical-align: top;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detailTable td:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
width: 35%;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.detailTable td:last-child {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
.modalTitle {
|
||||
font-size: clamp(1.125rem, 1vw + 0.8rem, 1.375rem);
|
||||
font-weight: 800;
|
||||
color: var(--neutral-800);
|
||||
margin: 0;
|
||||
letter-spacing: -0.015em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
background-color: #ef4444;
|
||||
color: var(--text-light);
|
||||
background: #ef4444;
|
||||
color: var(--text-on-brand);
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.025em;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
background-color: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
background: #dc2626;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.closeButton:active {
|
||||
transform: translateY(0);
|
||||
.modalContent {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.fileListSection {
|
||||
padding: 1.5rem;
|
||||
margin: 1rem;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.fileListHeader {
|
||||
.detailItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.fileListTitle {
|
||||
font-size: 1.125rem;
|
||||
.detailLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Mobile: Show only NIK and Name columns */
|
||||
.fileTable {
|
||||
min-width: 100%;
|
||||
.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;
|
||||
}
|
||||
|
||||
.fileTable th:not(:nth-child(2)):not(:nth-child(3)),
|
||||
.fileTable td:not(:nth-child(2)):not(:nth-child(3)) {
|
||||
display: none;
|
||||
/* ===== Responsive Design ===== */
|
||||
|
||||
/* Mobile First - Already defined above */
|
||||
|
||||
/* Tablet */
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
padding: 2rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.fileTable th,
|
||||
.fileTable td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
.typeItem,
|
||||
.entryItem {
|
||||
padding: 1.25rem;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.typeNumber,
|
||||
.entryNumber {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.fileTable th:nth-child(2) {
|
||||
width: 40%;
|
||||
.typeName,
|
||||
.entryName {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.fileTable th:nth-child(3) {
|
||||
width: 60%;
|
||||
.modalHeader {
|
||||
padding: 2rem 2rem 0 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.nameColumn {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
/* Modal responsive */
|
||||
.modalContent {
|
||||
padding: 1.5rem;
|
||||
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%;
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
.fileListSection {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.fileListTitle {
|
||||
font-size: 1rem;
|
||||
.typeItem,
|
||||
.entryItem {
|
||||
padding: 0.75rem;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.fileCount {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
.typeNumber,
|
||||
.entryNumber {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.fileTable th,
|
||||
.fileTable td {
|
||||
padding: 0.5rem 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
.typeName,
|
||||
.entryName {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
padding: 1rem;
|
||||
width: 98%;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.modalContent h3 {
|
||||
font-size: 1rem;
|
||||
.modalHeader {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detailTable {
|
||||
font-size: 0.75rem;
|
||||
.modalContent {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.detailTable td {
|
||||
padding: 0.5rem 0.375rem;
|
||||
.detailGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
padding: 0.625rem 1rem;
|
||||
.detailValue {
|
||||
padding: 0.625rem;
|
||||
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 {
|
||||
padding: 0.875rem 2rem;
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.fileListSection {
|
||||
padding: 3rem;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
/* Prevent text selection on interactive elements */
|
||||
.typeItem,
|
||||
.entryItem,
|
||||
.backButton,
|
||||
.closeButton {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
/* Focus styles for accessibility */
|
||||
.typeItem:focus,
|
||||
.entryItem:focus,
|
||||
.backButton:focus,
|
||||
.closeButton:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.downloadButton:hover {
|
||||
background-color: #008fc4;
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.typeNumber,
|
||||
.entryNumber {
|
||||
border: 2px solid var(--text-primary);
|
||||
}
|
||||
|
||||
.modal {
|
||||
border: 2px solid var(--text-primary);
|
||||
}
|
||||
}
|
||||
@@ -1,276 +1,276 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
// import React, { useEffect, useState } from "react";
|
||||
|
||||
const fieldLabels = {
|
||||
nik: "NIK",
|
||||
fullName: "Nama Lengkap",
|
||||
birthPlace: "Tempat Lahir",
|
||||
birthDate: "Tanggal Lahir",
|
||||
gender: "Jenis Kelamin",
|
||||
address: "Alamat",
|
||||
neighborhoodCode: "RT/RW",
|
||||
village: "Kelurahan/Desa",
|
||||
subDistrict: "Kecamatan",
|
||||
religion: "Agama",
|
||||
maritalStatus: "Status Perkawinan",
|
||||
occupation: "Pekerjaan",
|
||||
nationality: "Kewarganegaraan",
|
||||
validUntil: "Berlaku Hingga",
|
||||
issuedCity: "Kota Terbit",
|
||||
issuedDate: "Tanggal Terbit",
|
||||
phoneNumber: "No. HP",
|
||||
email: "Email",
|
||||
};
|
||||
// const fieldLabels = {
|
||||
// nik: "NIK",
|
||||
// fullName: "Nama Lengkap",
|
||||
// birthPlace: "Tempat Lahir",
|
||||
// birthDate: "Tanggal Lahir",
|
||||
// gender: "Jenis Kelamin",
|
||||
// address: "Alamat",
|
||||
// neighborhoodCode: "RT/RW",
|
||||
// village: "Kelurahan/Desa",
|
||||
// subDistrict: "Kecamatan",
|
||||
// religion: "Agama",
|
||||
// maritalStatus: "Status Perkawinan",
|
||||
// occupation: "Pekerjaan",
|
||||
// nationality: "Kewarganegaraan",
|
||||
// validUntil: "Berlaku Hingga",
|
||||
// issuedCity: "Kota Terbit",
|
||||
// issuedDate: "Tanggal Terbit",
|
||||
// phoneNumber: "No. HP",
|
||||
// email: "Email",
|
||||
// };
|
||||
|
||||
function Modal({ isOpen, onClose, loading, fileTemp, onSave, onDelete }) {
|
||||
const [formData, setFormData] = useState({});
|
||||
const [step, setStep] = useState(0);
|
||||
// function Modal({ isOpen, onClose, loading, fileTemp, onSave, onDelete }) {
|
||||
// const [formData, setFormData] = useState({});
|
||||
// const [step, setStep] = useState(0);
|
||||
|
||||
// ❗️Field yang disembunyikan, bisa diisi sesuai kebutuhan
|
||||
const disabledFields = [];
|
||||
// // ❗️Field yang disembunyikan, bisa diisi sesuai kebutuhan
|
||||
// const disabledFields = [];
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTemp) {
|
||||
setFormData(Array.isArray(fileTemp) ? fileTemp[0] : fileTemp);
|
||||
setStep(0);
|
||||
} else {
|
||||
setFormData({});
|
||||
}
|
||||
}, [fileTemp]);
|
||||
// useEffect(() => {
|
||||
// if (fileTemp) {
|
||||
// setFormData(Array.isArray(fileTemp) ? fileTemp[0] : fileTemp);
|
||||
// setStep(0);
|
||||
// } else {
|
||||
// setFormData({});
|
||||
// }
|
||||
// }, [fileTemp]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
// if (!isOpen) return null;
|
||||
|
||||
const handleChange = (key, newValue, isDate = false) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]: isDate ? { ...prev[key], value: newValue } : newValue,
|
||||
}));
|
||||
};
|
||||
// const handleChange = (key, newValue, isDate = false) => {
|
||||
// setFormData((prev) => ({
|
||||
// ...prev,
|
||||
// [key]: isDate ? { ...prev[key], value: newValue } : newValue,
|
||||
// }));
|
||||
// };
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().split("T")[0];
|
||||
};
|
||||
// const formatDate = (value) => {
|
||||
// if (!value) return "";
|
||||
// const d = new Date(value);
|
||||
// return isNaN(d) ? "" : d.toISOString().split("T")[0];
|
||||
// };
|
||||
|
||||
const renderInput = (key, value) => {
|
||||
if (value && typeof value === "object" && value.type === "dateTime") {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={formatDate(value.value)}
|
||||
onChange={(e) => handleChange(key, e.target.value, true)}
|
||||
style={styles.input}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// const renderInput = (key, value) => {
|
||||
// if (value && typeof value === "object" && value.type === "dateTime") {
|
||||
// return (
|
||||
// <input
|
||||
// type="date"
|
||||
// value={formatDate(value.value)}
|
||||
// onChange={(e) => handleChange(key, e.target.value, true)}
|
||||
// style={styles.input}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
if (key === "address") {
|
||||
return (
|
||||
<textarea
|
||||
rows={2}
|
||||
value={value || ""}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
style={{ ...styles.input, resize: "vertical" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// if (key === "address") {
|
||||
// return (
|
||||
// <textarea
|
||||
// rows={2}
|
||||
// value={value || ""}
|
||||
// onChange={(e) => handleChange(key, e.target.value)}
|
||||
// style={{ ...styles.input, resize: "vertical" }}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value != null ? value : ""}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
style={styles.input}
|
||||
/>
|
||||
);
|
||||
};
|
||||
// return (
|
||||
// <input
|
||||
// type="text"
|
||||
// value={value != null ? value : ""}
|
||||
// onChange={(e) => handleChange(key, e.target.value)}
|
||||
// style={styles.input}
|
||||
// />
|
||||
// );
|
||||
// };
|
||||
|
||||
// Langkah-langkah form (per halaman)
|
||||
const rawSteps = [
|
||||
["nik", "fullName", "birthPlace", "birthDate"],
|
||||
["gender", "address", "neighborhoodCode", "village", "subDistrict"],
|
||||
["religion", "maritalStatus", "occupation"],
|
||||
[
|
||||
"nationality",
|
||||
"validUntil",
|
||||
"issuedCity",
|
||||
"issuedDate",
|
||||
"phoneNumber",
|
||||
"email",
|
||||
],
|
||||
];
|
||||
// // Langkah-langkah form (per halaman)
|
||||
// const rawSteps = [
|
||||
// ["nik", "fullName", "birthPlace", "birthDate"],
|
||||
// ["gender", "address", "neighborhoodCode", "village", "subDistrict"],
|
||||
// ["religion", "maritalStatus", "occupation"],
|
||||
// [
|
||||
// "nationality",
|
||||
// "validUntil",
|
||||
// "issuedCity",
|
||||
// "issuedDate",
|
||||
// "phoneNumber",
|
||||
// "email",
|
||||
// ],
|
||||
// ];
|
||||
|
||||
// Filter field yang disable/hide
|
||||
const steps = rawSteps.map((fields) =>
|
||||
fields.filter((key) => !disabledFields.includes(key))
|
||||
);
|
||||
// // Filter field yang disable/hide
|
||||
// const steps = rawSteps.map((fields) =>
|
||||
// fields.filter((key) => !disabledFields.includes(key))
|
||||
// );
|
||||
|
||||
// Filter langkah kosong
|
||||
const visibleSteps = steps.filter((step) => step.length > 0);
|
||||
// // Filter langkah kosong
|
||||
// const visibleSteps = steps.filter((step) => step.length > 0);
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={onClose}>
|
||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
{loading ? (
|
||||
<div style={styles.spinnerContainer}>
|
||||
<div style={styles.spinner} />
|
||||
<style>{spinnerStyle}</style>
|
||||
</div>
|
||||
) : (
|
||||
Object.keys(formData).length > 0 && (
|
||||
<>
|
||||
<h4>
|
||||
Verifikasi Data (Langkah {step + 1} dari {visibleSteps.length})
|
||||
</h4>
|
||||
<table style={styles.table}>
|
||||
<tbody>
|
||||
{visibleSteps[step].map((key) => (
|
||||
<tr key={key} style={styles.tableRow}>
|
||||
<td style={styles.tableLabel}>
|
||||
{fieldLabels[key] || key}
|
||||
</td>
|
||||
<td style={styles.tableInput}>
|
||||
{renderInput(key, formData[key])}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
// return (
|
||||
// <div style={styles.overlay} onClick={onClose}>
|
||||
// <div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
// {loading ? (
|
||||
// <div style={styles.spinnerContainer}>
|
||||
// <div style={styles.spinner} />
|
||||
// <style>{spinnerStyle}</style>
|
||||
// </div>
|
||||
// ) : (
|
||||
// Object.keys(formData).length > 0 && (
|
||||
// <>
|
||||
// <h4>
|
||||
// Verifikasi Data (Langkah {step + 1} dari {visibleSteps.length})
|
||||
// </h4>
|
||||
// <table style={styles.table}>
|
||||
// <tbody>
|
||||
// {visibleSteps[step].map((key) => (
|
||||
// <tr key={key} style={styles.tableRow}>
|
||||
// <td style={styles.tableLabel}>
|
||||
// {fieldLabels[key] || key}
|
||||
// </td>
|
||||
// <td style={styles.tableInput}>
|
||||
// {renderInput(key, formData[key])}
|
||||
// </td>
|
||||
// </tr>
|
||||
// ))}
|
||||
// </tbody>
|
||||
// </table>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
disabled={step === 0}
|
||||
onClick={() => setStep((s) => s - 1)}
|
||||
style={{
|
||||
...styles.saveButton,
|
||||
opacity: step === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
< Sebelumnya
|
||||
</button>
|
||||
// <div
|
||||
// style={{
|
||||
// display: "flex",
|
||||
// justifyContent: "space-between",
|
||||
// marginTop: 10,
|
||||
// }}
|
||||
// >
|
||||
// <button
|
||||
// disabled={step === 0}
|
||||
// onClick={() => setStep((s) => s - 1)}
|
||||
// style={{
|
||||
// ...styles.saveButton,
|
||||
// opacity: step === 0 ? 0.5 : 1,
|
||||
// }}
|
||||
// >
|
||||
// < Sebelumnya
|
||||
// </button>
|
||||
|
||||
<button
|
||||
disabled={step === visibleSteps.length - 1}
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
style={{
|
||||
...styles.saveButton,
|
||||
opacity: step === visibleSteps.length - 1 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Selanjutnya >
|
||||
</button>
|
||||
</div>
|
||||
// <button
|
||||
// disabled={step === visibleSteps.length - 1}
|
||||
// onClick={() => setStep((s) => s + 1)}
|
||||
// style={{
|
||||
// ...styles.saveButton,
|
||||
// opacity: step === visibleSteps.length - 1 ? 0.5 : 1,
|
||||
// }}
|
||||
// >
|
||||
// Selanjutnya >
|
||||
// </button>
|
||||
// </div>
|
||||
|
||||
<div style={styles.actions}>
|
||||
<button
|
||||
onClick={() => onSave(formData)}
|
||||
style={styles.saveButton}
|
||||
>
|
||||
Simpan ke Galeri
|
||||
</button>
|
||||
<button onClick={onDelete} style={styles.deleteButton}>
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// <div style={styles.actions}>
|
||||
// <button
|
||||
// onClick={() => onSave(formData)}
|
||||
// style={styles.saveButton}
|
||||
// >
|
||||
// Simpan ke Galeri
|
||||
// </button>
|
||||
// <button onClick={onDelete} style={styles.deleteButton}>
|
||||
// Hapus
|
||||
// </button>
|
||||
// </div>
|
||||
// </>
|
||||
// )
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// Styles dan spinner animation
|
||||
const styles = {
|
||||
overlay: {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 1000,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
minWidth: 350,
|
||||
maxWidth: "90vw",
|
||||
maxHeight: "80vh",
|
||||
overflowY: "auto",
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.3)",
|
||||
},
|
||||
spinnerContainer: {
|
||||
textAlign: "center",
|
||||
padding: 40,
|
||||
},
|
||||
spinner: {
|
||||
border: "4px solid #f3f3f3",
|
||||
borderTop: "4px solid #3498db",
|
||||
borderRadius: "50%",
|
||||
width: 40,
|
||||
height: 40,
|
||||
animation: "spin 1s linear infinite",
|
||||
margin: "0 auto",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
},
|
||||
tableRow: {
|
||||
borderBottom: "1px solid #eee",
|
||||
},
|
||||
tableLabel: {
|
||||
padding: "8px 10px",
|
||||
fontWeight: "bold",
|
||||
width: "30%",
|
||||
verticalAlign: "top",
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
tableInput: {
|
||||
padding: "8px 10px",
|
||||
},
|
||||
input: {
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
border: "1px solid #ccc",
|
||||
width: "100%",
|
||||
},
|
||||
actions: {
|
||||
marginTop: 20,
|
||||
textAlign: "right",
|
||||
},
|
||||
saveButton: {
|
||||
marginRight: 10,
|
||||
backgroundColor: "green",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "8px 14px",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: "red",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "8px 14px",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
},
|
||||
};
|
||||
// // Styles dan spinner animation
|
||||
// const styles = {
|
||||
// overlay: {
|
||||
// position: "fixed",
|
||||
// inset: 0,
|
||||
// backgroundColor: "rgba(0,0,0,0.5)",
|
||||
// display: "flex",
|
||||
// justifyContent: "center",
|
||||
// alignItems: "center",
|
||||
// zIndex: 1000,
|
||||
// },
|
||||
// modal: {
|
||||
// backgroundColor: "white",
|
||||
// borderRadius: 8,
|
||||
// padding: 20,
|
||||
// minWidth: 350,
|
||||
// maxWidth: "90vw",
|
||||
// maxHeight: "80vh",
|
||||
// overflowY: "auto",
|
||||
// boxShadow: "0 2px 10px rgba(0,0,0,0.3)",
|
||||
// },
|
||||
// spinnerContainer: {
|
||||
// textAlign: "center",
|
||||
// padding: 40,
|
||||
// },
|
||||
// spinner: {
|
||||
// border: "4px solid #f3f3f3",
|
||||
// borderTop: "4px solid #3498db",
|
||||
// borderRadius: "50%",
|
||||
// width: 40,
|
||||
// height: 40,
|
||||
// animation: "spin 1s linear infinite",
|
||||
// margin: "0 auto",
|
||||
// },
|
||||
// table: {
|
||||
// width: "100%",
|
||||
// borderCollapse: "collapse",
|
||||
// },
|
||||
// tableRow: {
|
||||
// borderBottom: "1px solid #eee",
|
||||
// },
|
||||
// tableLabel: {
|
||||
// padding: "8px 10px",
|
||||
// fontWeight: "bold",
|
||||
// width: "30%",
|
||||
// verticalAlign: "top",
|
||||
// textTransform: "capitalize",
|
||||
// },
|
||||
// tableInput: {
|
||||
// padding: "8px 10px",
|
||||
// },
|
||||
// input: {
|
||||
// padding: 6,
|
||||
// borderRadius: 4,
|
||||
// border: "1px solid #ccc",
|
||||
// width: "100%",
|
||||
// },
|
||||
// actions: {
|
||||
// marginTop: 20,
|
||||
// textAlign: "right",
|
||||
// },
|
||||
// saveButton: {
|
||||
// marginRight: 10,
|
||||
// backgroundColor: "green",
|
||||
// color: "white",
|
||||
// border: "none",
|
||||
// padding: "8px 14px",
|
||||
// borderRadius: 4,
|
||||
// cursor: "pointer",
|
||||
// },
|
||||
// deleteButton: {
|
||||
// backgroundColor: "red",
|
||||
// color: "white",
|
||||
// border: "none",
|
||||
// padding: "8px 14px",
|
||||
// borderRadius: 4,
|
||||
// cursor: "pointer",
|
||||
// },
|
||||
// };
|
||||
|
||||
const spinnerStyle = `
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
// const spinnerStyle = `
|
||||
// @keyframes spin {
|
||||
// 0% { transform: rotate(0deg); }
|
||||
// 100% { transform: rotate(360deg); }
|
||||
// }
|
||||
// `;
|
||||
|
||||
export default Modal;
|
||||
// export default Modal;
|
||||
|
||||
123
src/KTPPDF.js
Normal file
123
src/KTPPDF.js
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
.overlay-box {
|
||||
position: absolute;
|
||||
border: 3px dashed red;
|
||||
border: 3px dashed #43a0a7;
|
||||
width: 80%; /* atau sesuaikan */
|
||||
aspect-ratio: 85.6 / 53.98;
|
||||
top: 50%;
|
||||
|
||||
1176
src/KTPScanner.js
1176
src/KTPScanner.js
File diff suppressed because it is too large
Load Diff
109
src/Login.js
109
src/Login.js
@@ -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";
|
||||
|
||||
const Login = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
/* ===========================================================
|
||||
LOGIN PAGE
|
||||
=========================================================== */
|
||||
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) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
const authorizedUri = "http://localhost:3000/dashboard?token=";
|
||||
const unauthorizedUri = `${baseUrl}?modal=${modal}&product_id=${productId}`;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const url =
|
||||
`${baseUrl}?modal=${modal}&product_id=${productId}` +
|
||||
`&authorized_uri=${encodeURIComponent(authorizedUri)}` +
|
||||
`&unauthorized_uri=${encodeURIComponent(unauthorizedUri)}`;
|
||||
|
||||
try {
|
||||
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");
|
||||
}
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.loginContainer}>
|
||||
<div className={styles.loginBox}>
|
||||
<img src="/PSI.png" alt="Logo" className={styles.logo} />
|
||||
<h1 className={styles.h1}>Kawal PSI</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Silakan masuk untuk melanjutkan ke dashboard
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="Username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={styles.input}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={styles.input}
|
||||
/>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<button type="submit" className={styles.button}>
|
||||
Login
|
||||
<div className={styles.loginCard}>
|
||||
{/* Logo/Brand */}
|
||||
|
||||
{/* Login Form */}
|
||||
<div className={styles.loginForm}>
|
||||
|
||||
<div className={styles.brandSection}>
|
||||
<div className={styles.logoIcon}>
|
||||
<FileText className={styles.logoIconSvg} />
|
||||
</div>
|
||||
<h1 className={styles.brandTitle}>SOLID DATA</h1>
|
||||
<p className={styles.brandSubtitle}>Kelola data dokumen Anda dengan mudah</p>
|
||||
</div>
|
||||
<button
|
||||
className={styles.loginButton}
|
||||
onClick={login}
|
||||
>
|
||||
Masuk
|
||||
</button>
|
||||
</form>
|
||||
<div className={styles.footer}>© 2025 Kediri Technopark</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
}
|
||||
1021
src/Login.module.css
1021
src/Login.module.css
File diff suppressed because it is too large
Load Diff
163
src/PickOrganization.js
Normal file
163
src/PickOrganization.js
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/PickOrganization.module.css
Normal file
18
src/PickOrganization.module.css
Normal 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
260
src/ProfileTab.js
Normal 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
550
src/ProfileTab.module.css
Normal 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
0
src/QWEN.md
Normal file
@@ -18,7 +18,7 @@ const ShowImage = () => {
|
||||
|
||||
try {
|
||||
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",
|
||||
headers: {
|
||||
|
||||
30
src/SuccessPage.js
Normal file
30
src/SuccessPage.js
Normal 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;
|
||||
@@ -3,11 +3,7 @@ import React from "react";
|
||||
import styles from "./Header.module.css";
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>Officers & Roles</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Sidebar.js
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styles from "./Sidebar.module.css";
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<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.menuItem}>Officers</div>
|
||||
<div className={styles.menuItem}>Roles</div>
|
||||
|
||||
Reference in New Issue
Block a user