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",
|
"name": "workspace",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"js-levenshtein": "^1.1.6",
|
"js-levenshtein": "^1.1.6",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
"pixelmatch": "^7.1.0",
|
"pixelmatch": "^7.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"recharts": "^3.0.2",
|
"recharts": "^3.0.2",
|
||||||
@@ -2912,6 +2916,171 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-pdf/fns": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g=="
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/font": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-/dAWu7Y2RD1RxarDZ9SkYPHgBYOhmcDnet4W/qN/m8k+A2Hr3ja54GymSR7GGxWBtxjKtNauVKrTa9LS1n8WUw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/pdfkit": "^4.0.3",
|
||||||
|
"@react-pdf/types": "^2.9.0",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"is-url": "^1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/image": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/png-js": "^3.0.0",
|
||||||
|
"jay-peg": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/layout": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-Aq+Cc6JYausWLoks2FvHe3PwK9cTuvksB2uJ0AnkKJEUtQbvCq8eCRb1bjbbwIji9OzFRTTzZij7LzkpKHjIeA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.2",
|
||||||
|
"@react-pdf/image": "^3.0.3",
|
||||||
|
"@react-pdf/primitives": "^4.1.1",
|
||||||
|
"@react-pdf/stylesheet": "^6.1.0",
|
||||||
|
"@react-pdf/textkit": "^6.0.0",
|
||||||
|
"@react-pdf/types": "^2.9.0",
|
||||||
|
"emoji-regex": "^10.3.0",
|
||||||
|
"queue": "^6.0.1",
|
||||||
|
"yoga-layout": "^3.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/layout/node_modules/emoji-regex": {
|
||||||
|
"version": "10.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
|
||||||
|
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/pdfkit": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-k+Lsuq8vTwWsCqTp+CCB4+2N+sOTFrzwGA7aw3H9ix/PDWR9QksbmNg0YkzGbLAPI6CeawmiLHcf4trZ5ecLPQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/png-js": "^3.0.0",
|
||||||
|
"browserify-zlib": "^0.2.0",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"vite-compatible-readable-stream": "^3.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/png-js": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
|
||||||
|
"dependencies": {
|
||||||
|
"browserify-zlib": "^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/primitives": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ=="
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||||
|
"version": "0.25.0-rc-603e6108-20241029",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||||
|
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/render": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-MdWfWaqO6d7SZD75TZ2z5L35V+cHpyA43YNRlJNG0RJ7/MeVGDQv12y/BXOJgonZKkeEGdzM3EpAt9/g4E22WA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.2",
|
||||||
|
"@react-pdf/primitives": "^4.1.1",
|
||||||
|
"@react-pdf/textkit": "^6.0.0",
|
||||||
|
"@react-pdf/types": "^2.9.0",
|
||||||
|
"abs-svg-path": "^0.1.1",
|
||||||
|
"color-string": "^1.9.1",
|
||||||
|
"normalize-svg-path": "^1.1.0",
|
||||||
|
"parse-svg-path": "^0.1.2",
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/renderer": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-28gpA69fU9ZQrDzmd5xMJa1bDf8t0PT3ApUKBl2PUpoE/x4JlvCB5X66nMXrfFrgF2EZrA72zWQAkvbg7TE8zw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.2",
|
||||||
|
"@react-pdf/font": "^4.0.2",
|
||||||
|
"@react-pdf/layout": "^4.4.0",
|
||||||
|
"@react-pdf/pdfkit": "^4.0.3",
|
||||||
|
"@react-pdf/primitives": "^4.1.1",
|
||||||
|
"@react-pdf/reconciler": "^1.1.4",
|
||||||
|
"@react-pdf/render": "^4.3.0",
|
||||||
|
"@react-pdf/types": "^2.9.0",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"queue": "^6.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/stylesheet": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-BGZ2sYNUp38VJUegjva/jsri3iiRGnVNjWI+G9dTwAvLNOmwFvSJzqaCsEnqQ/DW5mrTBk/577FhDY7pv6AidA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.2",
|
||||||
|
"@react-pdf/types": "^2.9.0",
|
||||||
|
"color-string": "^1.9.1",
|
||||||
|
"hsl-to-hex": "^1.0.0",
|
||||||
|
"media-engine": "^1.0.3",
|
||||||
|
"postcss-value-parser": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/textkit": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.2",
|
||||||
|
"bidi-js": "^1.0.2",
|
||||||
|
"hyphen": "^1.6.4",
|
||||||
|
"unicode-properties": "^1.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/types": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-ckj80vZLlvl9oYrQ4tovEaqKWP3O06Eb1D48/jQWbdwz1Yh7Y9v1cEmwlP8ET+a1Whp8xfdM0xduMexkuPANCQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/font": "^4.0.2",
|
||||||
|
"@react-pdf/primitives": "^4.1.1",
|
||||||
|
"@react-pdf/stylesheet": "^6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
|
||||||
@@ -3279,6 +3448,14 @@
|
|||||||
"url": "https://github.com/sponsors/gregberge"
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
@@ -4152,6 +4329,11 @@
|
|||||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/abs-svg-path": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -4956,6 +5138,25 @@
|
|||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/batch": {
|
"node_modules/batch": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
|
||||||
@@ -4976,6 +5177,14 @@
|
|||||||
"node": ">= 8.0.0"
|
"node": ">= 8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/big.js": {
|
"node_modules/big.js": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
||||||
@@ -5086,11 +5295,27 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browser-process-hrtime": {
|
"node_modules/browser-process-hrtime": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
|
||||||
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow=="
|
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow=="
|
||||||
},
|
},
|
||||||
|
"node_modules/browserify-zlib": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "~1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.25.0",
|
"version": "4.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
|
||||||
@@ -5426,6 +5651,14 @@
|
|||||||
"wrap-ansi": "^7.0.0"
|
"wrap-ansi": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -5549,6 +5782,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^1.0.0",
|
||||||
|
"simple-swizzle": "^0.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-support": {
|
"node_modules/color-support": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
@@ -5778,6 +6020,11 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crypto-js": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
|
||||||
|
},
|
||||||
"node_modules/crypto-random-string": {
|
"node_modules/crypto-random-string": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
||||||
@@ -6509,6 +6756,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
|
||||||
|
},
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -7855,6 +8107,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-saver": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/filelist": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
||||||
@@ -7996,6 +8254,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -8694,6 +8968,19 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hsl-to-hex": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||||
|
"dependencies": {
|
||||||
|
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hsl-to-rgb-for-reals": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg=="
|
||||||
|
},
|
||||||
"node_modules/html-encoding-sniffer": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
|
||||||
@@ -8888,6 +9175,11 @@
|
|||||||
"node": ">=10.17.0"
|
"node": ">=10.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyphen": {
|
||||||
|
"version": "1.10.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
|
||||||
|
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw=="
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -9674,6 +9966,14 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jay-peg": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||||
|
"dependencies": {
|
||||||
|
"restructure": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jest": {
|
"node_modules/jest": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
|
||||||
@@ -10784,6 +11084,23 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -10878,6 +11195,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.539.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
|
||||||
|
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -10937,6 +11263,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
|
||||||
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA=="
|
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/media-engine": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg=="
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
@@ -11317,6 +11648,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-svg-path": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||||
|
"dependencies": {
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-url": {
|
"node_modules/normalize-url": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||||
@@ -11655,6 +11994,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||||
|
},
|
||||||
"node_modules/param-case": {
|
"node_modules/param-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||||
@@ -11692,6 +12036,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-svg-path": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="
|
||||||
|
},
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
|
||||||
@@ -13293,6 +13642,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
|
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/queue": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -13500,6 +13857,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz",
|
||||||
"integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ=="
|
"integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
@@ -14029,6 +14395,11 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
|
||||||
|
},
|
||||||
"node_modules/retry": {
|
"node_modules/retry": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||||
@@ -14675,6 +15046,19 @@
|
|||||||
"simple-concat": "^1.0.0"
|
"simple-concat": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-swizzle": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arrayish": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/simple-swizzle/node_modules/is-arrayish": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
|
||||||
|
},
|
||||||
"node_modules/sisteransi": {
|
"node_modules/sisteransi": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||||
@@ -15328,6 +15712,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-arc-to-cubic-bezier": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
||||||
|
},
|
||||||
"node_modules/svg-parser": {
|
"node_modules/svg-parser": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
|
||||||
@@ -15756,6 +16145,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
|
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
@@ -16070,6 +16464,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unicode-property-aliases-ecmascript": {
|
"node_modules/unicode-property-aliases-ecmascript": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
|
||||||
@@ -16078,6 +16481,20 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
|
||||||
|
},
|
||||||
"node_modules/unique-string": {
|
"node_modules/unique-string": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
||||||
@@ -16260,6 +16677,19 @@
|
|||||||
"d3-timer": "^3.0.1"
|
"d3-timer": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-compatible-readable-stream": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/w3c-hr-time": {
|
"node_modules/w3c-hr-time": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||||
@@ -17095,6 +17525,7 @@
|
|||||||
"version": "0.18.5",
|
"version": "0.18.5",
|
||||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adler-32": "~1.3.0",
|
"adler-32": "~1.3.0",
|
||||||
"cfb": "~1.2.1",
|
"cfb": "~1.2.1",
|
||||||
@@ -17178,6 +17609,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yoga-layout": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="
|
||||||
|
},
|
||||||
"node_modules/zlibjs": {
|
"node_modules/zlibjs": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||||
|
|||||||
@@ -3,14 +3,18 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"js-levenshtein": "^1.1.6",
|
"js-levenshtein": "^1.1.6",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
"pixelmatch": "^7.1.0",
|
"pixelmatch": "^7.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"recharts": "^3.0.2",
|
"recharts": "^3.0.2",
|
||||||
|
|||||||
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/ikasapta.png" type="image/png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta name="description" content="Website SOLID DATA" />
|
||||||
name="description"
|
|
||||||
content="Web site created using create-react-app"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
<!--
|
<!--
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
@@ -25,7 +22,7 @@
|
|||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<title>React App</title>
|
<title>SOLID DATA</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"short_name": "React App",
|
"short_name": "IKASAPTA",
|
||||||
"name": "Create React App Sample",
|
"name": "IKASAPTA HUB",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "ikasapta.png",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"src": "ikasapta.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "192x192"
|
"sizes": "192x192"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo512.png",
|
"src": "ikasapta.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512"
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/App.js
110
src/App.js
@@ -1,49 +1,107 @@
|
|||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useEffect } from "react";
|
||||||
import ShowImage from "./ShowImage";
|
import ShowImage from "./ShowImage";
|
||||||
|
import SuccessPage from "./SuccessPage";
|
||||||
import Dashboard from "./Dashboard";
|
import Dashboard from "./Dashboard";
|
||||||
import Login from "./Login";
|
import LoginPage from "./Login";
|
||||||
|
import Expetation from "./DataTypePage";
|
||||||
import CameraKtp from "./KTPScanner";
|
import CameraKtp from "./KTPScanner";
|
||||||
|
import Profile from "./ProfileTab";
|
||||||
|
import PickOrganization from "./PickOrganization";
|
||||||
|
|
||||||
// Komponen untuk melindungi route dengan token
|
// ===== ProtectedRoute: cek token sebelum render =====
|
||||||
const ProtectedRoute = ({ element }) => {
|
const ProtectedRoute = ({ element }) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
return token ? element : <Navigate to="/login" />;
|
return token ? element : <Navigate to="/login" replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Komponen redirect berdasarkan sessionStorage
|
// ===== Redirector: /dashboard → /dashboard/:organization_id (kalau ada), kalau tidak ada ke /pick-organization =====
|
||||||
const HomeRedirect = () => {
|
const RedirectToOrgDashboard = () => {
|
||||||
const token = localStorage.getItem("token");
|
const orgId = localStorage.getItem("organization_id");
|
||||||
const hasOpen = sessionStorage.getItem("hasOpen");
|
if (orgId) return <Navigate to={`/dashboard/${orgId}`} replace />;
|
||||||
|
return <Navigate to="/pick-organization" replace />;
|
||||||
|
};
|
||||||
|
|
||||||
if (!token) {
|
// ===== Redirector: /scan → /scan/:organization_id (kalau ada), kalau tidak ada ke /pick-organization =====
|
||||||
return <Navigate to="/login" />;
|
const RedirectToOrgScan = () => {
|
||||||
}
|
const orgId = localStorage.getItem("organization_id");
|
||||||
|
if (orgId) return <Navigate to={`/scan/${orgId}`} replace />;
|
||||||
// Jika tidak ada sessionId (anggap sebagai session baru)
|
return <Navigate to="/pick-organization" replace />;
|
||||||
if (!hasOpen) {
|
|
||||||
sessionStorage.setItem("hasOpen", true);
|
|
||||||
|
|
||||||
return <Navigate to="/scan" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jika sudah ada sessionId
|
|
||||||
return <Navigate to="/dashboard" />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
// 1) Ambil token dari query, simpan, lalu arahkan ke pemilihan organisasi
|
||||||
|
const token = params.get("token");
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
params.delete("token");
|
||||||
|
navigate("/pick-organization", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2) Jika tidak ada token di query, biarkan mengalir normal
|
||||||
|
}, [location, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
{/* Default → login */}
|
||||||
<Route path="/scan" element={<CameraKtp />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
|
||||||
|
{/* Auth */}
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* Setelah login → pilih organisasi */}
|
||||||
|
<Route
|
||||||
|
path="/pick-organization"
|
||||||
|
element={<ProtectedRoute element={<PickOrganization />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dashboard "polos" otomatis diarahkan ke dashboard org aktif */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
|
element={<ProtectedRoute element={<RedirectToOrgDashboard />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dashboard spesifik organisasi */}
|
||||||
|
<Route
|
||||||
|
path="/dashboard/:organization_id"
|
||||||
element={<ProtectedRoute element={<Dashboard />} />}
|
element={<ProtectedRoute element={<Dashboard />} />}
|
||||||
/>
|
/>
|
||||||
<Route path="/" element={<HomeRedirect />} />
|
|
||||||
<Route path="/:nik" element={<ShowImage />} />
|
{/* Scan "polos" otomatis diarahkan ke scan org aktif */}
|
||||||
|
<Route
|
||||||
|
path="/scan"
|
||||||
|
element={<ProtectedRoute element={<RedirectToOrgScan />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scan spesifik organisasi */}
|
||||||
|
<Route
|
||||||
|
path="/scan/:organization_id"
|
||||||
|
element={<ProtectedRoute element={<CameraKtp />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Alur scan di dalam dashboard (jika memang ada halaman ini) */}
|
||||||
|
<Route
|
||||||
|
path="/dashboard/:organization_id/scan"
|
||||||
|
element={<ProtectedRoute element={<Expetation />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Halaman lain */}
|
||||||
|
<Route path="/success" element={<SuccessPage />} />
|
||||||
|
<Route path="/profile" element={<ProtectedRoute element={<Profile />} />} />
|
||||||
|
|
||||||
|
{/* Contoh: ShowImage jika masih dipakai */}
|
||||||
|
<Route path="/show-image" element={<ProtectedRoute element={<ShowImage />} />} />
|
||||||
|
|
||||||
|
{/* Fallback */}
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
427
src/Dashboard.js
427
src/Dashboard.js
@@ -1,7 +1,6 @@
|
|||||||
// Dashboard.jsx
|
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import styles from "./Dashboard.module.css";
|
import styles from "./Dashboard.module.css";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import FileListComponent from "./FileListComponent";
|
import FileListComponent from "./FileListComponent";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -12,93 +11,203 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
|
// Konsistenkan base URL (tanpa tanda minus)
|
||||||
|
const API_BASE = "https://bot.kediritechnopark.com/webhook/soliddata";
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Ambil org dari URL, lalu sediakan fallback ke localStorage
|
||||||
|
const { organization_id: orgParam } = useParams();
|
||||||
|
const [organizationId, setOrganizationId] = useState(
|
||||||
|
orgParam || localStorage.getItem("organization_id") || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orgParam) {
|
||||||
|
localStorage.setItem("organization_id", orgParam);
|
||||||
|
setOrganizationId(orgParam);
|
||||||
|
}
|
||||||
|
}, [orgParam]);
|
||||||
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [successMessage, setSuccessMessage] = useState("");
|
const [successMessage, setSuccessMessage] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const [user, setUser] = useState({});
|
const [user, setUser] = useState({});
|
||||||
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
|
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
|
||||||
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
|
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
|
||||||
|
const [totalFileSentYear, setTotalFileSentYear] = useState(0);
|
||||||
const [totalFilesSentOverall, setTotalFilesSentOverall] = useState(0);
|
const [totalFilesSentOverall, setTotalFilesSentOverall] = useState(0);
|
||||||
const [officerPerformanceData, setOfficerPerformanceData] = useState([]);
|
|
||||||
|
|
||||||
|
// === Grafik ===
|
||||||
|
const [officerPerformanceData, setOfficerPerformanceData] = useState([]); // data yang sedang ditampilkan di chart
|
||||||
|
const [byTypeSeries, setByTypeSeries] = useState([]); // dari /files: [{ label: nama_tipe, count }]
|
||||||
|
const [typeOptions, setTypeOptions] = useState([]); // daftar tipe untuk dropdown: [{id, name}]
|
||||||
|
const [performanceByType, setPerformanceByType] = useState({}); // { [nama_tipe]: monthlySeries [{label: 'YYYY-MM', count}] }
|
||||||
|
const [chartKey, setChartKey] = useState(""); // "" = semua tipe (agregat), selain itu = nama_tipe (tren bulanan)
|
||||||
|
const [officers, setOfficers] = useState([]);
|
||||||
|
|
||||||
|
// Terima daftar tipe + seri per-tipe dari FileListComponent (/files)
|
||||||
|
const handleTypesLoaded = (options, series) => {
|
||||||
|
setTypeOptions(options || []);
|
||||||
|
setByTypeSeries(series || []);
|
||||||
|
// Default grafik: tampilkan agregat per-tipe
|
||||||
|
setOfficerPerformanceData(series || []);
|
||||||
|
setChartKey(""); // mode "Semua tipe"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Terima seri bulanan saat tipe dibuka di FileListComponent
|
||||||
|
const handlePerformanceReady = (typeName, monthlySeries) => {
|
||||||
|
setPerformanceByType((prev) => ({ ...prev, [typeName]: monthlySeries }));
|
||||||
|
// Jika user sedang memilih tipe ini → langsung update grafik
|
||||||
|
if (chartKey === typeName) {
|
||||||
|
setOfficerPerformanceData(monthlySeries || []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Header auth + optional X-Organization-Id
|
||||||
|
const authHeaders = (extra = {}) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pastikan login
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
window.location.href = "/login";
|
navigate("/login");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, []);
|
// Opsional: jika org kosong, arahkan user ke pemilihan organisasi
|
||||||
|
if (!organizationId) {
|
||||||
|
setErrorMessage("Organisasi tidak terdeteksi. Silakan akses dashboard melalui tautan organisasi.");
|
||||||
|
}
|
||||||
|
}, [organizationId, navigate]);
|
||||||
|
|
||||||
|
// Verifikasi token & fetch ringkasan dashboard org aktif
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const verifyTokenAndFetchData = async () => {
|
const verifyTokenAndFetchData = async () => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) {
|
if (!token || !organizationId) return;
|
||||||
window.location.href = "/login";
|
|
||||||
return;
|
const toNum = (v) => {
|
||||||
}
|
const n = typeof v === "number" ? v : Number(v ?? 0);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
// Fetch total scans data (pakai API_BASE yang konsisten)
|
||||||
"https://bot.kediritechnopark.com/webhook/dashboard/psi",
|
const totalScansRes = await fetch(
|
||||||
{
|
`${API_BASE}/total-scans?organization_id=${encodeURIComponent(
|
||||||
method: "GET",
|
organizationId
|
||||||
headers: {
|
)}`,
|
||||||
Authorization: `Bearer ${token}`,
|
{ method: "GET", headers: authHeaders() }
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
const totalScansRaw = await totalScansRes.json();
|
||||||
|
console.log("RAW total-scans payload:", totalScansRaw);
|
||||||
|
|
||||||
const data = await response.json();
|
if (!totalScansRes.ok) {
|
||||||
|
console.error("Total Scans error:", totalScansRaw);
|
||||||
if (!response.ok || !data[0].payload.username) {
|
} else {
|
||||||
throw new Error("Unauthorized");
|
const totalScansPayload = Array.isArray(totalScansRaw)
|
||||||
|
? totalScansRaw[0]
|
||||||
|
: totalScansRaw;
|
||||||
|
setTotalFilesSentToday(toNum(totalScansPayload?.total_today));
|
||||||
|
setTotalFilesSentMonth(toNum(totalScansPayload?.total_month));
|
||||||
|
setTotalFileSentYear(toNum(totalScansPayload?.total_year));
|
||||||
|
setTotalFilesSentOverall(toNum(totalScansPayload?.total_overall));
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(data[0].payload);
|
// Fetch dashboard (user, officer performance default)
|
||||||
} catch (error) {
|
const res = await fetch(
|
||||||
console.error("Token tidak valid:", error.message);
|
`${API_BASE}/dashboard?organization_id=${encodeURIComponent(
|
||||||
localStorage.removeItem("token");
|
organizationId
|
||||||
window.location.href = "/login";
|
)}`,
|
||||||
|
{ method: "GET", headers: authHeaders() }
|
||||||
|
);
|
||||||
|
const raw = await res.json();
|
||||||
|
console.log("RAW dashboard payload:", raw);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Dashboard error:", raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
if (payload?.user) setUser(payload.user);
|
||||||
|
|
||||||
|
// Kalau backend kirim default "officerPerformance", tetap tampilkan (akan ditimpa saat /files datang)
|
||||||
|
if (Array.isArray(payload?.officerPerformance)) {
|
||||||
|
setOfficerPerformanceData(payload.officerPerformance);
|
||||||
|
setChartKey(""); // mode umum
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Token/Fetch dashboard gagal:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
verifyTokenAndFetchData();
|
verifyTokenAndFetchData();
|
||||||
}, []);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [organizationId]);
|
||||||
|
|
||||||
|
// Ambil daftar officer (khusus admin)
|
||||||
|
const fetchOfficers = async () => {
|
||||||
|
if (!organizationId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/list-user?organization_id=${encodeURIComponent(
|
||||||
|
organizationId
|
||||||
|
)}`,
|
||||||
|
{ method: "GET", headers: authHeaders() }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
setOfficers(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal memuat daftar officer:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role === "admin") {
|
||||||
|
fetchOfficers();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user?.role]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user");
|
||||||
|
localStorage.removeItem("organization_id"); // bersihkan juga orgId
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddOfficer = async (e) => {
|
const handleAddOfficer = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!organizationId) return;
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const res = await fetch(`${API_BASE}/add-officer`, {
|
||||||
"https://bot.kediritechnopark.com/webhook/add-officer",
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
organization_id: organizationId,
|
||||||
}),
|
}),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!response.ok || data.success === false) {
|
if (!res.ok || data.success === false) {
|
||||||
throw new Error(data.message || "Gagal menambahkan officer");
|
throw new Error(data.message || "Gagal menambahkan officer");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +215,45 @@ const Dashboard = () => {
|
|||||||
setUsername("");
|
setUsername("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(error.message || "Gagal menambahkan officer");
|
await fetchOfficers();
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMessage(err.message || "Gagal menambahkan officer");
|
||||||
setSuccessMessage("");
|
setSuccessMessage("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteOfficer = async (id) => {
|
||||||
|
const confirmDelete = window.confirm(
|
||||||
|
"Apakah Anda yakin ingin menghapus petugas ini?"
|
||||||
|
);
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
if (!organizationId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/delete-officer`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
organization_id: organizationId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.success === false) {
|
||||||
|
throw new Error(data.message || "Gagal menghapus officer");
|
||||||
|
}
|
||||||
|
|
||||||
|
setOfficers((prev) => prev.filter((o) => o.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
alert("Gagal menghapus petugas: " + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tutup menu ketika klik di luar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
@@ -122,18 +264,36 @@ const Dashboard = () => {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { orgName } = {};
|
||||||
|
|
||||||
|
// ====== WRAPPER SETTER untuk proteksi dari FileListComponent ======
|
||||||
|
const safeSetToday = (v) => {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
if (Number.isFinite(n)) setTotalFilesSentToday(n);
|
||||||
|
};
|
||||||
|
const safeSetMonth = (v) => {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
if (Number.isFinite(n)) setTotalFilesSentMonth(n);
|
||||||
|
};
|
||||||
|
const safeSetYear = (v) => {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
if (Number.isFinite(n)) setTotalFileSentYear(n);
|
||||||
|
};
|
||||||
|
const safeSetOverall = (v) => {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
if (Number.isFinite(n)) setTotalFilesSentOverall(n);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dashboardContainer}>
|
<div className={styles.dashboardContainer}>
|
||||||
<div className={styles.dashboardHeader}>
|
<div className={styles.dashboardHeader}>
|
||||||
<div className={styles.logoAndTitle}>
|
<div className={styles.logoAndTitle}>
|
||||||
<img src="/PSI.png" alt="Bot Avatar" />
|
<img src="/ikasapta.png" alt="Bot Avatar" />
|
||||||
<h1 className={styles.h1}>Kawal PSI Dashboard</h1>
|
<h1 className={styles.h1}>SOLID</h1>
|
||||||
|
<h1 className={`${styles.h1} ${styles.h1Accent}`}>DATA</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.dropdownContainer} ref={menuRef}>
|
<div className={styles.dropdownContainer} ref={menuRef}>
|
||||||
<span className={styles.userDisplayName}>
|
|
||||||
{user.username || "Guest"}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
className={styles.dropdownToggle}
|
className={styles.dropdownToggle}
|
||||||
@@ -149,6 +309,7 @@ const Dashboard = () => {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
@@ -157,24 +318,48 @@ const Dashboard = () => {
|
|||||||
</button>
|
</button>
|
||||||
{isMenuOpen && (
|
{isMenuOpen && (
|
||||||
<div className={styles.dropdownMenu}>
|
<div className={styles.dropdownMenu}>
|
||||||
|
{/* Static Organization */}
|
||||||
|
<div className={styles.dropdownItemStatic}>
|
||||||
|
<div className={styles.dropdownText}>
|
||||||
|
<strong>Organisasi</strong>
|
||||||
|
<div className={styles.orgName}>{orgName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scan */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate("/profile");
|
// Selalu bawa organizationId saat navigasi
|
||||||
|
if (organizationId) {
|
||||||
|
navigate(`/scan/${organizationId}`);
|
||||||
|
} else {
|
||||||
|
setErrorMessage("Organisasi tidak terdeteksi.");
|
||||||
|
}
|
||||||
setIsMenuOpen(false);
|
setIsMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
className={styles.dropdownItem}
|
className={styles.dropdownItem}
|
||||||
>
|
>
|
||||||
Profile
|
<svg
|
||||||
</button>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<button
|
width="18"
|
||||||
onClick={() => {
|
height="18"
|
||||||
navigate("/scan");
|
viewBox="0 0 24 24"
|
||||||
setIsMenuOpen(false);
|
fill="none"
|
||||||
}}
|
stroke="currentColor"
|
||||||
className={styles.dropdownItem}
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
Scan
|
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||||
|
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||||
|
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||||
|
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||||
|
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||||
|
</svg>
|
||||||
|
<span>Scan</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleLogout();
|
handleLogout();
|
||||||
@@ -182,15 +367,36 @@ const Dashboard = () => {
|
|||||||
}}
|
}}
|
||||||
className={styles.dropdownItem}
|
className={styles.dropdownItem}
|
||||||
>
|
>
|
||||||
Logout
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ... sisanya tetap sama persis */}
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
{/* Summary Cards */}
|
{errorMessage && (
|
||||||
|
<div className={styles.error} style={{ marginBottom: 12 }}>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.summaryCardsContainer}>
|
<div className={styles.summaryCardsContainer}>
|
||||||
<div className={styles.summaryCard}>
|
<div className={styles.summaryCard}>
|
||||||
<h3>Hari Ini</h3>
|
<h3>Hari Ini</h3>
|
||||||
@@ -200,16 +406,55 @@ const Dashboard = () => {
|
|||||||
<h3>Bulan Ini</h3>
|
<h3>Bulan Ini</h3>
|
||||||
<p>{totalFilesSentMonth.toLocaleString()}</p>
|
<p>{totalFilesSentMonth.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.summaryCard}>
|
||||||
|
<h3>Tahun Ini</h3>
|
||||||
|
<p>{totalFileSentYear.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
<div className={styles.summaryCard}>
|
<div className={styles.summaryCard}>
|
||||||
<h3>Total Keseluruhan</h3>
|
<h3>Total Keseluruhan</h3>
|
||||||
<p>{totalFilesSentOverall.toLocaleString()}</p>
|
<p>{totalFilesSentOverall.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid for Form (Admin) and Chart (Admin & Officer) */}
|
|
||||||
<div className={styles.dashboardGrid}>
|
<div className={styles.dashboardGrid}>
|
||||||
{user.role === "admin" && (
|
{user?.role === "admin" && (
|
||||||
<div className={styles.formSection}>
|
<div className={styles.formSection}>
|
||||||
|
<h2>Daftar Petugas</h2>
|
||||||
|
<div className={styles.officerListContainer}>
|
||||||
|
<div className={styles.officerList}>
|
||||||
|
{officers.length > 0 ? (
|
||||||
|
officers.map((officer) => (
|
||||||
|
<div key={officer.id} className={styles.officerItem}>
|
||||||
|
<div className={styles.officerInfo}>
|
||||||
|
<span className={styles.officerIcon}>👤</span>
|
||||||
|
<div className={styles.officerDetails}>
|
||||||
|
<strong className={styles.officerName}>
|
||||||
|
{officer.username}
|
||||||
|
</strong>
|
||||||
|
<span className={styles.officerRole}>
|
||||||
|
{officer.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteOfficer(officer.id)}
|
||||||
|
className={styles.deleteButton}
|
||||||
|
title="Hapus Petugas"
|
||||||
|
>
|
||||||
|
❌
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<span>📋</span>
|
||||||
|
<p>Belum ada petugas terdaftar</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className={styles.separator} />
|
||||||
<h2>Tambah Petugas Baru</h2>
|
<h2>Tambah Petugas Baru</h2>
|
||||||
<form onSubmit={handleAddOfficer} className={styles.form}>
|
<form onSubmit={handleAddOfficer} className={styles.form}>
|
||||||
<label>
|
<label>
|
||||||
@@ -245,35 +490,77 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartSection}>
|
||||||
<h2>Grafik Pertumbuhan Anggota</h2>
|
<div className={styles.chartHeader}>
|
||||||
|
<h2>Grafik Upload Dokumen</h2>
|
||||||
|
|
||||||
|
<div className={styles.chartFilter}>
|
||||||
|
<label htmlFor="chartTypeSelect">Filter tipe: </label>
|
||||||
|
<select
|
||||||
|
id="chartTypeSelect"
|
||||||
|
value={chartKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
const key = e.target.value;
|
||||||
|
setChartKey(key);
|
||||||
|
if (!key) {
|
||||||
|
// Semua tipe → pakai agregat per-tipe
|
||||||
|
setOfficerPerformanceData(byTypeSeries);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Jika sudah ada seri bulanan tipe tsb → pakai
|
||||||
|
if (performanceByType[key]?.length) {
|
||||||
|
setOfficerPerformanceData(performanceByType[key]);
|
||||||
|
} else {
|
||||||
|
// fallback sementara: 1 bar dari agregat tipe
|
||||||
|
const one = byTypeSeries.find((s) => s.label === key);
|
||||||
|
setOfficerPerformanceData(one ? [one] : []);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={typeOptions.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">Semua tipe</option>
|
||||||
|
{typeOptions.map((t) => (
|
||||||
|
<option key={t.id} value={t.name}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{officerPerformanceData.length > 0 ? (
|
{officerPerformanceData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={officerPerformanceData}>
|
<BarChart data={officerPerformanceData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="label" /> {/* label = nama_tipe ATAU YYYY-MM */}
|
||||||
<YAxis allowDecimals={false} />
|
<YAxis allowDecimals={false} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="count" fill="#00adef" />
|
<Bar dataKey="count" fill="var(--brand-primary)" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.warning}>
|
<div className={styles.warning}>
|
||||||
📋 Belum ada data performa untuk ditampilkan
|
📋 Belum ada data upload untuk ditampilkan
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ Tambahkan FileListComponent di sini */}
|
|
||||||
<FileListComponent
|
<FileListComponent
|
||||||
setTotalFilesSentToday={setTotalFilesSentToday}
|
organizationId={organizationId}
|
||||||
setTotalFilesSentMonth={setTotalFilesSentMonth}
|
// Gunakan wrapper agar nilai dari child selalu angka valid
|
||||||
setTotalFilesSentOverall={setTotalFilesSentOverall}
|
setTotalFilesSentToday={safeSetToday}
|
||||||
|
setTotalFilesSentMonth={safeSetMonth}
|
||||||
|
setTotalFileSentYear={safeSetYear}
|
||||||
|
setTotalFilesSentOverall={safeSetOverall}
|
||||||
|
// tampilkan agregat saat /files selesai
|
||||||
setOfficerPerformanceData={setOfficerPerformanceData}
|
setOfficerPerformanceData={setOfficerPerformanceData}
|
||||||
|
// === baru: terima daftar tipe + agregat & seri bulanan
|
||||||
|
onTypesLoaded={handleTypesLoaded}
|
||||||
|
onPerformanceReady={handlePerformanceReady}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
© 2025 Kediri Technopark • Dashboard PSI
|
© 2025 Kediri Technopark • Dashboard SOLID DATA
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,444 +1,512 @@
|
|||||||
/* Dashboard.module.css - Cleaned Version */
|
/* Dashboard.module.css - Brand Blue/Indigo, Full Page & Responsive */
|
||||||
|
|
||||||
/* Modern Color Palette */
|
/* ==== GLOBAL FULL-HEIGHT ==== */
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* ==== Palette & Tokens ==== */
|
||||||
:root {
|
:root {
|
||||||
--primary-blue: #3b82f6;
|
/* Brand */
|
||||||
--secondary-blue: #60a5fa;
|
--brand-primary: #2961eb; /* blue-600 */
|
||||||
--dark-blue: #1e40af;
|
--brand-primary-700: #1d4ed8; /* blue-700 */
|
||||||
--neutral-50: #fafafa;
|
--brand-secondary: #4f46e5; /* indigo-600 */
|
||||||
--neutral-100: #f5f5f5;
|
--brand-secondary-700: #4338ca;/* indigo-700 */
|
||||||
--neutral-200: #e5e5e5;
|
--brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
|
||||||
--neutral-300: #d4d4d4;
|
|
||||||
--neutral-500: #737373;
|
/* Gradients for cards */
|
||||||
--neutral-700: #404040;
|
--card-grad-1: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
|
||||||
--neutral-800: #262626;
|
--card-grad-2: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
|
||||||
--neutral-900: #171717;
|
--card-grad-3: linear-gradient(135deg, #2563eb 10%, #4338ca 100%);
|
||||||
|
|
||||||
|
|
||||||
|
/* Neutral */
|
||||||
|
--neutral-25: #fcfcfd;
|
||||||
|
--neutral-50: #f9fafb;
|
||||||
|
--neutral-100: #f3f4f6;
|
||||||
|
--neutral-200: #e5e7eb;
|
||||||
|
--neutral-300: #d1d5db;
|
||||||
|
--neutral-400: #9ca3af;
|
||||||
|
--neutral-600: #475569;
|
||||||
|
--neutral-800: #1f2937;
|
||||||
--white: #ffffff;
|
--white: #ffffff;
|
||||||
--success-green: #10b981;
|
|
||||||
--warning-amber: #f59e0b;
|
/* Text */
|
||||||
--error-red: #ef4444;
|
|
||||||
--text-primary: #0f172a;
|
--text-primary: #0f172a;
|
||||||
--text-secondary: #64748b;
|
--text-secondary: #64748b;
|
||||||
--text-light: #ffffff;
|
--text-on-brand: #ffffff;
|
||||||
--border-light: #e2e8f0;
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
/* Borders & Shadows */
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
--border-light: #e5e7eb;
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
|
||||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
--shadow-md: 0 4px 10px rgba(2,6,23,.08);
|
||||||
}
|
--shadow-lg: 0 12px 22px rgba(2,6,23,.12);
|
||||||
|
|
||||||
/* Base Styles & Reset */
|
/* States */
|
||||||
* {
|
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
|
||||||
box-sizing: border-box;
|
|
||||||
|
/* Semantic */
|
||||||
|
--error-red: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==== Base ==== */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
"Helvetica Neue", Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: var(--neutral-50);
|
background: var(--neutral-50);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Root container: full width & height */
|
||||||
.dashboardContainer {
|
.dashboardContainer {
|
||||||
background-color: var(--neutral-50);
|
min-height: 100dvh; /* support mobile dynamic viewport */
|
||||||
min-height: 100vh;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: var(--neutral-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Header --- */
|
/* ==== Header ==== */
|
||||||
.dashboardHeader {
|
.dashboardHeader {
|
||||||
background-color: var(--white);
|
position: sticky; top: 0; z-index: 50;
|
||||||
color: var(--text-primary);
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
display: flex;
|
background: rgba(255,255,255,.95);
|
||||||
justify-content: space-between;
|
backdrop-filter: blur(6px);
|
||||||
align-items: center;
|
border-bottom: 1px solid var(--border-light);
|
||||||
box-shadow: var(--shadow-sm);
|
width: 100%;
|
||||||
border-bottom: 3px solid #ef4444;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 50;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoAndTitle {
|
.logoAndTitle { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
|
||||||
display: flex;
|
.logoAndTitle img { width: 2.8rem; height: 2.8rem; object-fit: cover; border-radius: .6rem; box-shadow: var(--shadow-sm); }
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logoAndTitle img {
|
.h1 {
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboardHeader .h1 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: clamp(1.25rem, 1.2vw + 1rem, 2rem);
|
||||||
font-weight: 700;
|
font-weight: 800; letter-spacing: -.02em;
|
||||||
color: #ed4344;
|
background: var(--brand-gradient);
|
||||||
letter-spacing: -0.025em;
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.h1Accent {
|
||||||
|
background: linear-gradient(135deg, var(--brand-secondary), var(--brand-primary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdown Menu */
|
.orgBadge {
|
||||||
.dropdownContainer {
|
margin-left: .75rem;
|
||||||
position: relative;
|
padding: .25rem .6rem;
|
||||||
display: flex;
|
font-size: .75rem; font-weight: 700;
|
||||||
align-items: center;
|
color: var(--text-on-brand);
|
||||||
gap: 0.75rem;
|
background: var(--brand-secondary);
|
||||||
flex-shrink: 0;
|
border-radius: .5rem;
|
||||||
}
|
border: 1px solid rgba(255,255,255,.5);
|
||||||
|
|
||||||
.userDisplayName {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==== Dropdown ==== */
|
||||||
|
.dropdownContainer { position: relative; display: flex; align-items: center; gap: .5rem; }
|
||||||
.dropdownToggle {
|
.dropdownToggle {
|
||||||
background-color: var(--neutral-100);
|
min-width: 2.5rem; height: 2.5rem;
|
||||||
color: var(--text-primary);
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--white);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
padding: 0.5rem;
|
border-radius: .6rem;
|
||||||
border-radius: 0.5rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
box-shadow: var(--shadow-sm);
|
||||||
transition: all 0.2s ease;
|
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
|
||||||
min-width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownToggle:hover {
|
|
||||||
background-color: var(--neutral-200);
|
|
||||||
border-color: var(--neutral-300);
|
|
||||||
}
|
}
|
||||||
|
.dropdownToggle:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: var(--neutral-300); }
|
||||||
|
.dropdownToggle:focus { outline: none; box-shadow: var(--focus-ring); }
|
||||||
|
|
||||||
.dropdownMenu {
|
.dropdownMenu {
|
||||||
position: absolute;
|
position: absolute; right: 0; top: calc(100% + .5rem);
|
||||||
top: calc(100% + 0.5rem);
|
min-width: 10rem; padding: .5rem;
|
||||||
right: 0;
|
background: var(--white);
|
||||||
background-color: var(--white);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
z-index: 10;
|
border-radius: .75rem;
|
||||||
display: flex;
|
box-shadow: var(--shadow-lg);
|
||||||
flex-direction: column;
|
display: flex; flex-direction: column; gap: .25rem;
|
||||||
min-width: 10rem;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownItem {
|
.dropdownItem {
|
||||||
background: none;
|
border: 0; background: transparent; text-align: left; cursor: pointer;
|
||||||
border: none;
|
padding: .65rem .75rem; border-radius: .5rem;
|
||||||
padding: 0.75rem 1rem;
|
font-size: .95rem; font-weight: 600; color: var(--neutral-800);
|
||||||
text-align: left;
|
transition: background .15s ease, transform .06s ease;
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
}
|
||||||
|
.dropdownItem:hover { background: rgba(37,99,235,.08); transform: translateY(-1px); }
|
||||||
|
|
||||||
.dropdownItem:hover {
|
/* ==== Main: FULL WIDTH ==== */
|
||||||
background-color: var(--neutral-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItem:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Main Content --- */
|
|
||||||
.mainContent {
|
.mainContent {
|
||||||
flex-grow: 1;
|
flex: 1 1 auto;
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
margin: 0; /* remove center constraint */
|
||||||
|
display: flex; flex-direction: column; gap: 2rem;
|
||||||
|
min-width: 0; /* prevent overflow causing shrink */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Summary Cards Container */
|
/* Summary Cards */
|
||||||
.summaryCardsContainer {
|
.summaryCardsContainer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* prevent too-small cards */
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gradient cards (3 variasi) */
|
||||||
.summaryCard {
|
.summaryCard {
|
||||||
background-color: var(--white);
|
position: relative;
|
||||||
padding: 1.5rem;
|
overflow: hidden;
|
||||||
|
background: var(--card-grad-1);
|
||||||
|
color: var(--text-on-brand);
|
||||||
|
border: 1px solid rgba(255,255,255,.18);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
border: 1px solid var(--border-light);
|
padding: 1.25rem 1.5rem;
|
||||||
box-shadow: var(--shadow-sm);
|
min-height: 120px;
|
||||||
transition: all 0.2s ease;
|
box-shadow: 0 10px 20px rgba(37,99,235,.15);
|
||||||
|
transition: box-shadow .2s ease, transform .12s ease, background-position .4s ease, filter .2s ease;
|
||||||
|
background-size: 140% 140%;
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
.summaryCardsContainer .summaryCard:nth-child(1) { background: var(--card-grad-1); }
|
||||||
|
.summaryCardsContainer .summaryCard:nth-child(2) { background: var(--card-grad-2); }
|
||||||
|
.summaryCardsContainer .summaryCard:nth-child(3) { background: var(--card-grad-3); }
|
||||||
|
|
||||||
|
.summaryCard::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 60% at 10% -10%, rgba(255,255,255,.25) 0%, rgba(255,255,255,0) 55%),
|
||||||
|
radial-gradient(90% 50% at 90% -10%, rgba(255,255,255,.18) 0%, rgba(255,255,255,0) 60%);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryCard:hover {
|
.summaryCard:hover {
|
||||||
box-shadow: var(--shadow-md);
|
transform: translateY(-2px);
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 14px 28px rgba(37,99,235,.22);
|
||||||
|
filter: saturate(1.05);
|
||||||
|
background-position: 100% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryCard h3 {
|
.summaryCard h3 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 .5rem 0;
|
||||||
font-size: 0.875rem;
|
font-size: 1rem; letter-spacing: .06em; text-transform: uppercase;
|
||||||
color: var(--text-secondary);
|
color: rgba(255,255,255,.92);
|
||||||
font-weight: 500;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryCard p {
|
.summaryCard p {
|
||||||
font-size: 2rem;
|
margin: 0; line-height: 1;
|
||||||
font-weight: 700;
|
font-size: clamp(2rem, 2vw + .5rem, 2.2rem);
|
||||||
color: #ef4444;
|
font-weight: 900; color: #fff;
|
||||||
margin: 0;
|
background: none !important;
|
||||||
line-height: 1;
|
-webkit-background-clip: initial !important;
|
||||||
|
-webkit-text-fill-color: #fff !important;
|
||||||
|
text-shadow: 0 1px 1px rgba(0,0,0,.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dashboard Grid for Form and Chart */
|
/* Grid: Form & Chart */
|
||||||
.dashboardGrid {
|
.dashboardGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr; /* mobile: 1 kolom */
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formSection,
|
.formSection, .chartSection {
|
||||||
.chartSection {
|
background: var(--white);
|
||||||
background-color: var(--white);
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 1rem;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formSection h2,
|
.formSection h2, .chartSection h2 {
|
||||||
.chartSection h2 {
|
margin: 0 0 1rem 0;
|
||||||
color: var(--text-primary);
|
font-size: clamp(1.05rem, .9vw + .7rem, 1.35rem);
|
||||||
margin: 0 0 1.5rem 0;
|
font-weight: 800; letter-spacing: -.015em;
|
||||||
font-size: 1.25rem;
|
color: var(--neutral-800);
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
.form label {
|
.form label {
|
||||||
text-align: left;
|
display: block; margin-bottom: 1rem;
|
||||||
display: block;
|
font-size: .95rem; font-weight: 700; color: var(--neutral-800);
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form input[type="text"],
|
.form input[type="text"],
|
||||||
.form input[type="password"],
|
.form input[type="password"],
|
||||||
.form select {
|
.form select {
|
||||||
width: 100%;
|
width: 100%; margin-top: .35rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: .7rem 1rem;
|
||||||
margin-top: 0.375rem;
|
font-size: .95rem; color: var(--neutral-800);
|
||||||
|
background: var(--white);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 0.5rem;
|
border-radius: .55rem;
|
||||||
font-size: 0.875rem;
|
transition: border-color .2s ease, box-shadow .2s ease;
|
||||||
transition: all 0.2s ease;
|
|
||||||
background-color: var(--white);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form input[type="text"]:focus,
|
.form input[type="text"]:focus,
|
||||||
.form input[type="password"]:focus,
|
.form input[type="password"]:focus,
|
||||||
.form select:focus {
|
.form select:focus {
|
||||||
border-color: var(--primary-blue);
|
outline: none; border-color: var(--brand-primary);
|
||||||
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
|
box-shadow: var(--focus-ring);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
.submitButton {
|
.submitButton {
|
||||||
background-color: #ef4444;
|
|
||||||
color: var(--text-light);
|
|
||||||
border: none;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 1rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: all 0.2s ease;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
letter-spacing: 0.025em;
|
padding: .9rem 1.2rem;
|
||||||
}
|
border: none; border-radius: .6rem; cursor: pointer;
|
||||||
|
font-size: 1rem; font-weight: 800; letter-spacing: .02em;
|
||||||
.submitButton:hover {
|
color: var(--text-on-brand);
|
||||||
background-color: #d03b3b;
|
background: var(--brand-primary);
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 6px 18px rgba(37,99,235,.18);
|
||||||
box-shadow: var(--shadow-md);
|
transition: transform .12s ease, box-shadow .2s ease, background .2s ease;
|
||||||
}
|
|
||||||
|
|
||||||
.submitButton:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
|
.submitButton:hover { transform: translateY(-1px); background: var(--brand-primary-700); box-shadow: 0 8px 22px rgba(37,99,235,.22); }
|
||||||
|
.submitButton:active { transform: translateY(0); }
|
||||||
|
.submitButton:focus { outline: none; box-shadow: var(--focus-ring); }
|
||||||
|
|
||||||
/* Messages */
|
/* Messages */
|
||||||
|
.success, .warning {
|
||||||
|
margin-top: 1rem; padding: .85rem 1rem;
|
||||||
|
border-radius: .6rem; font-size: .92rem; font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
.success {
|
.success {
|
||||||
background-color: rgb(16 185 129 / 0.1);
|
color: var(--brand-primary);
|
||||||
color: var(--success-green);
|
background: rgba(37,99,235,.08);
|
||||||
border: 1px solid rgb(16 185 129 / 0.2);
|
border-color: rgba(37,99,235,.18);
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
background-color: rgb(239 68 68 / 0.1);
|
|
||||||
color: var(--error-red);
|
|
||||||
border: 1px solid rgb(239 68 68 / 0.2);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
background-color: #ef444417;
|
color: var(--brand-secondary);
|
||||||
color: #ef4444;
|
background: rgba(79,70,229,.08);
|
||||||
border: 1px solid #ef444433;
|
border-color: rgba(79,70,229,.18);
|
||||||
padding: 1rem;
|
}
|
||||||
border-radius: 0.5rem;
|
.error {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem; padding: .85rem 1rem;
|
||||||
font-weight: 500;
|
border-radius: .6rem; font-size: .92rem; font-weight: 700;
|
||||||
font-size: 0.875rem;
|
color: var(--error-red);
|
||||||
|
background: rgba(239,68,68,.08);
|
||||||
|
border: 1px solid rgba(239,68,68,.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
background-color: var(--white);
|
margin-top: auto;
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-top: auto;
|
font-size: .85rem;
|
||||||
font-size: 0.75rem;
|
color: var(--text-secondary);
|
||||||
|
background: var(--white);
|
||||||
border-top: 1px solid var(--border-light);
|
border-top: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chart placeholder (kalau perlu) */
|
||||||
.chartPlaceholder {
|
.chartPlaceholder {
|
||||||
background-color: var(--neutral-50);
|
|
||||||
height: 20rem;
|
height: 20rem;
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
justify-content: center;
|
background: var(--neutral-50);
|
||||||
align-items: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
border: 2px dashed var(--border-light);
|
border: 2px dashed var(--border-light);
|
||||||
font-size: 0.875rem;
|
border-radius: .75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic; font-size: .95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Media Queries for Tablets and Desktops --- */
|
/* Officers List */
|
||||||
|
.officerListContainer {
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: .8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.officerList {
|
||||||
|
max-height: 300px; overflow-y: auto;
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: .6rem; background: var(--white);
|
||||||
|
}
|
||||||
|
.officerItem {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: .95rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
.officerItem:last-child { border-bottom: 0; }
|
||||||
|
.officerItem:hover { background: rgba(79,70,229,.05); }
|
||||||
|
.officerInfo { display: flex; align-items: center; gap: .75rem; flex: 1; }
|
||||||
|
.officerIcon { width: 24px; height: 24px; display: grid; place-items: center; font-size: 18px; }
|
||||||
|
.officerDetails { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.officerName { font-weight: 800; color: var(--neutral-800); font-size: .98rem; }
|
||||||
|
.officerRole { font-size: .85rem; color: var(--text-secondary); text-transform: capitalize; font-style: italic; }
|
||||||
|
.deleteButton {
|
||||||
|
background: transparent; border: 0; cursor: pointer;
|
||||||
|
font-size: .85rem; padding: .45rem .65rem; border-radius: .4rem;
|
||||||
|
color: var(--error-red);
|
||||||
|
transition: background .15s ease, transform .06s ease;
|
||||||
|
opacity: .9;
|
||||||
|
}
|
||||||
|
.deleteButton:hover { background: rgba(239,68,68,.08); transform: translateY(-1px); }
|
||||||
|
.deleteButton:focus { outline: none; box-shadow: var(--focus-ring); }
|
||||||
|
.emptyState { text-align: center; padding: 36px 18px; color: var(--text-secondary); }
|
||||||
|
.emptyState span { display: block; font-size: 32px; margin-bottom: .5rem; }
|
||||||
|
.separator { border: 0; border-top: 1px solid var(--border-light); margin: 20px 0; }
|
||||||
|
|
||||||
/* Tablet-sized screens and up */
|
/* Scrollbar */
|
||||||
|
.officerList::-webkit-scrollbar { width: 8px; }
|
||||||
|
.officerList::-webkit-scrollbar-track { background: var(--neutral-100); border-radius: 4px; }
|
||||||
|
.officerList::-webkit-scrollbar-thumb { background: var(--neutral-300); border-radius: 4px; }
|
||||||
|
.officerList::-webkit-scrollbar-thumb:hover { background: var(--neutral-400); }
|
||||||
|
|
||||||
|
/* ==== Responsive ==== */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.dashboardHeader {
|
.dashboardHeader { padding: 1rem 2rem; }
|
||||||
padding: 1rem 2rem;
|
.logoAndTitle img { width: 3rem; height: 3rem; }
|
||||||
|
|
||||||
|
.mainContent { padding: 2.25rem 2rem; gap: 2rem; }
|
||||||
|
.dashboardGrid { grid-template-columns: 1.1fr 2fr; gap: 2rem; } /* 2 kolom, tetap lega */
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoAndTitle img {
|
@media (min-width: 1280px) {
|
||||||
width: 3rem;
|
.mainContent { padding: 2.5rem 2.25rem; gap: 2.25rem; }
|
||||||
height: 3rem;
|
.dashboardGrid { grid-template-columns: 1fr 2fr; gap: 2.25rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboardHeader .h1 {
|
/* ==== FIX: jika hanya ada 1 section, lebarkan full ==== */
|
||||||
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) {
|
|
||||||
.dashboardGrid > *:only-child {
|
.dashboardGrid > *:only-child {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pastikan chart section benar-benar membentang */
|
||||||
|
.chartSection {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0; /* cegah overflow anak bikin kontainer menyempit */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rapikan baris kartu ringkasan */
|
||||||
|
.summaryCardsContainer {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
justify-items: stretch;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tinggi & padding kartu konsisten + bayangan lebih halus */
|
||||||
|
.summaryCard {
|
||||||
|
min-height: 110px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
box-shadow: 0 8px 18px rgba(37,99,235,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Judul chart dan box warning sedikit lebih rapat */
|
||||||
|
.chartSection h2 { margin-bottom: .75rem; }
|
||||||
|
.warning { margin-top: .75rem; }
|
||||||
|
|
||||||
|
/* Header biar konten tidak terlalu mepet tepi di layar lebar */
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.dashboardHeader { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
.mainContent { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (Opsional) Kalau tetap terlihat terlalu ke kiri,
|
||||||
|
kamu bisa center-kan isi utama tanpa mengubah full width list dokumen */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mainContent { max-width: 1280px; margin-left: auto; margin-right: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuButton:hover {
|
||||||
|
background: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownMenu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 45px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 8px;
|
||||||
|
width: 220px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItem,
|
||||||
|
.dropdownItemStatic {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItem:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItemStatic {
|
||||||
|
cursor: default;
|
||||||
|
background: #fafafa;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orgName {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|||||||
441
src/DataTypePage.js
Normal file
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 React, { useState, useEffect } from "react";
|
||||||
import styles from "./FileListComponent.module.css";
|
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
|
import { saveAs } from "file-saver";
|
||||||
|
import styles from "./FileListComponent.module.css";
|
||||||
|
|
||||||
const FileListComponent = ({
|
const FileListComponent = ({
|
||||||
setTotalFilesSentToday,
|
setOfficerPerformanceData, // optional: kirim seri ke parent (compat)
|
||||||
setTotalFilesSentMonth,
|
onTypesLoaded, // NEW: kirim daftar tipe & seri agregat per tipe
|
||||||
setTotalFilesSentOverall,
|
onPerformanceReady, // NEW: kirim seri per-bulan saat tipe dibuka
|
||||||
setOfficerPerformanceData,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
const [selectedType, setSelectedType] = useState(null);
|
||||||
const [successMessage, setSuccessMessage] = useState("");
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [loadingEntries, setLoadingEntries] = useState(false);
|
||||||
|
const [selectedEntry, setSelectedEntry] = useState(null);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const getOrganizationId = () => {
|
||||||
|
try {
|
||||||
|
const orgData = localStorage.getItem("selected_organization");
|
||||||
|
if (!orgData) return null;
|
||||||
|
const parsed = JSON.parse(orgData);
|
||||||
|
return parsed.organization_id || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Gagal membaca organization_id:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 👉 Download Excel dari daftar entries yang sedang dibuka
|
||||||
|
const downloadExcel = () => {
|
||||||
|
if (!entries.length) {
|
||||||
|
alert("Tidak ada data untuk diunduh.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattened = entries.map((e) => ({
|
||||||
|
nama_tipe: e.nama_tipe,
|
||||||
|
...e.data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(flattened);
|
||||||
|
|
||||||
|
// Auto width kolom
|
||||||
|
const objectMaxLength = [];
|
||||||
|
flattened.forEach((row) => {
|
||||||
|
Object.keys(row).forEach((key, colIndex) => {
|
||||||
|
const value = row[key] ? row[key].toString() : "";
|
||||||
|
objectMaxLength[colIndex] = Math.max(
|
||||||
|
objectMaxLength[colIndex] || key.length,
|
||||||
|
value.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
worksheet["!cols"] = objectMaxLength.map((w) => ({ wch: w + 2 }));
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
||||||
|
const excelBuffer = XLSX.write(workbook, { bookType: "xlsx", type: "array" });
|
||||||
|
const blob = new Blob([excelBuffer], { type: "application/octet-stream" });
|
||||||
|
saveAs(blob, `data_${selectedType?.nama || "dokumen"}.xlsx`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch daftar tipe dari webhook /files
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFiles = async () => {
|
const fetchFiles = async () => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
const orgId = getOrganizationId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"https://bot.kediritechnopark.com/webhook/files",
|
"https://bot.kediritechnopark.com/webhook/solid-data/files",
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({ organization_id: orgId }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
throw new Error("Server membalas kosong.");
|
setFiles([]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(text);
|
let data;
|
||||||
|
try {
|
||||||
if (!data.success || !Array.isArray(data.data)) {
|
data = JSON.parse(text);
|
||||||
throw new Error("Format respons tidak valid.");
|
} catch (err) {
|
||||||
|
console.error("Respons bukan JSON valid:", text);
|
||||||
|
setFiles([]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileData = data.data;
|
const fileData = Array.isArray(data) ? data : data?.data || [];
|
||||||
|
|
||||||
// 1. Set ke state
|
|
||||||
setFiles(fileData);
|
setFiles(fileData);
|
||||||
|
|
||||||
// 2. Hitung total file hari ini
|
// === Kirim daftar tipe & seri agregat per tipe ke parent ===
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const typeOptions = fileData.map((f) => ({
|
||||||
const totalToday = fileData.filter((f) =>
|
id: f.data_type_id,
|
||||||
f.created_at.startsWith(today)
|
name: f.nama_tipe,
|
||||||
).length;
|
}));
|
||||||
setTotalFilesSentToday(totalToday);
|
|
||||||
|
|
||||||
// 3. Hitung total bulan ini
|
const byTypeSeries = fileData.map((f) => ({
|
||||||
const now = new Date();
|
label: f.nama_tipe, // sumbu X
|
||||||
const currentMonth = now.getMonth();
|
count: Number(f.total_entries || 0), // tinggi bar
|
||||||
const currentYear = now.getFullYear();
|
}));
|
||||||
const totalThisMonth = fileData.filter((f) => {
|
|
||||||
const d = new Date(f.created_at);
|
|
||||||
return (
|
|
||||||
d.getMonth() === currentMonth && d.getFullYear() === currentYear
|
|
||||||
);
|
|
||||||
}).length;
|
|
||||||
setTotalFilesSentMonth(totalThisMonth);
|
|
||||||
|
|
||||||
// 4. Total keseluruhan
|
if (typeof onTypesLoaded === "function") {
|
||||||
setTotalFilesSentOverall(fileData.length);
|
onTypesLoaded(typeOptions, byTypeSeries);
|
||||||
|
|
||||||
// 5. Grafik performa per bulan (dinamis)
|
|
||||||
const dateObjects = fileData.map((item) => new Date(item.created_at));
|
|
||||||
if (dateObjects.length > 0) {
|
|
||||||
const minDate = new Date(Math.min(...dateObjects));
|
|
||||||
const maxDate = new Date(Math.max(...dateObjects));
|
|
||||||
|
|
||||||
const monthlyDataMap = {};
|
|
||||||
let current = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
|
|
||||||
const end = new Date(maxDate.getFullYear(), maxDate.getMonth(), 1);
|
|
||||||
|
|
||||||
while (current <= end) {
|
|
||||||
const monthKey = `${current.getFullYear()}-${String(
|
|
||||||
current.getMonth() + 1
|
|
||||||
).padStart(2, "0")}`;
|
|
||||||
monthlyDataMap[monthKey] = 0;
|
|
||||||
current.setMonth(current.getMonth() + 1);
|
|
||||||
}
|
}
|
||||||
|
// (opsional) kompatibel: tampilkan juga langsung di grafik
|
||||||
fileData.forEach((item) => {
|
if (typeof setOfficerPerformanceData === "function") {
|
||||||
const d = new Date(item.created_at);
|
setOfficerPerformanceData(byTypeSeries);
|
||||||
const monthKey = `${d.getFullYear()}-${String(
|
|
||||||
d.getMonth() + 1
|
|
||||||
).padStart(2, "0")}`;
|
|
||||||
if (monthlyDataMap[monthKey] !== undefined) {
|
|
||||||
monthlyDataMap[monthKey]++;
|
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
console.error("Gagal fetch files:", e);
|
||||||
const performanceArray = Object.entries(monthlyDataMap).map(
|
setFiles([]);
|
||||||
([month, count]) => {
|
|
||||||
const [year, monthNum] = month.split("-");
|
|
||||||
const dateObj = new Date(`${month}-01`);
|
|
||||||
const label = new Intl.DateTimeFormat("id-ID", {
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(dateObj); // hasil: "Juli 2025"
|
|
||||||
return { month: label, count };
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
setOfficerPerformanceData(performanceArray);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Gagal mengambil data dari server:", error.message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchFiles();
|
fetchFiles();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatPhoneNumber = (phone) =>
|
// Fetch entries per data_type_id (saat user klik suatu tipe)
|
||||||
phone?.replace(/(\d{4})(\d{4})(\d{4})/, "$1-$2-$3");
|
const fetchEntries = async (dataTypeId, nama_tipe, entryName, expectation) => {
|
||||||
const handleRowClick = async (file) => {
|
let resolvedEntryName = entryName;
|
||||||
const token = localStorage.getItem("token");
|
if (!resolvedEntryName && expectation && Object.keys(expectation).length > 0) {
|
||||||
|
resolvedEntryName = Object.keys(expectation)[0];
|
||||||
if (!token) {
|
|
||||||
alert("Token tidak ditemukan. Silakan login kembali.");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
|
||||||
|
setEntries([]);
|
||||||
|
setLoadingEntries(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://bot.kediritechnopark.com/webhook/6915ea36-e1f4-49ad-a7f1-a27ce0bf2279/ktp/${file.nik}`,
|
"https://bot.kediritechnopark.com/webhook/solid-data/files/entry",
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token, // atau `Bearer ${token}` jika diperlukan
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({ data_type_id: dataTypeId }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error("Gagal ambil entries");
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text) {
|
let data;
|
||||||
throw new Error("Respons kosong dari server.");
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Respons bukan JSON valid:", text);
|
||||||
|
data = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(text);
|
const entryList = Array.isArray(data) ? data : data?.data || [];
|
||||||
|
|
||||||
if (data.error) {
|
// Fallback nama field untuk judul kartu
|
||||||
alert(data.error);
|
if (!resolvedEntryName && entryList.length > 0) {
|
||||||
return;
|
resolvedEntryName = Object.keys(entryList[0].data || {})[0] || null;
|
||||||
|
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = data[0];
|
setEntries(entryList);
|
||||||
|
|
||||||
if (!item) {
|
// ====== Hitung seri per-bulan utk tipe yang dibuka ======
|
||||||
alert("Data tidak ditemukan.");
|
const parseDate = (v) => {
|
||||||
return;
|
if (!v) return null;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d) ? null : d;
|
||||||
|
};
|
||||||
|
const pickDate = (entry) => {
|
||||||
|
const cands = [
|
||||||
|
entry.created_at,
|
||||||
|
entry.updated_at,
|
||||||
|
entry.data?.tanggal,
|
||||||
|
entry.data?.tgl,
|
||||||
|
entry.data?.date,
|
||||||
|
entry.data?.created_at,
|
||||||
|
];
|
||||||
|
for (const c of cands) {
|
||||||
|
const d = parseDate(c);
|
||||||
|
if (d) return d;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const mmKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const counts = new Map();
|
||||||
|
for (const e of entryList) {
|
||||||
|
const d = pickDate(e);
|
||||||
|
if (!d) continue;
|
||||||
|
const k = mmKey(d);
|
||||||
|
counts.set(k, (counts.get(k) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validasi jika ada image URL
|
const monthlySeries = Array.from(counts.entries())
|
||||||
if (item.foto_url && !item.foto_url.match(/\.(jpg|jpeg|png|webp)$/i)) {
|
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
console.warn(
|
.map(([k, v]) => ({ label: k, count: v })); // "label" = YYYY-MM
|
||||||
"URL foto bukan format gambar yang didukung:",
|
|
||||||
item.foto_url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedFile(item); // tampilkan di modal misalnya
|
if (typeof onPerformanceReady === "function") {
|
||||||
} catch (error) {
|
onPerformanceReady(nama_tipe, monthlySeries);
|
||||||
console.error("Gagal mengambil detail:", error.message || error);
|
|
||||||
alert("Gagal mengambil detail. Pastikan data tersedia.");
|
|
||||||
}
|
}
|
||||||
|
// (opsional) bila ingin langsung tampilkan seri bulanan ini
|
||||||
|
// if (typeof setOfficerPerformanceData === "function") {
|
||||||
|
// setOfficerPerformanceData(monthlySeries);
|
||||||
|
// }
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch entries:", err);
|
||||||
|
setEntries([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingEntries(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEntryModal = (entry) => {
|
||||||
|
setSelectedEntry(entry);
|
||||||
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setSelectedFile(null);
|
setShowModal(false);
|
||||||
|
setSelectedEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportToExcel = (data) => {
|
const backToTypes = () => {
|
||||||
const domain = window.location.origin;
|
setSelectedType(null);
|
||||||
|
setEntries([]);
|
||||||
// Step 1: Transform data
|
|
||||||
const modifiedData = data.map((item) => ({
|
|
||||||
ID: item.id,
|
|
||||||
NIK: item.nik,
|
|
||||||
Nama: item.nama_lengkap,
|
|
||||||
NoHP: item.no_hp,
|
|
||||||
Email: item.email,
|
|
||||||
Tanggal_Lahir: new Date(item.tanggal_lahir).toLocaleString("id-ID", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
}),
|
|
||||||
CreatedAt: new Date(item.created_at).toLocaleString("id-ID", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}),
|
|
||||||
ImageURL: `${domain}/${item.nik}`, // Will become a hyperlink
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Step 2: Create worksheet from data
|
|
||||||
const worksheet = XLSX.utils.json_to_sheet(modifiedData);
|
|
||||||
|
|
||||||
// Step 3: Add hyperlinks to ImageURL column
|
|
||||||
modifiedData.forEach((item, index) => {
|
|
||||||
const cellAddress = `G${index + 2}`; // G column, +2 because of header
|
|
||||||
if (worksheet[cellAddress]) {
|
|
||||||
worksheet[cellAddress].l = {
|
|
||||||
Target: item.ImageURL,
|
|
||||||
Tooltip: "View Image",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 4: Optional - add column widths
|
|
||||||
worksheet["!cols"] = [
|
|
||||||
{ wch: 5 }, // ID
|
|
||||||
{ wch: 15 }, // NIK
|
|
||||||
{ wch: 25 }, // Nama
|
|
||||||
{ wch: 15 }, // NoHP
|
|
||||||
{ wch: 30 }, // Email
|
|
||||||
{ wch: 25 }, // CreatedAt
|
|
||||||
{ wch: 40 }, // ImageURL
|
|
||||||
];
|
|
||||||
|
|
||||||
// Step 5: Optional - enable filter and freeze header
|
|
||||||
worksheet["!autofilter"] = { ref: "A1:G1" };
|
|
||||||
|
|
||||||
// Step 6: Create and export workbook
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
|
|
||||||
XLSX.writeFile(workbook, "data-export.xlsx");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.fileListSection}>
|
<div className={styles.container}>
|
||||||
<div className={styles.emptyState}>
|
<h2 className={styles.title}>📑 Daftar Jenis Dokumen</h2>
|
||||||
|
|
||||||
|
{!selectedType ? (
|
||||||
|
<>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loading}>
|
||||||
<div className={styles.spinner}></div>
|
<div className={styles.spinner}></div>
|
||||||
<div className={styles.emptyStateTitle}>Memuat file...</div>
|
<p>Sedang memuat...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : files.length === 0 ? (
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.fileListSection}>
|
|
||||||
<div className={styles.fileListHeader}>
|
|
||||||
<h2 className={styles.fileListTitle}>📁 Daftar Anggota</h2>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
exportToExcel(files);
|
|
||||||
}}
|
|
||||||
className={styles.downloadButton}
|
|
||||||
>
|
|
||||||
⬇️ Unduh Excel
|
|
||||||
</button>
|
|
||||||
<span className={styles.fileCount}>{files.length} file tersedia</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{successMessage && (
|
|
||||||
<div className={styles.successMessage}>
|
|
||||||
<span>✅</span>
|
|
||||||
{successMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
|
||||||
{files.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<div className={styles.emptyStateTitle}>Belum ada data</div>
|
<div className={styles.emptyStateTitle}>Belum ada data</div>
|
||||||
<p className={styles.emptyStateText}>
|
<p className={styles.emptyStateText}>
|
||||||
Tidak ada data KTP yang tersedia saat ini.
|
Tidak ada jenis dokumen yang tersedia saat ini.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className={styles.fileTable}>
|
<ul className={styles.typeList}>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>NIK</th>
|
|
||||||
<th className={styles.nameColumn}>Nama Lengkap</th>
|
|
||||||
<th>No. HP</th>
|
|
||||||
<th>Email</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{files.map((file, index) => (
|
{files.map((file, index) => (
|
||||||
<tr
|
<li
|
||||||
key={file.id}
|
key={file.data_type_id}
|
||||||
onClick={() => handleRowClick(file)}
|
className={styles.typeItem}
|
||||||
className={styles.tableRow}
|
onClick={() =>
|
||||||
|
fetchEntries(
|
||||||
|
file.data_type_id,
|
||||||
|
file.nama_tipe,
|
||||||
|
file.entry_name,
|
||||||
|
file.expectation
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<td>{index + 1}</td>
|
<div className={styles.typeInfo}>
|
||||||
<td>{file.nik}</td>
|
<div className={styles.typeNumber}>{index + 1}</div>
|
||||||
<td className={styles.nameColumn}>{file.nama_lengkap}</td>
|
<div className={styles.typeDetails}>
|
||||||
<td>{formatPhoneNumber(file.no_hp)}</td>
|
<div className={styles.typeName}>{file.nama_tipe}</div>
|
||||||
<td>{file.email}</td>
|
<div className={styles.typeCount}>
|
||||||
</tr>
|
{file.total_entries} data tersedia
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.typeArrow}>→</div>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</ul>
|
||||||
</table>
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.entrySection}>
|
||||||
|
<button className={styles.backButton} onClick={backToTypes}>
|
||||||
|
← Kembali ke Daftar Jenis
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 className={styles.entryTitle}>
|
||||||
|
📂 Isi Dokumen: {selectedType.nama}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 👉 Tombol Download Excel */}
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<button className={styles.downloadButton} onClick={downloadExcel}>
|
||||||
|
⬇️ Download Excel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingEntries ? (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Sedang memuat data...</p>
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateTitle}>Belum ada entry</div>
|
||||||
|
<p className={styles.emptyStateText}>
|
||||||
|
Belum ada entry untuk dokumen ini.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.entryList}>
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<li
|
||||||
|
key={entry.data_id}
|
||||||
|
className={styles.entryItem}
|
||||||
|
onClick={() => openEntryModal(entry)}
|
||||||
|
>
|
||||||
|
<div className={styles.entryInfo}>
|
||||||
|
<div className={styles.entryNumber}>{index + 1}</div>
|
||||||
|
<div className={styles.entryDetails}>
|
||||||
|
<div className={styles.entryName}>
|
||||||
|
{entry.data?.[selectedType.entryName] || "Data tidak tersedia"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.entryHint}>
|
||||||
|
Klik untuk melihat detail lengkap
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.entryArrow}>→</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Detail */}
|
|
||||||
{selectedFile && (
|
|
||||||
<div className={styles.modalOverlay} onClick={closeModal}>
|
|
||||||
<div
|
|
||||||
className={styles.modalContent}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Foto KTP */}
|
|
||||||
{selectedFile.data && (
|
|
||||||
<img
|
|
||||||
src={`data:image/jpeg;base64,${selectedFile.data}`}
|
|
||||||
alt={`Foto KTP - ${selectedFile.nik}`}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
maxHeight: "300px",
|
|
||||||
objectFit: "contain",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
borderRadius: "8px",
|
|
||||||
boxShadow: "0 2px 6px rgba(0,0,0,0.2)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3>🪪 Detail Data KTP</h3>
|
{/* Modal untuk detail entry */}
|
||||||
<table className={styles.detailTable}>
|
{showModal && selectedEntry && (
|
||||||
<tbody>
|
<div className={styles.modalOverlay} onClick={closeModal}>
|
||||||
<tr>
|
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
<td>NIK</td>
|
<div className={styles.modalHeader}>
|
||||||
<td>{selectedFile.nik}</td>
|
<h3 className={styles.modalTitle}>
|
||||||
</tr>
|
Detail Data:{" "}
|
||||||
<tr>
|
{selectedEntry.data?.[selectedType.entryName] ||
|
||||||
<td>Nama Lengkap</td>
|
selectedEntry.data?.nama ||
|
||||||
<td>{selectedFile.nama_lengkap}</td>
|
selectedEntry.data?.name ||
|
||||||
</tr>
|
"Data"}
|
||||||
<tr>
|
</h3>
|
||||||
<td>Tempat Lahir</td>
|
|
||||||
<td>{selectedFile.tempat_lahir}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Tanggal Lahir</td>
|
|
||||||
<td>{selectedFile.tanggal_lahir}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Jenis Kelamin</td>
|
|
||||||
<td>{selectedFile.jenis_kelamin}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Alamat</td>
|
|
||||||
<td>{selectedFile.alamat}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>RT/RW</td>
|
|
||||||
<td>{selectedFile.rt_rw}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Kelurahan/Desa</td>
|
|
||||||
<td>{selectedFile.kel_desa}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Kecamatan</td>
|
|
||||||
<td>{selectedFile.kecamatan}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Agama</td>
|
|
||||||
<td>{selectedFile.agama}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Status Perkawinan</td>
|
|
||||||
<td>{selectedFile.status_perkawinan}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Pekerjaan</td>
|
|
||||||
<td>{selectedFile.pekerjaan}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Kewarganegaraan</td>
|
|
||||||
<td>{selectedFile.kewarganegaraan}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>No HP</td>
|
|
||||||
<td>{selectedFile.no_hp}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Email</td>
|
|
||||||
<td>{selectedFile.email}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Berlaku Hingga</td>
|
|
||||||
<td>{selectedFile.berlaku_hingga}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Tanggal Pembuatan</td>
|
|
||||||
<td>{selectedFile.pembuatan}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Kota Pembuatan</td>
|
|
||||||
<td>{selectedFile.kota_pembuatan}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<button className={styles.closeButton} onClick={closeModal}>
|
<button className={styles.closeButton} onClick={closeModal}>
|
||||||
Tutup
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.modalContent}>
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
{Object.entries(selectedEntry.data || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className={styles.detailItem}>
|
||||||
|
<div className={styles.detailLabel}>
|
||||||
|
{key
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailValue}>{value || "-"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,169 +1,95 @@
|
|||||||
/* FileListComponent.module.css - Updated to match Dashboard design */
|
/* FileListComponent.module.css - Brand Blue/Indigo & Mobile-First */
|
||||||
|
|
||||||
/* Use the same color palette as Dashboard */
|
/* ===== CSS Variables ===== */
|
||||||
:root {
|
:root {
|
||||||
--primary-blue: #3b82f6;
|
--brand-primary: #2961eb;
|
||||||
--secondary-blue: #60a5fa;
|
--brand-primary-700: #1d4ed8;
|
||||||
--dark-blue: #1e40af;
|
--brand-secondary: #4f46e5;
|
||||||
--neutral-50: #fafafa;
|
--brand-secondary-700: #4338ca;
|
||||||
--neutral-100: #f5f5f5;
|
--brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
|
||||||
--neutral-200: #e5e5e5;
|
|
||||||
--neutral-300: #d4d4d4;
|
--neutral-25: #fcfcfd;
|
||||||
--neutral-500: #737373;
|
--neutral-50: #f9fafb;
|
||||||
--neutral-700: #404040;
|
--neutral-100: #f3f4f6;
|
||||||
--neutral-800: #262626;
|
--neutral-200: #e5e7eb;
|
||||||
--neutral-900: #171717;
|
--neutral-300: #d1d5db;
|
||||||
|
--neutral-400: #9ca3af;
|
||||||
|
--neutral-600: #475569;
|
||||||
|
--neutral-700: #374151;
|
||||||
|
--neutral-800: #1f2937;
|
||||||
--white: #ffffff;
|
--white: #ffffff;
|
||||||
--success-green: #10b981;
|
|
||||||
--warning-amber: #f59e0b;
|
|
||||||
--error-red: #ef4444;
|
|
||||||
--text-primary: #0f172a;
|
--text-primary: #0f172a;
|
||||||
--text-secondary: #64748b;
|
--text-secondary: #64748b;
|
||||||
--text-light: #ffffff;
|
--text-on-brand: #ffffff;
|
||||||
--border-light: #e2e8f0;
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--border-light: #e5e7eb;
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
--shadow-md: 0 4px 10px rgba(2,6,23,.08);
|
||||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
--shadow-lg: 0 12px 22px rgba(2,6,23,.12);
|
||||||
|
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* File List Section */
|
/* ===== Container ===== */
|
||||||
.fileListSection {
|
.container {
|
||||||
background-color: var(--white);
|
background: var(--white);
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 1rem;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
margin: 2rem auto;
|
padding: 1.5rem;
|
||||||
max-width: 1200px;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: 600px;
|
|
||||||
height: auto;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileListSection:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileListHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileListTitle {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileCount {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 500;
|
|
||||||
background-color: #ef4444;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.successMessage {
|
|
||||||
background-color: rgb(16 185 129 / 0.1);
|
|
||||||
color: var(--success-green);
|
|
||||||
border: 1px solid rgb(16 185 129 / 0.2);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer {
|
/* ===== Title ===== */
|
||||||
flex: 1;
|
.title {
|
||||||
overflow: auto;
|
margin: 0 0 1.5rem 0;
|
||||||
border-radius: 0.75rem;
|
font-size: clamp(1.125rem, 1vw + 0.8rem, 1.5rem);
|
||||||
border: 1px solid var(--border-light);
|
font-weight: 800;
|
||||||
background-color: var(--white);
|
letter-spacing: -0.015em;
|
||||||
width: 100%;
|
color: var(--neutral-800);
|
||||||
|
background: var(--brand-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable {
|
/* ===== Loading State ===== */
|
||||||
width: 100%;
|
.loading {
|
||||||
min-width: 600px;
|
|
||||||
table-layout: auto;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
background-color: var(--white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileTable th {
|
|
||||||
background-color: #ef4444;
|
|
||||||
padding: 0.75rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
padding: 3rem 1rem;
|
||||||
color: #ffffff;
|
color: var(--text-secondary);
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
white-space: nowrap;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable td {
|
.spinner {
|
||||||
padding: 0.75rem;
|
width: 2rem;
|
||||||
border-bottom: 1px solid var(--border-light);
|
height: 2rem;
|
||||||
color: var(--text-primary);
|
border: 3px solid var(--neutral-300);
|
||||||
vertical-align: middle;
|
border-top: 3px solid var(--brand-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableRow {
|
@keyframes spin {
|
||||||
cursor: pointer;
|
to { transform: rotate(360deg); }
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableRow:hover {
|
|
||||||
background-color: var(--neutral-50);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nameColumn {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Empty State ===== */
|
||||||
.emptyState {
|
.emptyState {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 2rem;
|
padding: 3rem 1rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyStateTitle {
|
.emptyStateTitle {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--neutral-800);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyStateText {
|
.emptyStateText {
|
||||||
@@ -172,327 +98,514 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
/* ===== Type List ===== */
|
||||||
width: 2rem;
|
.typeList {
|
||||||
height: 2rem;
|
list-style: none;
|
||||||
border: 3px solid var(--neutral-300);
|
margin: 0;
|
||||||
border-top: 3px solid #ef4444;
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeItem:hover {
|
||||||
|
background: rgba(79, 70, 229, 0.05);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeNumber {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--brand-gradient);
|
||||||
|
color: var(--text-on-brand);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
font-size: 0.75rem;
|
||||||
margin: 0 auto 1rem;
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(41, 97, 235, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
.typeDetails {
|
||||||
0% {
|
display: flex;
|
||||||
transform: rotate(0deg);
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
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 */
|
.backButton {
|
||||||
.tableContainer::-webkit-scrollbar {
|
background: var(--neutral-600);
|
||||||
|
color: var(--text-on-brand);
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(71, 85, 105, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton:hover {
|
||||||
|
background: var(--neutral-700);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(71, 85, 105, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryTitle {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: clamp(1rem, 0.9vw + 0.7rem, 1.25rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--neutral-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Entry List ===== */
|
||||||
|
.entryList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--white);
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryItem:hover {
|
||||||
|
background: rgba(41, 97, 235, 0.05);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryNumber {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--brand-gradient);
|
||||||
|
color: var(--text-on-brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryDetails {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryName {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--neutral-800);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryHint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryArrow {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Scrollbar Styling ===== */
|
||||||
|
.entryList::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer::-webkit-scrollbar-track {
|
.entryList::-webkit-scrollbar-track {
|
||||||
background: var(--neutral-100);
|
background: var(--neutral-100);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer::-webkit-scrollbar-thumb {
|
.entryList::-webkit-scrollbar-thumb {
|
||||||
background: #ef4444;
|
background: var(--neutral-300);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer::-webkit-scrollbar-thumb:hover {
|
.entryList::-webkit-scrollbar-thumb:hover {
|
||||||
background: #dc2626;
|
background: var(--neutral-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer::-webkit-scrollbar-corner {
|
/* ===== Modal ===== */
|
||||||
background: var(--neutral-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableContainer {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #ef4444 var(--neutral-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Styles - Matching Dashboard Design */
|
|
||||||
.modalOverlay {
|
.modalOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalContent {
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
max-width: 600px;
|
|
||||||
width: 90%;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
}
|
|
||||||
|
|
||||||
.modalContent h3 {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailTable {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
max-width: 900px;
|
||||||
margin-bottom: 1.5rem;
|
max-height: 85vh;
|
||||||
font-size: 0.875rem;
|
overflow: hidden;
|
||||||
text-align: left;
|
animation: modalSlideIn 0.3s ease-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable tr:nth-child(even) {
|
@keyframes modalSlideIn {
|
||||||
background-color: var(--neutral-50);
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable td {
|
.modalHeader {
|
||||||
padding: 0.75rem;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 1.5rem 0 1.5rem;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
vertical-align: top;
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable td:first-child {
|
.modalTitle {
|
||||||
font-weight: 600;
|
font-size: clamp(1.125rem, 1vw + 0.8rem, 1.375rem);
|
||||||
color: var(--text-secondary);
|
font-weight: 800;
|
||||||
width: 35%;
|
color: var(--neutral-800);
|
||||||
text-transform: uppercase;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
letter-spacing: -0.015em;
|
||||||
letter-spacing: 0.05em;
|
overflow: hidden;
|
||||||
}
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
.detailTable td:last-child {
|
padding-right: 1rem;
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.closeButton {
|
||||||
background-color: #ef4444;
|
background: #ef4444;
|
||||||
color: var(--text-light);
|
color: var(--text-on-brand);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.75rem 1.5rem;
|
border-radius: 50%;
|
||||||
border-radius: 0.5rem;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
width: 100%;
|
transition: all 0.15s ease;
|
||||||
transition: all 0.2s ease;
|
flex-shrink: 0;
|
||||||
letter-spacing: 0.025em;
|
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton:hover {
|
.closeButton:hover {
|
||||||
background-color: #dc2626;
|
background: #dc2626;
|
||||||
transform: translateY(-1px);
|
transform: scale(1.05);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton:active {
|
.modalContent {
|
||||||
transform: translateY(0);
|
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
.detailGrid {
|
||||||
@media (max-width: 768px) {
|
display: grid;
|
||||||
.fileListSection {
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
padding: 1.5rem;
|
gap: 1rem;
|
||||||
margin: 1rem;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileListHeader {
|
.detailItem {
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
gap: 0.375rem;
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileListTitle {
|
.detailLabel {
|
||||||
font-size: 1.125rem;
|
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 */
|
.detailValue {
|
||||||
.fileTable {
|
font-size: 0.875rem;
|
||||||
min-width: 100%;
|
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)),
|
/* ===== Responsive Design ===== */
|
||||||
.fileTable td:not(:nth-child(2)):not(:nth-child(3)) {
|
|
||||||
display: none;
|
/* Mobile First - Already defined above */
|
||||||
|
|
||||||
|
/* Tablet */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 2rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 1200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable th,
|
.typeItem,
|
||||||
.fileTable td {
|
.entryItem {
|
||||||
padding: 0.75rem 0.5rem;
|
padding: 1.25rem;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeNumber,
|
||||||
|
.entryNumber {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable th:nth-child(2) {
|
.typeName,
|
||||||
width: 40%;
|
.entryName {
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable th:nth-child(3) {
|
.modalHeader {
|
||||||
width: 60%;
|
padding: 2rem 2rem 0 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nameColumn {
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal responsive */
|
|
||||||
.modalContent {
|
.modalContent {
|
||||||
padding: 1.5rem;
|
padding: 0 2rem 2rem 2rem;
|
||||||
width: 95%;
|
|
||||||
max-height: 90vh;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalContent h3 {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailTable {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailTable td {
|
|
||||||
padding: 0.625rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailTable td:first-child {
|
|
||||||
width: 40%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.container {
|
||||||
|
padding: 2.5rem;
|
||||||
|
margin: 2.5rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryList {
|
||||||
|
max-height: 500px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small Mobile */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.fileListSection {
|
.container {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 0.5rem;
|
margin: 0.5rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileListTitle {
|
.typeItem,
|
||||||
font-size: 1rem;
|
.entryItem {
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileCount {
|
.typeNumber,
|
||||||
font-size: 0.75rem;
|
.entryNumber {
|
||||||
padding: 0.25rem 0.5rem;
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable th,
|
.typeName,
|
||||||
.fileTable td {
|
.entryName {
|
||||||
padding: 0.5rem 0.375rem;
|
font-size: 0.85rem;
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalContent {
|
.modalHeader {
|
||||||
padding: 1rem;
|
padding: 1rem 1rem 0 1rem;
|
||||||
width: 98%;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalContent h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable {
|
.modalContent {
|
||||||
font-size: 0.75rem;
|
padding: 0 1rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable td {
|
.detailGrid {
|
||||||
padding: 0.5rem 0.375rem;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.detailValue {
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet and Desktop enhancements */
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.fileListSection {
|
|
||||||
padding: 2.5rem;
|
|
||||||
margin: 2.5rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileListTitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileTable th,
|
|
||||||
.fileTable td {
|
|
||||||
padding: 1rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalContent {
|
|
||||||
padding: 2.5rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalContent h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailTable {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailTable td {
|
|
||||||
padding: 1rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeButton {
|
.closeButton {
|
||||||
padding: 0.875rem 2rem;
|
width: 32px;
|
||||||
display: block;
|
height: 32px;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
/* Prevent text selection on interactive elements */
|
||||||
.fileListSection {
|
.typeItem,
|
||||||
padding: 3rem;
|
.entryItem,
|
||||||
margin: 3rem auto;
|
.backButton,
|
||||||
}
|
.closeButton {
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloadButton {
|
/* Focus styles for accessibility */
|
||||||
background-color: #00adef;
|
.typeItem:focus,
|
||||||
color: white;
|
.entryItem:focus,
|
||||||
border: none;
|
.backButton:focus,
|
||||||
padding: 6px 12px;
|
.closeButton:focus {
|
||||||
border-radius: 8px;
|
outline: none;
|
||||||
cursor: pointer;
|
box-shadow: var(--focus-ring);
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloadButton:hover {
|
/* High contrast mode support */
|
||||||
background-color: #008fc4;
|
@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 = {
|
// const fieldLabels = {
|
||||||
nik: "NIK",
|
// nik: "NIK",
|
||||||
fullName: "Nama Lengkap",
|
// fullName: "Nama Lengkap",
|
||||||
birthPlace: "Tempat Lahir",
|
// birthPlace: "Tempat Lahir",
|
||||||
birthDate: "Tanggal Lahir",
|
// birthDate: "Tanggal Lahir",
|
||||||
gender: "Jenis Kelamin",
|
// gender: "Jenis Kelamin",
|
||||||
address: "Alamat",
|
// address: "Alamat",
|
||||||
neighborhoodCode: "RT/RW",
|
// neighborhoodCode: "RT/RW",
|
||||||
village: "Kelurahan/Desa",
|
// village: "Kelurahan/Desa",
|
||||||
subDistrict: "Kecamatan",
|
// subDistrict: "Kecamatan",
|
||||||
religion: "Agama",
|
// religion: "Agama",
|
||||||
maritalStatus: "Status Perkawinan",
|
// maritalStatus: "Status Perkawinan",
|
||||||
occupation: "Pekerjaan",
|
// occupation: "Pekerjaan",
|
||||||
nationality: "Kewarganegaraan",
|
// nationality: "Kewarganegaraan",
|
||||||
validUntil: "Berlaku Hingga",
|
// validUntil: "Berlaku Hingga",
|
||||||
issuedCity: "Kota Terbit",
|
// issuedCity: "Kota Terbit",
|
||||||
issuedDate: "Tanggal Terbit",
|
// issuedDate: "Tanggal Terbit",
|
||||||
phoneNumber: "No. HP",
|
// phoneNumber: "No. HP",
|
||||||
email: "Email",
|
// email: "Email",
|
||||||
};
|
// };
|
||||||
|
|
||||||
function Modal({ isOpen, onClose, loading, fileTemp, onSave, onDelete }) {
|
// function Modal({ isOpen, onClose, loading, fileTemp, onSave, onDelete }) {
|
||||||
const [formData, setFormData] = useState({});
|
// const [formData, setFormData] = useState({});
|
||||||
const [step, setStep] = useState(0);
|
// const [step, setStep] = useState(0);
|
||||||
|
|
||||||
// ❗️Field yang disembunyikan, bisa diisi sesuai kebutuhan
|
// // ❗️Field yang disembunyikan, bisa diisi sesuai kebutuhan
|
||||||
const disabledFields = [];
|
// const disabledFields = [];
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
if (fileTemp) {
|
// if (fileTemp) {
|
||||||
setFormData(Array.isArray(fileTemp) ? fileTemp[0] : fileTemp);
|
// setFormData(Array.isArray(fileTemp) ? fileTemp[0] : fileTemp);
|
||||||
setStep(0);
|
// setStep(0);
|
||||||
} else {
|
// } else {
|
||||||
setFormData({});
|
// setFormData({});
|
||||||
}
|
// }
|
||||||
}, [fileTemp]);
|
// }, [fileTemp]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
// if (!isOpen) return null;
|
||||||
|
|
||||||
const handleChange = (key, newValue, isDate = false) => {
|
// const handleChange = (key, newValue, isDate = false) => {
|
||||||
setFormData((prev) => ({
|
// setFormData((prev) => ({
|
||||||
...prev,
|
// ...prev,
|
||||||
[key]: isDate ? { ...prev[key], value: newValue } : newValue,
|
// [key]: isDate ? { ...prev[key], value: newValue } : newValue,
|
||||||
}));
|
// }));
|
||||||
};
|
// };
|
||||||
|
|
||||||
const formatDate = (value) => {
|
// const formatDate = (value) => {
|
||||||
if (!value) return "";
|
// if (!value) return "";
|
||||||
const d = new Date(value);
|
// const d = new Date(value);
|
||||||
return isNaN(d) ? "" : d.toISOString().split("T")[0];
|
// return isNaN(d) ? "" : d.toISOString().split("T")[0];
|
||||||
};
|
// };
|
||||||
|
|
||||||
const renderInput = (key, value) => {
|
// const renderInput = (key, value) => {
|
||||||
if (value && typeof value === "object" && value.type === "dateTime") {
|
// if (value && typeof value === "object" && value.type === "dateTime") {
|
||||||
return (
|
// return (
|
||||||
<input
|
// <input
|
||||||
type="date"
|
// type="date"
|
||||||
value={formatDate(value.value)}
|
// value={formatDate(value.value)}
|
||||||
onChange={(e) => handleChange(key, e.target.value, true)}
|
// onChange={(e) => handleChange(key, e.target.value, true)}
|
||||||
style={styles.input}
|
// style={styles.input}
|
||||||
/>
|
// />
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (key === "address") {
|
// if (key === "address") {
|
||||||
return (
|
// return (
|
||||||
<textarea
|
// <textarea
|
||||||
rows={2}
|
// rows={2}
|
||||||
value={value || ""}
|
// value={value || ""}
|
||||||
onChange={(e) => handleChange(key, e.target.value)}
|
// onChange={(e) => handleChange(key, e.target.value)}
|
||||||
style={{ ...styles.input, resize: "vertical" }}
|
// style={{ ...styles.input, resize: "vertical" }}
|
||||||
/>
|
// />
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<input
|
// <input
|
||||||
type="text"
|
// type="text"
|
||||||
value={value != null ? value : ""}
|
// value={value != null ? value : ""}
|
||||||
onChange={(e) => handleChange(key, e.target.value)}
|
// onChange={(e) => handleChange(key, e.target.value)}
|
||||||
style={styles.input}
|
// style={styles.input}
|
||||||
/>
|
// />
|
||||||
);
|
// );
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Langkah-langkah form (per halaman)
|
// // Langkah-langkah form (per halaman)
|
||||||
const rawSteps = [
|
// const rawSteps = [
|
||||||
["nik", "fullName", "birthPlace", "birthDate"],
|
// ["nik", "fullName", "birthPlace", "birthDate"],
|
||||||
["gender", "address", "neighborhoodCode", "village", "subDistrict"],
|
// ["gender", "address", "neighborhoodCode", "village", "subDistrict"],
|
||||||
["religion", "maritalStatus", "occupation"],
|
// ["religion", "maritalStatus", "occupation"],
|
||||||
[
|
// [
|
||||||
"nationality",
|
// "nationality",
|
||||||
"validUntil",
|
// "validUntil",
|
||||||
"issuedCity",
|
// "issuedCity",
|
||||||
"issuedDate",
|
// "issuedDate",
|
||||||
"phoneNumber",
|
// "phoneNumber",
|
||||||
"email",
|
// "email",
|
||||||
],
|
// ],
|
||||||
];
|
// ];
|
||||||
|
|
||||||
// Filter field yang disable/hide
|
// // Filter field yang disable/hide
|
||||||
const steps = rawSteps.map((fields) =>
|
// const steps = rawSteps.map((fields) =>
|
||||||
fields.filter((key) => !disabledFields.includes(key))
|
// fields.filter((key) => !disabledFields.includes(key))
|
||||||
);
|
// );
|
||||||
|
|
||||||
// Filter langkah kosong
|
// // Filter langkah kosong
|
||||||
const visibleSteps = steps.filter((step) => step.length > 0);
|
// const visibleSteps = steps.filter((step) => step.length > 0);
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<div style={styles.overlay} onClick={onClose}>
|
// <div style={styles.overlay} onClick={onClose}>
|
||||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
// <div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
{loading ? (
|
// {loading ? (
|
||||||
<div style={styles.spinnerContainer}>
|
// <div style={styles.spinnerContainer}>
|
||||||
<div style={styles.spinner} />
|
// <div style={styles.spinner} />
|
||||||
<style>{spinnerStyle}</style>
|
// <style>{spinnerStyle}</style>
|
||||||
</div>
|
// </div>
|
||||||
) : (
|
// ) : (
|
||||||
Object.keys(formData).length > 0 && (
|
// Object.keys(formData).length > 0 && (
|
||||||
<>
|
// <>
|
||||||
<h4>
|
// <h4>
|
||||||
Verifikasi Data (Langkah {step + 1} dari {visibleSteps.length})
|
// Verifikasi Data (Langkah {step + 1} dari {visibleSteps.length})
|
||||||
</h4>
|
// </h4>
|
||||||
<table style={styles.table}>
|
// <table style={styles.table}>
|
||||||
<tbody>
|
// <tbody>
|
||||||
{visibleSteps[step].map((key) => (
|
// {visibleSteps[step].map((key) => (
|
||||||
<tr key={key} style={styles.tableRow}>
|
// <tr key={key} style={styles.tableRow}>
|
||||||
<td style={styles.tableLabel}>
|
// <td style={styles.tableLabel}>
|
||||||
{fieldLabels[key] || key}
|
// {fieldLabels[key] || key}
|
||||||
</td>
|
// </td>
|
||||||
<td style={styles.tableInput}>
|
// <td style={styles.tableInput}>
|
||||||
{renderInput(key, formData[key])}
|
// {renderInput(key, formData[key])}
|
||||||
</td>
|
// </td>
|
||||||
</tr>
|
// </tr>
|
||||||
))}
|
// ))}
|
||||||
</tbody>
|
// </tbody>
|
||||||
</table>
|
// </table>
|
||||||
|
|
||||||
<div
|
// <div
|
||||||
style={{
|
// style={{
|
||||||
display: "flex",
|
// display: "flex",
|
||||||
justifyContent: "space-between",
|
// justifyContent: "space-between",
|
||||||
marginTop: 10,
|
// marginTop: 10,
|
||||||
}}
|
// }}
|
||||||
>
|
// >
|
||||||
<button
|
// <button
|
||||||
disabled={step === 0}
|
// disabled={step === 0}
|
||||||
onClick={() => setStep((s) => s - 1)}
|
// onClick={() => setStep((s) => s - 1)}
|
||||||
style={{
|
// style={{
|
||||||
...styles.saveButton,
|
// ...styles.saveButton,
|
||||||
opacity: step === 0 ? 0.5 : 1,
|
// opacity: step === 0 ? 0.5 : 1,
|
||||||
}}
|
// }}
|
||||||
>
|
// >
|
||||||
< Sebelumnya
|
// < Sebelumnya
|
||||||
</button>
|
// </button>
|
||||||
|
|
||||||
<button
|
// <button
|
||||||
disabled={step === visibleSteps.length - 1}
|
// disabled={step === visibleSteps.length - 1}
|
||||||
onClick={() => setStep((s) => s + 1)}
|
// onClick={() => setStep((s) => s + 1)}
|
||||||
style={{
|
// style={{
|
||||||
...styles.saveButton,
|
// ...styles.saveButton,
|
||||||
opacity: step === visibleSteps.length - 1 ? 0.5 : 1,
|
// opacity: step === visibleSteps.length - 1 ? 0.5 : 1,
|
||||||
}}
|
// }}
|
||||||
>
|
// >
|
||||||
Selanjutnya >
|
// Selanjutnya >
|
||||||
</button>
|
// </button>
|
||||||
</div>
|
// </div>
|
||||||
|
|
||||||
<div style={styles.actions}>
|
// <div style={styles.actions}>
|
||||||
<button
|
// <button
|
||||||
onClick={() => onSave(formData)}
|
// onClick={() => onSave(formData)}
|
||||||
style={styles.saveButton}
|
// style={styles.saveButton}
|
||||||
>
|
// >
|
||||||
Simpan ke Galeri
|
// Simpan ke Galeri
|
||||||
</button>
|
// </button>
|
||||||
<button onClick={onDelete} style={styles.deleteButton}>
|
// <button onClick={onDelete} style={styles.deleteButton}>
|
||||||
Hapus
|
// Hapus
|
||||||
</button>
|
// </button>
|
||||||
</div>
|
// </div>
|
||||||
</>
|
// </>
|
||||||
)
|
// )
|
||||||
)}
|
// )}
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Styles dan spinner animation
|
// // Styles dan spinner animation
|
||||||
const styles = {
|
// const styles = {
|
||||||
overlay: {
|
// overlay: {
|
||||||
position: "fixed",
|
// position: "fixed",
|
||||||
inset: 0,
|
// inset: 0,
|
||||||
backgroundColor: "rgba(0,0,0,0.5)",
|
// backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
display: "flex",
|
// display: "flex",
|
||||||
justifyContent: "center",
|
// justifyContent: "center",
|
||||||
alignItems: "center",
|
// alignItems: "center",
|
||||||
zIndex: 1000,
|
// zIndex: 1000,
|
||||||
},
|
// },
|
||||||
modal: {
|
// modal: {
|
||||||
backgroundColor: "white",
|
// backgroundColor: "white",
|
||||||
borderRadius: 8,
|
// borderRadius: 8,
|
||||||
padding: 20,
|
// padding: 20,
|
||||||
minWidth: 350,
|
// minWidth: 350,
|
||||||
maxWidth: "90vw",
|
// maxWidth: "90vw",
|
||||||
maxHeight: "80vh",
|
// maxHeight: "80vh",
|
||||||
overflowY: "auto",
|
// overflowY: "auto",
|
||||||
boxShadow: "0 2px 10px rgba(0,0,0,0.3)",
|
// boxShadow: "0 2px 10px rgba(0,0,0,0.3)",
|
||||||
},
|
// },
|
||||||
spinnerContainer: {
|
// spinnerContainer: {
|
||||||
textAlign: "center",
|
// textAlign: "center",
|
||||||
padding: 40,
|
// padding: 40,
|
||||||
},
|
// },
|
||||||
spinner: {
|
// spinner: {
|
||||||
border: "4px solid #f3f3f3",
|
// border: "4px solid #f3f3f3",
|
||||||
borderTop: "4px solid #3498db",
|
// borderTop: "4px solid #3498db",
|
||||||
borderRadius: "50%",
|
// borderRadius: "50%",
|
||||||
width: 40,
|
// width: 40,
|
||||||
height: 40,
|
// height: 40,
|
||||||
animation: "spin 1s linear infinite",
|
// animation: "spin 1s linear infinite",
|
||||||
margin: "0 auto",
|
// margin: "0 auto",
|
||||||
},
|
// },
|
||||||
table: {
|
// table: {
|
||||||
width: "100%",
|
// width: "100%",
|
||||||
borderCollapse: "collapse",
|
// borderCollapse: "collapse",
|
||||||
},
|
// },
|
||||||
tableRow: {
|
// tableRow: {
|
||||||
borderBottom: "1px solid #eee",
|
// borderBottom: "1px solid #eee",
|
||||||
},
|
// },
|
||||||
tableLabel: {
|
// tableLabel: {
|
||||||
padding: "8px 10px",
|
// padding: "8px 10px",
|
||||||
fontWeight: "bold",
|
// fontWeight: "bold",
|
||||||
width: "30%",
|
// width: "30%",
|
||||||
verticalAlign: "top",
|
// verticalAlign: "top",
|
||||||
textTransform: "capitalize",
|
// textTransform: "capitalize",
|
||||||
},
|
// },
|
||||||
tableInput: {
|
// tableInput: {
|
||||||
padding: "8px 10px",
|
// padding: "8px 10px",
|
||||||
},
|
// },
|
||||||
input: {
|
// input: {
|
||||||
padding: 6,
|
// padding: 6,
|
||||||
borderRadius: 4,
|
// borderRadius: 4,
|
||||||
border: "1px solid #ccc",
|
// border: "1px solid #ccc",
|
||||||
width: "100%",
|
// width: "100%",
|
||||||
},
|
// },
|
||||||
actions: {
|
// actions: {
|
||||||
marginTop: 20,
|
// marginTop: 20,
|
||||||
textAlign: "right",
|
// textAlign: "right",
|
||||||
},
|
// },
|
||||||
saveButton: {
|
// saveButton: {
|
||||||
marginRight: 10,
|
// marginRight: 10,
|
||||||
backgroundColor: "green",
|
// backgroundColor: "green",
|
||||||
color: "white",
|
// color: "white",
|
||||||
border: "none",
|
// border: "none",
|
||||||
padding: "8px 14px",
|
// padding: "8px 14px",
|
||||||
borderRadius: 4,
|
// borderRadius: 4,
|
||||||
cursor: "pointer",
|
// cursor: "pointer",
|
||||||
},
|
// },
|
||||||
deleteButton: {
|
// deleteButton: {
|
||||||
backgroundColor: "red",
|
// backgroundColor: "red",
|
||||||
color: "white",
|
// color: "white",
|
||||||
border: "none",
|
// border: "none",
|
||||||
padding: "8px 14px",
|
// padding: "8px 14px",
|
||||||
borderRadius: 4,
|
// borderRadius: 4,
|
||||||
cursor: "pointer",
|
// cursor: "pointer",
|
||||||
},
|
// },
|
||||||
};
|
// };
|
||||||
|
|
||||||
const spinnerStyle = `
|
// const spinnerStyle = `
|
||||||
@keyframes spin {
|
// @keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
// 0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
// 100% { transform: rotate(360deg); }
|
||||||
}
|
// }
|
||||||
`;
|
// `;
|
||||||
|
|
||||||
export default Modal;
|
// export default Modal;
|
||||||
|
|||||||
123
src/KTPPDF.js
Normal file
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 {
|
.overlay-box {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 3px dashed red;
|
border: 3px dashed #43a0a7;
|
||||||
width: 80%; /* atau sesuaikan */
|
width: 80%; /* atau sesuaikan */
|
||||||
aspect-ratio: 85.6 / 53.98;
|
aspect-ratio: 85.6 / 53.98;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
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";
|
import styles from "./Login.module.css";
|
||||||
|
|
||||||
const Login = () => {
|
/* ===========================================================
|
||||||
const [formData, setFormData] = useState({
|
LOGIN PAGE
|
||||||
username: "",
|
=========================================================== */
|
||||||
password: "",
|
export default function LoginPage({ onLoggedIn }) {
|
||||||
});
|
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const login = () => {
|
||||||
|
const baseUrl = "https://kediritechnopark.com/";
|
||||||
|
const modal = "product";
|
||||||
|
const productId = 9;
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const authorizedUri = "http://localhost:3000/dashboard?token=";
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
const unauthorizedUri = `${baseUrl}?modal=${modal}&product_id=${productId}`;
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const url =
|
||||||
e.preventDefault();
|
`${baseUrl}?modal=${modal}&product_id=${productId}` +
|
||||||
|
`&authorized_uri=${encodeURIComponent(authorizedUri)}` +
|
||||||
|
`&unauthorized_uri=${encodeURIComponent(unauthorizedUri)}`;
|
||||||
|
|
||||||
try {
|
window.location.href = url;
|
||||||
const loginResponse = await fetch(
|
|
||||||
"https://bot.kediritechnopark.com/webhook/login/psi",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const loginDataRaw = await loginResponse.json();
|
|
||||||
const loginData = Array.isArray(loginDataRaw)
|
|
||||||
? loginDataRaw[0]
|
|
||||||
: loginDataRaw;
|
|
||||||
|
|
||||||
if (loginData?.success && loginData?.token) {
|
|
||||||
localStorage.setItem("token", loginData.token);
|
|
||||||
window.location.href = "/";
|
|
||||||
} else {
|
|
||||||
setError(loginData?.message || "Username atau password salah");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Login Error:", err);
|
|
||||||
setError("Gagal terhubung ke server");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.loginContainer}>
|
<div className={styles.loginContainer}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginCard}>
|
||||||
<img src="/PSI.png" alt="Logo" className={styles.logo} />
|
{/* Logo/Brand */}
|
||||||
<h1 className={styles.h1}>Kawal PSI</h1>
|
|
||||||
<p className={styles.subtitle}>
|
{/* Login Form */}
|
||||||
Silakan masuk untuk melanjutkan ke dashboard
|
<div className={styles.loginForm}>
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit} className={styles.form}>
|
<div className={styles.brandSection}>
|
||||||
<input
|
<div className={styles.logoIcon}>
|
||||||
type="text"
|
<FileText className={styles.logoIconSvg} />
|
||||||
name="username"
|
</div>
|
||||||
placeholder="Username"
|
<h1 className={styles.brandTitle}>SOLID DATA</h1>
|
||||||
value={formData.username}
|
<p className={styles.brandSubtitle}>Kelola data dokumen Anda dengan mudah</p>
|
||||||
onChange={handleChange}
|
</div>
|
||||||
className={styles.input}
|
<button
|
||||||
/>
|
className={styles.loginButton}
|
||||||
<input
|
onClick={login}
|
||||||
type="password"
|
>
|
||||||
name="password"
|
Masuk
|
||||||
placeholder="Password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
className={styles.input}
|
|
||||||
/>
|
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
|
||||||
<button type="submit" className={styles.button}>
|
|
||||||
Login
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
<div className={styles.footer}>© 2025 Kediri Technopark</div>
|
|
||||||
</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 {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://bot.kediritechnopark.com/webhook/0f4420a8-8517-49ba-8ec5-75adde117813/ktp/img/${nik}`,
|
`https://bot.kediritechnopark.com/webhook/ed467164-05c0-4692-bb81-a8f13116bb1b/ktp/img/ikasapta/:nik/${nik}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
30
src/SuccessPage.js
Normal file
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";
|
import styles from "./Header.module.css";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
return (
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div className={styles.title}>Officers & Roles</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// Sidebar.js
|
// Sidebar.js
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import styles from "./Sidebar.module.css";
|
import styles from "./Sidebar.module.css";
|
||||||
|
|
||||||
const Sidebar = () => {
|
const Sidebar = () => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.sidebar}>
|
<div className={styles.sidebar}>
|
||||||
<div className={styles.logo}>Dashboard</div>
|
<Link to="/dashboard" className={styles.logo}>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
<div className={styles.menu}>
|
<div className={styles.menu}>
|
||||||
<div className={styles.menuItem}>Officers</div>
|
<div className={styles.menuItem}>Officers</div>
|
||||||
<div className={styles.menuItem}>Roles</div>
|
<div className={styles.menuItem}>Roles</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user