diff --git a/package-lock.json b/package-lock.json
index 5f68eee..634cf4c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "workspace",
"version": "0.1.0",
"dependencies": {
+ "@react-pdf/renderer": "^4.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -2912,6 +2913,171 @@
}
}
},
+ "node_modules/@react-pdf/fns": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
+ "integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g=="
+ },
+ "node_modules/@react-pdf/font": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.2.tgz",
+ "integrity": "sha512-/dAWu7Y2RD1RxarDZ9SkYPHgBYOhmcDnet4W/qN/m8k+A2Hr3ja54GymSR7GGxWBtxjKtNauVKrTa9LS1n8WUw==",
+ "dependencies": {
+ "@react-pdf/pdfkit": "^4.0.3",
+ "@react-pdf/types": "^2.9.0",
+ "fontkit": "^2.0.2",
+ "is-url": "^1.2.4"
+ }
+ },
+ "node_modules/@react-pdf/image": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.3.tgz",
+ "integrity": "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==",
+ "dependencies": {
+ "@react-pdf/png-js": "^3.0.0",
+ "jay-peg": "^1.1.1"
+ }
+ },
+ "node_modules/@react-pdf/layout": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.0.tgz",
+ "integrity": "sha512-Aq+Cc6JYausWLoks2FvHe3PwK9cTuvksB2uJ0AnkKJEUtQbvCq8eCRb1bjbbwIji9OzFRTTzZij7LzkpKHjIeA==",
+ "dependencies": {
+ "@react-pdf/fns": "3.1.2",
+ "@react-pdf/image": "^3.0.3",
+ "@react-pdf/primitives": "^4.1.1",
+ "@react-pdf/stylesheet": "^6.1.0",
+ "@react-pdf/textkit": "^6.0.0",
+ "@react-pdf/types": "^2.9.0",
+ "emoji-regex": "^10.3.0",
+ "queue": "^6.0.1",
+ "yoga-layout": "^3.2.1"
+ }
+ },
+ "node_modules/@react-pdf/layout/node_modules/emoji-regex": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
+ "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="
+ },
+ "node_modules/@react-pdf/pdfkit": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.3.tgz",
+ "integrity": "sha512-k+Lsuq8vTwWsCqTp+CCB4+2N+sOTFrzwGA7aw3H9ix/PDWR9QksbmNg0YkzGbLAPI6CeawmiLHcf4trZ5ecLPQ==",
+ "dependencies": {
+ "@babel/runtime": "^7.20.13",
+ "@react-pdf/png-js": "^3.0.0",
+ "browserify-zlib": "^0.2.0",
+ "crypto-js": "^4.2.0",
+ "fontkit": "^2.0.2",
+ "jay-peg": "^1.1.1",
+ "linebreak": "^1.1.0",
+ "vite-compatible-readable-stream": "^3.6.1"
+ }
+ },
+ "node_modules/@react-pdf/png-js": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
+ "integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
+ "dependencies": {
+ "browserify-zlib": "^0.2.0"
+ }
+ },
+ "node_modules/@react-pdf/primitives": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
+ "integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ=="
+ },
+ "node_modules/@react-pdf/reconciler": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.4.tgz",
+ "integrity": "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==",
+ "dependencies": {
+ "object-assign": "^4.1.1",
+ "scheduler": "0.25.0-rc-603e6108-20241029"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@react-pdf/reconciler/node_modules/scheduler": {
+ "version": "0.25.0-rc-603e6108-20241029",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
+ "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="
+ },
+ "node_modules/@react-pdf/render": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.0.tgz",
+ "integrity": "sha512-MdWfWaqO6d7SZD75TZ2z5L35V+cHpyA43YNRlJNG0RJ7/MeVGDQv12y/BXOJgonZKkeEGdzM3EpAt9/g4E22WA==",
+ "dependencies": {
+ "@babel/runtime": "^7.20.13",
+ "@react-pdf/fns": "3.1.2",
+ "@react-pdf/primitives": "^4.1.1",
+ "@react-pdf/textkit": "^6.0.0",
+ "@react-pdf/types": "^2.9.0",
+ "abs-svg-path": "^0.1.1",
+ "color-string": "^1.9.1",
+ "normalize-svg-path": "^1.1.0",
+ "parse-svg-path": "^0.1.2",
+ "svg-arc-to-cubic-bezier": "^3.2.0"
+ }
+ },
+ "node_modules/@react-pdf/renderer": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.0.tgz",
+ "integrity": "sha512-28gpA69fU9ZQrDzmd5xMJa1bDf8t0PT3ApUKBl2PUpoE/x4JlvCB5X66nMXrfFrgF2EZrA72zWQAkvbg7TE8zw==",
+ "dependencies": {
+ "@babel/runtime": "^7.20.13",
+ "@react-pdf/fns": "3.1.2",
+ "@react-pdf/font": "^4.0.2",
+ "@react-pdf/layout": "^4.4.0",
+ "@react-pdf/pdfkit": "^4.0.3",
+ "@react-pdf/primitives": "^4.1.1",
+ "@react-pdf/reconciler": "^1.1.4",
+ "@react-pdf/render": "^4.3.0",
+ "@react-pdf/types": "^2.9.0",
+ "events": "^3.3.0",
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.6.2",
+ "queue": "^6.0.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@react-pdf/stylesheet": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.0.tgz",
+ "integrity": "sha512-BGZ2sYNUp38VJUegjva/jsri3iiRGnVNjWI+G9dTwAvLNOmwFvSJzqaCsEnqQ/DW5mrTBk/577FhDY7pv6AidA==",
+ "dependencies": {
+ "@react-pdf/fns": "3.1.2",
+ "@react-pdf/types": "^2.9.0",
+ "color-string": "^1.9.1",
+ "hsl-to-hex": "^1.0.0",
+ "media-engine": "^1.0.3",
+ "postcss-value-parser": "^4.1.0"
+ }
+ },
+ "node_modules/@react-pdf/textkit": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.0.0.tgz",
+ "integrity": "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==",
+ "dependencies": {
+ "@react-pdf/fns": "3.1.2",
+ "bidi-js": "^1.0.2",
+ "hyphen": "^1.6.4",
+ "unicode-properties": "^1.4.1"
+ }
+ },
+ "node_modules/@react-pdf/types": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.0.tgz",
+ "integrity": "sha512-ckj80vZLlvl9oYrQ4tovEaqKWP3O06Eb1D48/jQWbdwz1Yh7Y9v1cEmwlP8ET+a1Whp8xfdM0xduMexkuPANCQ==",
+ "dependencies": {
+ "@react-pdf/font": "^4.0.2",
+ "@react-pdf/primitives": "^4.1.1",
+ "@react-pdf/stylesheet": "^6.1.0"
+ }
+ },
"node_modules/@reduxjs/toolkit": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
@@ -3279,6 +3445,14 @@
"url": "https://github.com/sponsors/gregberge"
}
},
+ "node_modules/@swc/helpers": {
+ "version": "0.5.17",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
+ "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -4152,6 +4326,11 @@
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"optional": true
},
+ "node_modules/abs-svg-path": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
+ "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -4956,6 +5135,25 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
"node_modules/batch": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
@@ -4976,6 +5174,14 @@
"node": ">= 8.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -5086,11 +5292,27 @@
"node": ">=8"
}
},
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
"node_modules/browser-process-hrtime": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow=="
},
+ "node_modules/browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "dependencies": {
+ "pako": "~1.0.5"
+ }
+ },
"node_modules/browserslist": {
"version": "4.25.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
@@ -5426,6 +5648,14 @@
"wrap-ansi": "^7.0.0"
}
},
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -5549,6 +5779,15 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -5778,6 +6017,11 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
+ },
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@@ -6509,6 +6753,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -7996,6 +8245,22 @@
}
}
},
+ "node_modules/fontkit": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
+ "dependencies": {
+ "@swc/helpers": "^0.5.12",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "dfa": "^1.2.0",
+ "fast-deep-equal": "^3.1.3",
+ "restructure": "^3.0.0",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.4.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -8694,6 +8959,19 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/hsl-to-hex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
+ "integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
+ "dependencies": {
+ "hsl-to-rgb-for-reals": "^1.1.0"
+ }
+ },
+ "node_modules/hsl-to-rgb-for-reals": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
+ "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg=="
+ },
"node_modules/html-encoding-sniffer": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -8888,6 +9166,11 @@
"node": ">=10.17.0"
}
},
+ "node_modules/hyphen": {
+ "version": "1.10.6",
+ "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
+ "integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw=="
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -9674,6 +9957,14 @@
"node": ">=10"
}
},
+ "node_modules/jay-peg": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
+ "integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
+ "dependencies": {
+ "restructure": "^3.0.0"
+ }
+ },
"node_modules/jest": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
@@ -10784,6 +11075,23 @@
"node": ">=10"
}
},
+ "node_modules/linebreak": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+ "dependencies": {
+ "base64-js": "0.0.8",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/linebreak/node_modules/base64-js": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -10937,6 +11245,11 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA=="
},
+ "node_modules/media-engine": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
+ "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg=="
+ },
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -11317,6 +11630,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/normalize-svg-path": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
+ "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
+ "dependencies": {
+ "svg-arc-to-cubic-bezier": "^3.0.0"
+ }
+ },
"node_modules/normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@@ -11655,6 +11976,11 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+ },
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -11692,6 +12018,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parse-svg-path": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
+ "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="
+ },
"node_modules/parse5": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
@@ -13293,6 +13624,14 @@
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
+ "node_modules/queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "dependencies": {
+ "inherits": "~2.0.3"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -14029,6 +14368,11 @@
"node": ">=10"
}
},
+ "node_modules/restructure": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
+ },
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -14675,6 +15019,19 @@
"simple-concat": "^1.0.0"
}
},
+ "node_modules/simple-swizzle": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+ "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/simple-swizzle/node_modules/is-arrayish": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -15328,6 +15685,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/svg-arc-to-cubic-bezier": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
+ "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
+ },
"node_modules/svg-parser": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
@@ -15756,6 +16118,11 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
},
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
+ },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -16070,6 +16437,15 @@
"node": ">=4"
}
},
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/unicode-property-aliases-ecmascript": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
@@ -16078,6 +16454,20 @@
"node": ">=4"
}
},
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
+ "node_modules/unicode-trie/node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
+ },
"node_modules/unique-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
@@ -16260,6 +16650,19 @@
"d3-timer": "^3.0.1"
}
},
+ "node_modules/vite-compatible-readable-stream": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
+ "integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
@@ -17178,6 +17581,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/yoga-layout": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
+ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="
+ },
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
diff --git a/package.json b/package.json
index 1ee23e0..2aead23 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "@react-pdf/renderer": "^4.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
diff --git a/public/PSI.png b/public/PSI.png
deleted file mode 100644
index f7075fb..0000000
Binary files a/public/PSI.png and /dev/null differ
diff --git a/public/asfasf.png b/public/asfasf.png
new file mode 100644
index 0000000..afe8d50
Binary files /dev/null and b/public/asfasf.png differ
diff --git a/public/ikasapta.png b/public/ikasapta.png
new file mode 100644
index 0000000..612c497
Binary files /dev/null and b/public/ikasapta.png differ
diff --git a/public/index.html b/public/index.html
index 632e247..06545ff 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2,13 +2,10 @@
-
+
-
+
- React App
+ SOLID DATA
diff --git a/public/manifest.json b/public/manifest.json
index 080d6c7..1793573 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -1,19 +1,19 @@
{
- "short_name": "React App",
- "name": "Create React App Sample",
+ "short_name": "IKASAPTA",
+ "name": "IKASAPTA HUB",
"icons": [
{
- "src": "favicon.ico",
+ "src": "ikasapta.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
- "src": "logo192.png",
+ "src": "ikasapta.png",
"type": "image/png",
"sizes": "192x192"
},
{
- "src": "logo512.png",
+ "src": "ikasapta.png",
"type": "image/png",
"sizes": "512x512"
}
diff --git a/src/Dashboard.js b/src/Dashboard.js
index 6f3845c..319d589 100644
--- a/src/Dashboard.js
+++ b/src/Dashboard.js
@@ -43,7 +43,7 @@ const Dashboard = () => {
try {
const response = await fetch(
- "https://bot.kediritechnopark.com/webhook/dashboard/psi",
+ "https://bot.kediritechnopark.com/webhook/solid-data/dashboard",
{
method: "GET",
headers: {
@@ -75,7 +75,7 @@ const Dashboard = () => {
const token = localStorage.getItem("token");
try {
const response = await fetch(
- "https://bot.kediritechnopark.com/webhook/list-user/psi",
+ "https://bot.kediritechnopark.com/webhook/solid-data/list-user",
{
method: "GET",
headers: {
@@ -110,7 +110,7 @@ const Dashboard = () => {
try {
const response = await fetch(
- "https://bot.kediritechnopark.com/webhook/add-officer",
+ "https://bot.kediritechnopark.com/webhook/solid-data/add-officer",
{
method: "POST",
headers: {
@@ -163,7 +163,7 @@ const Dashboard = () => {
try {
const response = await fetch(
- `https://bot.kediritechnopark.com/webhook/psi/delete-officer`,
+ `https://bot.kediritechnopark.com/webhook/solid-data/delete-officer`,
{
method: "DELETE",
headers: {
@@ -193,8 +193,11 @@ const Dashboard = () => {
-

-
Kawal PSI Dashboard
+

+
SOLID
+
+ DATA
+
@@ -343,7 +346,7 @@ const Dashboard = () => {
)}
-
Grafik Pertumbuhan Anggota
+
Grafik Upload Document
{officerPerformanceData.length > 0 ? (
@@ -355,7 +358,7 @@ const Dashboard = () => {
) : (
- ๐ Belum ada data performa untuk ditampilkan
+ ๐ Belum ada data upload untuk ditampilkan
)}
@@ -370,7 +373,7 @@ const Dashboard = () => {
- ยฉ 2025 Kediri Technopark โข Dashboard PSI
+ ยฉ 2025 Kediri Technopark โข Dashboard SOLID DATA
);
diff --git a/src/Dashboard.module.css b/src/Dashboard.module.css
index 728a5fd..9a9ea26 100644
--- a/src/Dashboard.module.css
+++ b/src/Dashboard.module.css
@@ -14,7 +14,7 @@
--neutral-800: #262626;
--neutral-900: #171717;
--white: #ffffff;
- --success-green: #10b981;
+ --success-green: #43a0a7;
--warning-amber: #f59e0b;
--error-red: #ef4444;
--text-primary: #0f172a;
@@ -59,7 +59,7 @@ body {
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
- border-bottom: 3px solid #ef4444;
+ border-bottom: 3px solid #43a0a7;
position: sticky;
top: 0;
z-index: 50;
@@ -81,10 +81,18 @@ body {
}
.dashboardHeader .h1 {
+ margin: 2px;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #43a0a7;
+ letter-spacing: -0.025em;
+}
+
+.data {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
- color: #ed4344;
+ color: #154666;
letter-spacing: -0.025em;
}
@@ -207,7 +215,7 @@ body {
.summaryCard p {
font-size: 2rem;
font-weight: 700;
- color: #ef4444;
+ color: #43a0a7;
margin: 0;
line-height: 1;
}
@@ -270,7 +278,7 @@ body {
}
.submitButton {
- background-color: #ef4444;
+ background-color: #43a0a7;
color: var(--text-light);
border: none;
padding: 0.75rem 1.5rem;
@@ -285,7 +293,7 @@ body {
}
.submitButton:hover {
- background-color: #d03b3b;
+ background-color: #357734;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
@@ -296,9 +304,9 @@ body {
/* Messages */
.success {
- background-color: rgb(16 185 129 / 0.1);
+ background-color: rgb(67 160 167 / 0.1);
color: var(--success-green);
- border: 1px solid rgb(16 185 129 / 0.2);
+ border: 1px solid rgb(67 160 167 / 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
@@ -318,9 +326,9 @@ body {
}
.warning {
- background-color: #ef444417;
- color: #ef4444;
- border: 1px solid #ef444433;
+ background-color: rgb(67 160 167 / 0.1);
+ color: #43a0a7;
+ border: 1px solid rgb(67 160 167 / 0.2);
padding: 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
diff --git a/src/FileListComponent.js b/src/FileListComponent.js
index cc559cc..168f5e7 100644
--- a/src/FileListComponent.js
+++ b/src/FileListComponent.js
@@ -1,6 +1,8 @@
import React, { useState, useEffect } from "react";
import styles from "./FileListComponent.module.css";
import * as XLSX from "xlsx";
+import { PDFDownloadLink } from "@react-pdf/renderer";
+import KTPPDF from "./KTPPDF";
const FileListComponent = ({
setTotalFilesSentToday,
@@ -9,17 +11,18 @@ const FileListComponent = ({
setOfficerPerformanceData,
}) => {
const [files, setFiles] = useState([]);
+ const [filteredFiles, setFilteredFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState(null);
const [successMessage, setSuccessMessage] = useState("");
+ const [selectedDocumentType, setSelectedDocumentType] = useState("");
useEffect(() => {
const fetchFiles = async () => {
const token = localStorage.getItem("token");
-
try {
const response = await fetch(
- "https://bot.kediritechnopark.com/webhook/files",
+ "https://bot.kediritechnopark.com/webhook/solid-data/files",
{
method: "GET",
headers: {
@@ -29,35 +32,25 @@ const FileListComponent = ({
}
);
- if (!response.ok) {
+ if (!response.ok)
throw new Error(`HTTP error! Status: ${response.status}`);
- }
-
const text = await response.text();
-
- if (!text) {
- throw new Error("Server membalas kosong.");
- }
+ if (!text) throw new Error("Server membalas kosong.");
const data = JSON.parse(text);
-
- if (!data.success || !Array.isArray(data.data)) {
+ if (!data.success || !Array.isArray(data.data))
throw new Error("Format respons tidak valid.");
- }
const fileData = data.data;
-
- // 1. Set ke state
setFiles(fileData);
+ setFilteredFiles(fileData);
- // 2. Hitung total file hari ini
const today = new Date().toISOString().slice(0, 10);
const totalToday = fileData.filter((f) =>
f.created_at.startsWith(today)
).length;
setTotalFilesSentToday(totalToday);
- // 3. Hitung total bulan ini
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
@@ -69,10 +62,8 @@ const FileListComponent = ({
}).length;
setTotalFilesSentMonth(totalThisMonth);
- // 4. Total keseluruhan
setTotalFilesSentOverall(fileData.length);
- // 5. Grafik performa per bulan (dinamis)
const dateObjects = fileData.map((item) => new Date(item.created_at));
if (dateObjects.length > 0) {
const minDate = new Date(Math.min(...dateObjects));
@@ -95,19 +86,17 @@ const FileListComponent = ({
const monthKey = `${d.getFullYear()}-${String(
d.getMonth() + 1
).padStart(2, "0")}`;
- if (monthlyDataMap[monthKey] !== undefined) {
+ if (monthlyDataMap[monthKey] !== undefined)
monthlyDataMap[monthKey]++;
- }
});
const performanceArray = Object.entries(monthlyDataMap).map(
([month, count]) => {
- const [year, monthNum] = month.split("-");
const dateObj = new Date(`${month}-01`);
const label = new Intl.DateTimeFormat("id-ID", {
month: "long",
year: "numeric",
- }).format(dateObj); // hasil: "Juli 2025"
+ }).format(dateObj);
return { month: label, count };
}
);
@@ -124,11 +113,18 @@ const FileListComponent = ({
fetchFiles();
}, []);
- const formatPhoneNumber = (phone) =>
- phone?.replace(/(\d{4})(\d{4})(\d{4})/, "$1-$2-$3");
+ useEffect(() => {
+ if (selectedDocumentType) {
+ setFilteredFiles(
+ files.filter((file) => file.document_type === selectedDocumentType)
+ );
+ } else {
+ setFilteredFiles(files);
+ }
+ }, [selectedDocumentType, files]);
+
const handleRowClick = async (file) => {
const token = localStorage.getItem("token");
-
if (!token) {
alert("Token tidak ditemukan. Silakan login kembali.");
return;
@@ -136,60 +132,51 @@ const FileListComponent = ({
try {
const response = await fetch(
- `https://bot.kediritechnopark.com/webhook/6915ea36-e1f4-49ad-a7f1-a27ce0bf2279/ktp/${file.nik}`,
+ `https://bot.kediritechnopark.com/webhook/solid-data/merged?nama_lengkap=${encodeURIComponent(
+ file.nama_lengkap
+ )}`,
{
method: "GET",
headers: {
- Authorization: token, // atau `Bearer ${token}` jika diperlukan
+ Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
- if (!response.ok) {
+ if (!response.ok)
throw new Error(`HTTP error! Status: ${response.status}`);
- }
-
const text = await response.text();
- if (!text) {
- throw new Error("Respons kosong dari server.");
- }
+ if (!text) throw new Error("Respons kosong dari server.");
const data = JSON.parse(text);
-
if (data.error) {
alert(data.error);
return;
}
- const item = data[0];
-
- if (!item) {
- alert("Data tidak ditemukan.");
- return;
- }
-
- // Validasi jika ada image URL
- if (item.foto_url && !item.foto_url.match(/\.(jpg|jpeg|png|webp)$/i)) {
- console.warn(
- "URL foto bukan format gambar yang didukung:",
- item.foto_url
- );
- }
-
- setSelectedFile(item); // tampilkan di modal misalnya
+ setSelectedFile(data[0]);
} catch (error) {
- console.error("Gagal mengambil detail:", error.message || error);
+ console.error("Gagal mengambil detail:", error.message);
alert("Gagal mengambil detail. Pastikan data tersedia.");
}
};
- const closeModal = () => {
- setSelectedFile(null);
+ const getImageSrc = (base64) => {
+ if (!base64) return null;
+ const cleaned = base64.replace(/\s/g, "");
+ if (cleaned.startsWith("iVBOR")) return `data:image/png;base64,${cleaned}`;
+ if (cleaned.startsWith("/9j/")) return `data:image/jpeg;base64,${cleaned}`;
+ if (cleaned.startsWith("UklGR")) return `data:image/webp;base64,${cleaned}`;
+ return `data:image/*;base64,${cleaned}`;
};
- const exportToExcel = (data) => {
- const domain = window.location.origin;
+ const closeModal = () => setSelectedFile(null);
+
+ const formatPhoneNumber = (phone) =>
+ phone?.replace(/(\d{4})(\d{4})(\d{4})/, "$1-$2-$3");
+
+ const exportToExcel = (data) => {
const modifiedData = data.map((item) => ({
ID: item.id,
Petugas_ID: item.petugas_id,
@@ -200,7 +187,8 @@ const FileListComponent = ({
Tanggal_Lahir: new Date(item.tanggal_lahir),
Jenis_Kelamin: item.jenis_kelamin,
Alamat: item.alamat,
- RT_RW: item.rt_rw,
+ RT: item.rt,
+ RW: item.rw,
Kel_Desa: item.kel_desa,
Kecamatan: item.kecamatan,
Agama: item.agama,
@@ -213,61 +201,40 @@ const FileListComponent = ({
Pembuatan: new Date(item.pembuatan),
Kota_Pembuatan: item.kota_pembuatan,
Created_At: new Date(item.created_at),
- ImageURL: `${domain}/${item.nik}`,
}));
const worksheet = XLSX.utils.json_to_sheet(modifiedData);
-
- // Add hyperlink to ImageURL column (last column)
- modifiedData.forEach((item, index) => {
- const cellAddress = `W${index + 2}`; // Column W (ImageURL), starts at row 2
- if (worksheet[cellAddress]) {
- worksheet[cellAddress].l = {
- Target: item.ImageURL,
- Tooltip: "Lihat Gambar",
- };
- }
- });
-
- // Optional: Auto column widths (you can fine-tune)
- worksheet["!cols"] = new Array(Object.keys(modifiedData[0]).length).fill({
- wch: 20,
- });
-
- // Add autofilter
- worksheet["!autofilter"] = { ref: `A1:W1` }; // Covers all columns (A to W)
-
- // Export
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
XLSX.writeFile(workbook, "data-export.xlsx");
};
- if (loading) {
- return (
-
- );
- }
-
return (
-
๐ Daftar Anggota
+
๐ Daftar Document
+
- {files.length} anggota
+
+ {filteredFiles.length} document
+
@@ -279,11 +246,11 @@ const FileListComponent = ({
)}
- {files.length === 0 ? (
+ {filteredFiles.length === 0 ? (
Belum ada data
- Tidak ada data KTP yang tersedia saat ini.
+ Tidak ada data KK yang tersedia saat ini.
) : (
@@ -292,13 +259,12 @@ const FileListComponent = ({
| ID |
NIK |
+ Jenis |
Nama Lengkap |
- No. HP |
- Email |
- {files.map((file, index) => (
+ {filteredFiles.map((file, index) => (
handleRowClick(file)}
@@ -306,27 +272,26 @@ const FileListComponent = ({
>
| {index + 1} |
{file.nik} |
+ {file.document_type} |
{file.nama_lengkap} |
- {formatPhoneNumber(file.no_hp)} |
- {file.email} |
))}
)}
-
- {/* Modal Detail */}
+ {/* Modal dan komponen lainnya tetap seperti sebelumnya */}
{selectedFile && (
+ {" "}
e.stopPropagation()}
>
- {/* Foto KTP */}
+ {" "}
{selectedFile.data && (

- )}
-
-
๐ชช Detail Data Anggota
+ )}{" "}
+
๐ชช Detail Data Document
+
+
+ }
+ fileName={`KTP_${selectedFile.nik}.pdf`}
+ style={{
+ textDecoration: "none",
+ padding: "8px 16px",
+ color: "#fff",
+ backgroundColor: "#00adef",
+ borderRadius: "6px",
+ display: "inline-block",
+ }}
+ >
+ {({ loading }) =>
+ loading ? "Menyiapkan PDF..." : "โฌ๏ธ Unduh PDF"
+ }
+
+
-
- | NIK |
- {selectedFile.nik} |
-
-
- | Nama Lengkap |
- {selectedFile.nama_lengkap} |
-
-
- | Tempat Lahir |
- {selectedFile.tempat_lahir} |
-
-
- | Tanggal Lahir |
- {selectedFile.tanggal_lahir} |
-
-
- | Jenis Kelamin |
- {selectedFile.jenis_kelamin} |
-
-
- | Alamat |
- {selectedFile.alamat} |
-
-
- | RT/RW |
- {selectedFile.rt_rw} |
-
-
- | Kelurahan/Desa |
- {selectedFile.kel_desa} |
-
-
- | Kecamatan |
- {selectedFile.kecamatan} |
-
-
- | Agama |
- {selectedFile.agama} |
-
-
- | Status Perkawinan |
- {selectedFile.status_perkawinan} |
-
-
- | Pekerjaan |
- {selectedFile.pekerjaan} |
-
-
- | Kewarganegaraan |
- {selectedFile.kewarganegaraan} |
-
-
- | No HP |
- {selectedFile.no_hp} |
-
-
- | Email |
- {selectedFile.email} |
-
-
- | Berlaku Hingga |
- {selectedFile.berlaku_hingga} |
-
-
- | Tanggal Pembuatan |
- {selectedFile.pembuatan} |
-
-
- | Kota Pembuatan |
- {selectedFile.kota_pembuatan} |
-
+ {[
+ ["NIK", selectedFile.nik],
+ ["No.Al", selectedFile.no_al],
+ ["Nomor Akta Kelahiran", selectedFile.akta_kelahiran_nomor],
+ ["Nama Lengkap", selectedFile.nama_lengkap],
+ ["Anak Ke", selectedFile.anak_ke],
+ ["Tempat Lahir", selectedFile.tempat_lahir],
+ ["Tanggal Lahir", selectedFile.tanggal_lahir],
+ ["Jenis Kelamin", selectedFile.jenis_kelamin],
+ ["Alamat", selectedFile.alamat],
+ ["Ayah", selectedFile.ayah],
+ ["ibu", selectedFile.ibu],
+ ["RT", selectedFile.rt],
+ ["RW", selectedFile.rw],
+ ["Kelurahan/Desa", selectedFile.kel_desa],
+ ["Kecamatan", selectedFile.kecamatan],
+ ["Agama", selectedFile.agama],
+ ["Status Perkawinan", selectedFile.status_perkawinan],
+ ["Pekerjaan", selectedFile.pekerjaan],
+ ["Kewarganegaraan", selectedFile.kewarganegaraan],
+ ["No HP", selectedFile.no_hp],
+ ["Email", selectedFile.email],
+ ["Berlaku Hingga", selectedFile.berlaku_hingga],
+ ["Tanggal Pembuatan", selectedFile.pembuatan],
+ ["Kota Pembuatan", selectedFile.kota_pembuatan],
+ ]
+ .filter(([_, value]) => value !== null && value !== "")
+ .map(([label, value]) => (
+
+ | {label} |
+ {value} |
+
+ ))}
-
+ {" "}
+ Tutup{" "}
+ {" "}
+
{" "}
)}
diff --git a/src/FileListComponent.module.css b/src/FileListComponent.module.css
index 53ad054..bc0f14e 100644
--- a/src/FileListComponent.module.css
+++ b/src/FileListComponent.module.css
@@ -14,7 +14,7 @@
--neutral-800: #262626;
--neutral-900: #171717;
--white: #ffffff;
- --success-green: #10b981;
+ --success-green: #43a0a7;
--warning-amber: #f59e0b;
--error-red: #ef4444;
--text-primary: #0f172a;
@@ -72,19 +72,19 @@
}
.fileCount {
- font-size: 0.875rem;
+ font-size: 0.6rem;
color: #ffffff;
font-weight: 500;
- background-color: #ef4444;
+ background-color: #43a0a7;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
border: 1px solid var(--border-light);
}
.successMessage {
- background-color: rgb(16 185 129 / 0.1);
+ background-color: rgb(67 160 167 / 0.1);
color: var(--success-green);
- border: 1px solid rgb(16 185 129 / 0.2);
+ border: 1px solid rgb(67 160 167 / 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
@@ -116,7 +116,7 @@
}
.fileTable th {
- background-color: #ef4444;
+ background-color: #43a0a7;
padding: 0.75rem;
text-align: center;
font-weight: 600;
@@ -176,7 +176,7 @@
width: 2rem;
height: 2rem;
border: 3px solid var(--neutral-300);
- border-top: 3px solid #ef4444;
+ border-top: 3px solid #43a0a7;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
@@ -203,13 +203,13 @@
}
.tableContainer::-webkit-scrollbar-thumb {
- background: #ef4444;
+ background: #43a0a7;
border-radius: 4px;
transition: background 0.2s ease;
}
.tableContainer::-webkit-scrollbar-thumb:hover {
- background: #dc2626;
+ background: #306a2f;
}
.tableContainer::-webkit-scrollbar-corner {
@@ -218,7 +218,7 @@
.tableContainer {
scrollbar-width: thin;
- scrollbar-color: #ef4444 var(--neutral-100);
+ scrollbar-color: #43a0a7 var(--neutral-100);
}
/* Modal Styles - Matching Dashboard Design */
@@ -291,7 +291,7 @@
}
.closeButton {
- background-color: #ef4444;
+ background-color: #43a0a7;
color: var(--text-light);
border: none;
padding: 0.75rem 1.5rem;
@@ -399,7 +399,7 @@
}
.fileCount {
- font-size: 0.75rem;
+ font-size: 0.6rem;
padding: 0.25rem 0.5rem;
}
@@ -482,14 +482,14 @@
}
.downloadButton {
- background-color: #00adef;
+ background-color: #164665;
color: white;
border: none;
- padding: 6px 12px;
- border-radius: 8px;
+ padding: 0.25rem 0.5rem;
+ border-radius: 1rem;
cursor: pointer;
font-weight: bold;
- font-size: 0.9rem;
+ font-size: 0.6rem;
transition: background-color 0.3s ease;
}
diff --git a/src/KTPPDF.js b/src/KTPPDF.js
new file mode 100644
index 0000000..bb1954c
--- /dev/null
+++ b/src/KTPPDF.js
@@ -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 }) => (
+
+
+ Biodata Anggota
+ {data.data ? (
+
+ ) : data.fallbackImage ? (
+
+ ) : (
+ Tidak ada foto KTP tersedia
+ )}
+
+
+
+ NIK: {data.nik}
+
+
+ Nama Lengkap: {data.nama_lengkap}
+
+
+ Tempat Lahir:{" "}
+ {data.tempat_lahir || "-"}
+
+
+ Tanggal Lahir:{" "}
+ {data.tanggal_lahir || "-"}
+
+
+ Jenis Kelamin:{" "}
+ {data.jenis_kelamin || "-"}
+
+
+ Alamat: {data.alamat || "-"}
+
+
+ RT/RW: {data.rt_rw || "-"}
+
+
+ Kel/Desa: {data.kel_desa || "-"}
+
+
+ Kecamatan: {data.kecamatan || "-"}
+
+
+ Agama: {data.agama || "-"}
+
+
+ Status Perkawinan:{" "}
+ {data.status_perkawinan || "-"}
+
+
+ Pekerjaan: {data.pekerjaan || "-"}
+
+
+ Kewarganegaraan:{" "}
+ {data.kewarganegaraan || "-"}
+
+
+ No HP: {data.no_hp || "-"}
+
+
+ Email: {data.email || "-"}
+
+
+ Berlaku Hingga:{" "}
+ {data.berlaku_hingga || "-"}
+
+
+ Tanggal Pembuatan:{" "}
+ {data.pembuatan || "-"}
+
+
+ Kota Pembuatan:{" "}
+ {data.kota_pembuatan || "-"}
+
+
+
+
+);
+
+export default KTPPDF;
diff --git a/src/KTPScanner.css b/src/KTPScanner.css
index 6e56610..98adda8 100644
--- a/src/KTPScanner.css
+++ b/src/KTPScanner.css
@@ -1,6 +1,6 @@
.overlay-box {
position: absolute;
- border: 3px dashed red;
+ border: 3px dashed #43a0a7;
width: 80%; /* atau sesuaikan */
aspect-ratio: 85.6 / 53.98;
top: 50%;
diff --git a/src/KTPScanner.js b/src/KTPScanner.js
index defdc53..42adc7d 100644
--- a/src/KTPScanner.js
+++ b/src/KTPScanner.js
@@ -1,72 +1,342 @@
import React, { useEffect, useRef, useState } from "react";
-import styless from "./Dashboard.module.css";
-
-import { useNavigate } from "react-router-dom";
-
-import Modal from "./Modal";
-import PaginatedFormEditable from "./PaginatedFormEditable";
-
+// import PaginatedFormEditable from "./PaginatedFormEditable"; // Assuming this is provided externally or defined above
const STORAGE_KEY = "camera_canvas_gallery";
+// Placeholder for PaginatedFormEditable if not provided by user.
+// In a real scenario, this would be a separate file.
+const PaginatedFormEditable = ({ data, handleSimpan }) => {
+ const [editableData, setEditableData] = useState(data);
+
+ useEffect(() => {
+ setEditableData(data);
+ }, [data]);
+
+ const handleChange = (key, value) => {
+ setEditableData((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ if (!editableData) return null;
+
+ return (
+
+
Form Data
+ {Object.entries(editableData).map(([key, value]) => (
+
+
+ handleChange(key, e.target.value)}
+ style={paginatedFormEditableStyles.input}
+ />
+
+ ))}
+
+
+ );
+};
+
+const paginatedFormEditableStyles = {
+ container: {
+ backgroundColor: "#f9f9f9",
+ borderRadius: "12px",
+ padding: "20px",
+ marginTop: "20px",
+ boxShadow: "0 4px 10px rgba(0,0,0,0.05)",
+ },
+ title: {
+ fontSize: "20px",
+ fontWeight: "bold",
+ marginBottom: "15px",
+ color: "#333",
+ },
+ fieldGroup: {
+ marginBottom: "15px",
+ },
+ label: {
+ display: "block",
+ marginBottom: "5px",
+ fontWeight: "600",
+ color: "#555",
+ },
+ input: {
+ width: "100%",
+ padding: "10px",
+ border: "1px solid #ddd",
+ borderRadius: "8px",
+ fontSize: "16px",
+ boxSizing: "border-box",
+ },
+ saveButton: {
+ backgroundColor: "#429241",
+ color: "white",
+ padding: "12px 20px",
+ borderRadius: "8px",
+ border: "none",
+ fontSize: "16px",
+ fontWeight: "bold",
+ cursor: "pointer",
+ marginTop: "15px",
+ width: "100%",
+ },
+};
+
+// Custom Modal Component for New Document Type
+const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
+ const [documentName, setDocumentName] = useState("");
+ const [formFields, setFormFields] = useState([
+ { id: crypto.randomUUID(), label: "" },
+ ]); // State for dynamic form fields
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Reset state when modal opens/closes
+ useEffect(() => {
+ if (isOpen) {
+ setDocumentName("");
+ setFormFields([{ id: crypto.randomUUID(), label: "" }]);
+ }
+ }, [isOpen]);
+
+ const handleAddField = () => {
+ setFormFields([...formFields, { id: crypto.randomUUID(), label: "" }]);
+ };
+
+ const handleRemoveField = (idToRemove) => {
+ setFormFields(formFields.filter((field) => field.id !== idToRemove));
+ };
+
+ const handleFieldLabelChange = (id, newLabel) => {
+ setFormFields(
+ formFields.map((field) =>
+ field.id === id ? { ...field, label: newLabel } : field
+ )
+ );
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!documentName.trim()) return;
+
+ // Ensure all fields have labels
+ const hasEmptyField = formFields.some((field) => !field.label.trim());
+ if (hasEmptyField) {
+ // Use a custom message box instead of alert
+ // For this example, I'll just log to console, but in a real app, a modal would appear.
+ console.log("Please fill all field labels.");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ // Pass both documentName and formFields to the onSubmit handler
+ await onSubmit(
+ documentName.trim(),
+ formFields.map((field) => ({ label: field.label.trim() }))
+ );
+ setDocumentName("");
+ setFormFields([{ id: crypto.randomUUID(), label: "" }]);
+ onClose();
+ } catch (error) {
+ console.error("Error submitting new document type:", error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
Tambah Jenis Dokumen Baru
+
+
+
+
+
+ );
+};
const CameraCanvas = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef(null);
- const navigate = useNavigate();
+ // const useNavigate = () => { /* Placeholder if react-router-dom is not available */ return () => {}; }; // Uncomment if react-router-dom is fully set up
+ // const navigate = useNavigate(); // Uncomment if react-router-dom is fully set up
const videoRef = useRef(null);
const canvasRef = useRef(null);
const hiddenCanvasRef = useRef(null);
const [capturedImage, setCapturedImage] = useState(null);
- const [galleryImages, setGalleryImages] = useState([]);
+ const [galleryImages, setGalleryImages] = useState([]); // This state is not used in the current display logic
const [fileTemp, setFileTemp] = useState(null);
const [isFreeze, setIsFreeze] = useState(false);
const freezeFrameRef = useRef(null);
- const [modalOpen, setModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
+ const [KTPdetected, setKTPdetected] = useState(false); // Not directly used for KTP anymore, but kept for consistency
+ const [showDocumentSelection, setShowDocumentSelection] = useState(true);
+ const [selectedDocumentType, setSelectedDocumentType] = useState(null);
+ const [cameraInitialized, setCameraInitialized] = useState(false);
+ const [showNewDocumentModal, setShowNewDocumentModal] = useState(false);
- const [KTPdetected, setKTPdetected] = useState(false);
+ const handleDocumentTypeSelection = (type) => {
+ if (type === "new") {
+ setShowNewDocumentModal(true);
+ } else {
+ setSelectedDocumentType(type);
+ setShowDocumentSelection(false);
+ initializeCamera();
+ }
+ };
const fileInputRef = useRef(null);
const triggerFileSelect = () => {
fileInputRef.current?.click();
};
- const loadImageToCanvas = (src, width, height) => {
- return new Promise((resolve) => {
- const img = new Image();
- img.src = src;
- img.onload = () => {
- const canvas = document.createElement("canvas");
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext("2d");
- ctx.drawImage(img, 0, 0, width, height);
- resolve(canvas);
- };
- });
+
+ // Removed loadImageToCanvas as it's not directly used in the current flow.
+
+ const handleNewDocumentSubmit = async (documentName, fields) => {
+ try {
+ const token = localStorage.getItem("token"); // Ensure token is available
+
+ // Kirim ke webhook
+ const response = await fetch(
+ "https://bot.kediritechnopark.com/webhook/solid-data/newtype",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ document_type: documentName,
+ fields: fields, // Include the new dynamic fields
+ }),
+ }
+ );
+
+ const result = await response.json();
+
+ if (response.ok && result.status) {
+ // Simpan ID dokumen ke localStorage
+ localStorage.setItem("document_id", result.document_id);
+
+ // Set nama document type agar lanjut ke kamera
+ setSelectedDocumentType(
+ result.document_type.toLowerCase().replace(/\s+/g, "_")
+ );
+
+ setShowDocumentSelection(false);
+
+ // Lanjutkan ke kamera
+ initializeCamera();
+
+ console.log("Document ID:", result.document_id);
+ console.log(
+ "New Document Type Created:",
+ result.document_type,
+ "with fields:",
+ fields
+ );
+ } else {
+ throw new Error(result.message || "Gagal membuat document type");
+ }
+ } catch (error) {
+ console.error("Error submitting new document type:", error);
+ // Use a custom message box instead of alert
+ console.log("Gagal membuat dokumen. Coba lagi.");
+ }
};
- // const isImageSimilar = (canvasA, canvasB) => {
- // const ctxA = canvasA.getContext("2d");
- // const ctxB = canvasB.getContext("2d");
-
- // const imgA = ctxA.getImageData(0, 0, canvasA.width, canvasA.height);
- // const imgB = ctxB.getImageData(0, 0, canvasB.width, canvasB.height);
-
- // const diffPixels = pixelmatch(
- // imgA.data,
- // imgB.data,
- // null,
- // canvasA.width,
- // canvasA.height,
- // { threshold: 0.5 }
- // );
-
- // const similarity = diffPixels / (canvasA.width * canvasA.height);
- // return similarity < 0.2; // you can adjust the threshold
- // };
-
const rectRef = useRef({
x: 0,
y: 0,
@@ -112,86 +382,134 @@ const CameraCanvas = () => {
ctx.restore();
};
+ const initializeCamera = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: { ideal: "environment" } },
+ audio: false,
+ });
+
+ if (videoRef.current) {
+ videoRef.current.srcObject = stream;
+
+ videoRef.current.onloadedmetadata = () => {
+ videoRef.current.play();
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ const hiddenCanvas = hiddenCanvasRef.current;
+ const ctx = canvas.getContext("2d");
+
+ // Set canvas dimensions to match video stream
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ canvas.style.maxWidth = "100%";
+ canvas.style.height = "auto"; // Maintain aspect ratio
+
+ hiddenCanvas.width = video.videoWidth;
+ hiddenCanvas.height = video.videoHeight;
+
+ // Calculate rectangle dimensions based on a common document aspect ratio (e.g., ID card)
+ const rectWidth = canvas.width * 0.9;
+ const rectHeight = (53.98 / 85.6) * rectWidth; // Standard ID card aspect ratio
+ const rectX = (canvas.width - rectWidth) / 2;
+ const rectY = (canvas.height - rectHeight) / 2;
+
+ rectRef.current = {
+ x: rectX,
+ y: rectY,
+ width: rectWidth,
+ height: rectHeight,
+ radius: 20,
+ };
+
+ const drawToCanvas = () => {
+ if (video.readyState === 4) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ if (isFreeze && freezeFrameRef.current) {
+ ctx.putImageData(freezeFrameRef.current, 0, 0);
+ } else {
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ }
+ drawRoundedRect(
+ ctx,
+ rectRef.current.x,
+ rectRef.current.y,
+ rectRef.current.width,
+ rectRef.current.height,
+ rectRef.current.radius
+ );
+ if (isFreeze) {
+ fillOutsideRect(
+ ctx,
+ rectRef.current,
+ canvas.width,
+ canvas.height
+ );
+ }
+ }
+ // Continue drawing only if document selection is not active
+ if (!showDocumentSelection) {
+ requestAnimationFrame(drawToCanvas);
+ }
+ };
+
+ drawToCanvas();
+ setCameraInitialized(true);
+ };
+ }
+ } catch (err) {
+ console.error("Gagal mendapatkan kamera:", err);
+ // Handle camera access denied or not available
+ // For example, show a message to the user or offer manual upload only.
+ }
+ };
+
useEffect(() => {
+ // This effect is for loading gallery images, which isn't directly used
+ // in the current display but kept for consistency with original code.
const savedGallery = localStorage.getItem(STORAGE_KEY);
if (savedGallery) setGalleryImages(JSON.parse(savedGallery));
+ }, []);
- const getCameraStream = async () => {
- try {
- const stream = await navigator.mediaDevices.getUserMedia({
- video: { facingMode: { ideal: "environment" } },
- audio: false,
- });
+ // Modified useEffect to only run when isFreeze changes and camera is initialized
+ useEffect(() => {
+ if (cameraInitialized) {
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext("2d");
- if (videoRef.current) {
- videoRef.current.srcObject = stream;
-
- videoRef.current.onloadedmetadata = () => {
- videoRef.current.play();
- const video = videoRef.current;
- const canvas = canvasRef.current;
- const hiddenCanvas = hiddenCanvasRef.current;
- const ctx = canvas.getContext("2d");
-
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
- canvas.style.maxWidth = "100%";
- canvas.style.height = "auto";
-
- hiddenCanvas.width = video.videoWidth;
- hiddenCanvas.height = video.videoHeight;
-
- const rectWidth = canvas.width * 0.9;
- const rectHeight = (53.98 / 85.6) * rectWidth;
- const rectX = (canvas.width - rectWidth) / 2;
- const rectY = (canvas.height - rectHeight) / 2;
-
- rectRef.current = {
- x: rectX,
- y: rectY,
- width: rectWidth,
- height: rectHeight,
- radius: 20,
- };
-
- const drawToCanvas = () => {
- if (video.readyState === 4) {
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- if (isFreeze && freezeFrameRef.current) {
- ctx.putImageData(freezeFrameRef.current, 0, 0);
- } else {
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
- }
- drawRoundedRect(
- ctx,
- rectRef.current.x,
- rectRef.current.y,
- rectRef.current.width,
- rectRef.current.height,
- rectRef.current.radius
- );
- if (isFreeze) {
- fillOutsideRect(
- ctx,
- rectRef.current,
- canvas.width,
- canvas.height
- );
- }
- }
- requestAnimationFrame(drawToCanvas);
- };
-
- drawToCanvas();
- };
+ const drawToCanvas = () => {
+ if (video && video.readyState === 4) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ if (isFreeze && freezeFrameRef.current) {
+ ctx.putImageData(freezeFrameRef.current, 0, 0);
+ } else {
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ }
+ drawRoundedRect(
+ ctx,
+ rectRef.current.x,
+ rectRef.current.y,
+ rectRef.current.width,
+ rectRef.current.height,
+ rectRef.current.radius
+ );
+ if (isFreeze) {
+ fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height);
+ }
}
- } catch (err) {
- console.error("Gagal mendapatkan kamera:", err);
- }
- };
+ if (!showDocumentSelection) {
+ requestAnimationFrame(drawToCanvas);
+ }
+ };
- getCameraStream();
- }, [isFreeze]);
+ // Start drawing loop whenever isFreeze or cameraInitialized changes
+ // and document selection is not active.
+ if (!showDocumentSelection) {
+ drawToCanvas();
+ }
+ }
+ }, [isFreeze, cameraInitialized, showDocumentSelection]);
const shootImage = async () => {
const video = videoRef.current;
@@ -200,6 +518,7 @@ const CameraCanvas = () => {
const hiddenCtx = hiddenCanvas.getContext("2d");
const visibleCtx = canvasRef.current.getContext("2d");
+ // Capture the current frame from the visible canvas to freeze it
freezeFrameRef.current = visibleCtx.getImageData(
0,
0,
@@ -209,13 +528,16 @@ const CameraCanvas = () => {
setIsFreeze(true);
setLoading(true);
+ // Draw the video frame onto the hidden canvas for cropping
hiddenCtx.drawImage(video, 0, 0, hiddenCanvas.width, hiddenCanvas.height);
+ // Create a new canvas for the cropped image
const cropCanvas = document.createElement("canvas");
cropCanvas.width = Math.floor(width);
cropCanvas.height = Math.floor(height);
const cropCtx = cropCanvas.getContext("2d");
+ // Draw the cropped portion from the hidden canvas to the crop canvas
cropCtx.drawImage(
hiddenCanvas,
Math.floor(x),
@@ -231,9 +553,9 @@ const CameraCanvas = () => {
const imageDataUrl = cropCanvas.toDataURL("image/png", 1.0);
setCapturedImage(imageDataUrl);
- setKTPdetected(true);
+ setKTPdetected(true); // This variable name might be misleading now, but kept for consistency
setLoading(false);
- // Continue to OCR etc...
+ // Continue to OCR etc... (handled by ReadImage)
};
function base64ToFile(base64Data, fileName) {
@@ -251,7 +573,7 @@ const CameraCanvas = () => {
const ReadImage = async (capturedImage) => {
try {
setLoading(true);
- const token = localStorage.getItem("token");
+ const token = localStorage.getItem("token"); // Ensure token is available
// Ubah base64 ke file
const file = base64ToFile(capturedImage, "image.jpg");
@@ -259,13 +581,14 @@ const CameraCanvas = () => {
// Gunakan FormData
const formData = new FormData();
formData.append("image", file);
+ formData.append("document_type", selectedDocumentType); // Add document type to form data
const res = await fetch(
- "https://bot.kediritechnopark.com/webhook/mastersnapper/read",
+ "https://bot.kediritechnopark.com/webhook/solid-data/scan",
{
method: "POST",
headers: {
- Authorization: `Bearer ${token}`,
+ Authorization: `Bearer ${token}`, // No Content-Type for FormData, browser sets it
},
body: formData,
}
@@ -275,64 +598,98 @@ const CameraCanvas = () => {
const data = await res.json();
if (data.responseCode == 409) {
- console.log(409);
+ console.log("Error 409: Document already registered.");
setFileTemp({ error: 409 });
return;
}
- console.log(data);
+ console.log("Scan Result:", data);
setFileTemp(data);
} catch (error) {
console.error("Failed to read image:", error);
+ // Handle error, e.g., show a message to the user
}
};
- const handleSaveTemp = async (verifiedData) => {
+ const handleSaveTemp = async (verifiedData, documentType) => {
try {
setLoading(true);
- const token = localStorage.getItem("token");
+ const token = localStorage.getItem("token"); // Ensure token is available
const formData = new FormData();
-
- // Tambahkan data terverifikasi sebagai JSON string
formData.append("data", JSON.stringify(verifiedData));
+ if (!documentType) {
+ console.error("โ documentType undefined! Cannot save.");
+ setLoading(false);
+ return;
+ }
+
+ console.log("โ
Saving data for documentType:", documentType);
+ formData.append("document_type", documentType);
+
const res = await fetch(
- "https://bot.kediritechnopark.com/webhook/mastersnapper/save",
+ "https://bot.kediritechnopark.com/webhook/solid-data/save",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
- // Jangan set Content-Type secara manual untuk FormData
+ // Content-Type is set by browser for FormData
},
body: formData,
}
);
setLoading(false);
- setFileTemp(null);
+ const result = await res.json();
+ console.log("Save Result:", result);
+ if (res.ok && result.status) {
+ // Successfully saved, clear temp data and reset camera
+ setFileTemp(null);
+ setIsFreeze(false);
+ setCapturedImage(null);
+ setKTPdetected(false);
+ // Optionally, go back to document selection or re-initialize camera for new scan
+ goBackToSelection();
+ } else {
+ console.error(
+ "Failed to save data:",
+ result.message || "Unknown error"
+ );
+ // Show error message to user
+ }
} catch (err) {
console.error("Gagal menyimpan ke server:", err);
+ // Handle error, e.g., show a message to the user
}
};
const handleDeleteTemp = async () => {
+ // This function seems to be for deleting temporary data on the server.
+ // The current implementation is commented out and sends `fileTemp` as body.
+ // If this is meant to delete a specific temporary scan result, it needs
+ // an ID or identifier from `fileTemp`.
try {
- await fetch(
- "https://bot.kediritechnopark.com/webhook/mastersnapper/delete",
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ fileTemp }),
- }
- );
-
+ // Example of how it might be implemented if an ID was available:
+ // const tempScanId = fileTemp?.id; // Assuming fileTemp has an ID
+ // if (tempScanId) {
+ // await fetch(
+ // `https://bot.kediritechnopark.com/webhook/mastersnapper/delete/${tempScanId}`,
+ // {
+ // method: "DELETE", // Or POST with a specific action
+ // headers: { "Content-Type": "application/json" },
+ // }
+ // );
+ // }
setFileTemp(null);
+ setIsFreeze(false);
+ setCapturedImage(null);
} catch (err) {
console.error("Gagal menghapus dari server:", err);
}
};
+ // `removeImage` is for galleryImages, which is not currently displayed.
const removeImage = (index) => {
const newGallery = [...galleryImages];
newGallery.splice(index, 1);
@@ -340,7 +697,8 @@ const CameraCanvas = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newGallery));
};
- const aspectRatio = 53.98 / 85.6;
+ // `aspectRatio` is used in `initializeCamera` to calculate rectHeight.
+ // const aspectRatio = 53.98 / 85.6; // Already used implicitly in initializeCamera
const handleManualUpload = async (e) => {
const file = e.target.files[0];
@@ -350,7 +708,7 @@ const CameraCanvas = () => {
reader.onloadend = () => {
const imageDataUrl = reader.result;
setCapturedImage(imageDataUrl);
- setIsFreeze(true);
+ setIsFreeze(true); // Freeze the display with the uploaded image
const image = new Image();
image.onload = async () => {
@@ -359,14 +717,13 @@ const CameraCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
+ // Clear canvas and draw the uploaded image, respecting the crop area
ctx.clearRect(0, 0, canvas.width, canvas.height);
- if (isFreeze && freezeFrameRef.current) {
- ctx.putImageData(freezeFrameRef.current, 0, 0);
- } else {
- const video = videoRef.current;
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
- }
+ // Draw the image to the entire canvas first
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
+
+ // Then draw the rounded rectangle outline
drawRoundedRect(
ctx,
rectRef.current.x,
@@ -376,90 +733,10 @@ const CameraCanvas = () => {
rectRef.current.radius
);
- ctx.save();
- ctx.beginPath();
- ctx.moveTo(
- rectRef.current.x + rectRef.current.radius,
- rectRef.current.y
- );
- ctx.lineTo(
- rectRef.current.x + rectRef.current.width - rectRef.current.radius,
- rectRef.current.y
- );
- ctx.quadraticCurveTo(
- rectRef.current.x + rectRef.current.width,
- rectRef.current.y,
- rectRef.current.x + rectRef.current.width,
- rectRef.current.y + rectRef.current.radius
- );
- ctx.lineTo(
- rectRef.current.x + rectRef.current.width,
- rectRef.current.y + rectRef.current.height - rectRef.current.radius
- );
- ctx.quadraticCurveTo(
- rectRef.current.x + rectRef.current.width,
- rectRef.current.y + rectRef.current.height,
- rectRef.current.x + rectRef.current.width - rectRef.current.radius,
- rectRef.current.y + rectRef.current.height
- );
- ctx.lineTo(
- rectRef.current.x + rectRef.current.radius,
- rectRef.current.y + rectRef.current.height
- );
- ctx.quadraticCurveTo(
- rectRef.current.x,
- rectRef.current.y + rectRef.current.height,
- rectRef.current.x,
- rectRef.current.y + rectRef.current.height - rectRef.current.radius
- );
- ctx.lineTo(
- rectRef.current.x,
- rectRef.current.y + rectRef.current.radius
- );
- ctx.quadraticCurveTo(
- rectRef.current.x,
- rectRef.current.y,
- rectRef.current.x + rectRef.current.radius,
- rectRef.current.y
- );
- ctx.closePath();
- ctx.clip();
-
- // === Object-Fit: Cover Logic ===
- const imageAspectRatio = image.width / image.height;
- const rectAspectRatio = rectWidth / rectHeight;
-
- let sx, sy, sWidth, sHeight;
-
- if (imageAspectRatio > rectAspectRatio) {
- // Image is wider than rect
- sHeight = image.height;
- sWidth = sHeight * rectAspectRatio;
- sx = (image.width - sWidth) / 2;
- sy = 0;
- } else {
- // Image is taller than rect
- sWidth = image.width;
- sHeight = sWidth / rectAspectRatio;
- sx = 0;
- sy = (image.height - sHeight) / 2;
- }
-
- ctx.drawImage(
- image,
- sx,
- sy,
- sWidth,
- sHeight,
- rectRef.current.x,
- rectRef.current.y,
- rectWidth,
- rectHeight
- );
- // ==============================
-
- ctx.restore();
+ // Fill outside the rectangle to create the masking effect
+ fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height);
+ // Store this state as the freeze frame
freezeFrameRef.current = ctx.getImageData(
0,
0,
@@ -467,42 +744,103 @@ const CameraCanvas = () => {
canvas.height
);
+ // Create a separate canvas for the actual cropped image data
const cropCanvas = document.createElement("canvas");
cropCanvas.width = rectWidth;
cropCanvas.height = rectHeight;
const cropCtx = cropCanvas.getContext("2d");
+ // Draw the cropped portion from the main canvas to the crop canvas
cropCtx.drawImage(
- canvas,
+ canvas, // Source canvas
rectRef.current.x,
rectRef.current.y,
rectWidth,
rectHeight,
- 0,
- 0,
+ 0, // Destination x
+ 0, // Destination y
rectWidth,
rectHeight
);
- setKTPdetected(true);
+ setKTPdetected(true); // Indicate that an image is ready for scan
};
image.src = imageDataUrl;
};
reader.readAsDataURL(file);
};
+ const goBackToSelection = () => {
+ setShowDocumentSelection(true);
+ setSelectedDocumentType(null);
+ setCameraInitialized(false);
+ setIsFreeze(false);
+ setCapturedImage(null);
+ setFileTemp(null);
+ setKTPdetected(false);
+
+ // Stop camera stream
+ if (videoRef.current && videoRef.current.srcObject) {
+ const stream = videoRef.current.srcObject;
+ const tracks = stream.getTracks();
+ tracks.forEach((track) => track.stop());
+ videoRef.current.srcObject = null;
+ }
+ };
+
+ // Function to get document display info
+ const getDocumentDisplayInfo = (docType) => {
+ switch (docType) {
+ case "ktp":
+ return { icon: "๐", name: "KTP", fullName: "Kartu Tanda Penduduk" };
+ case "kk":
+ return { icon: "๐จโ๐ฉโ๐งโ๐ฆ", name: "KK", fullName: "Kartu Keluarga" };
+ case "akta_kelahiran":
+ return {
+ icon: "๐ถ",
+ name: "Akta Kelahiran",
+ fullName: "Akta Kelahiran",
+ };
+ case "new": // For the "new" option itself
+ return { icon: "โจ", name: "New Document", fullName: "Dokumen Baru" };
+ default:
+ // For dynamically added document types, use the name itself
+ return {
+ icon: "๐",
+ name: docType,
+ fullName: docType
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (l) => l.toUpperCase()),
+ };
+ }
+ };
+
+ // Placeholder for `useNavigate` if `react-router-dom` is not used in this environment.
+ // If `react-router-dom` is available, uncomment the original `useNavigate`.
+ const navigate = (path) => {
+ console.log(`Navigating to: ${path}`);
+ // In a real browser environment with react-router-dom, this would be:
+ // useNavigate()(path);
+ };
+
return (
-
-
-

-
Kawal PSI Dashboard
+
+
+ {/* Placeholder for image, ensure it's accessible */}
+

+
SOLID
+
DATA
-
+
{isMenuOpen && (
-
+
@@ -537,7 +875,7 @@ const CameraCanvas = () => {
navigate("/dashboard");
setIsMenuOpen(false);
}}
- className={styless.dropdownItem}
+ style={styles.dropdownItem}
>
Dashboard
@@ -545,125 +883,240 @@ const CameraCanvas = () => {
)}
-
-
-
-
- {!isFreeze ? (
- <>
-
- Ambil Gambar
+ {showDocumentSelection ? (
+
+
+
Pilih Jenis Dokumen
+
+ Silakan pilih jenis dokumen yang akan Anda scan
+
+
+ {/* New horizontal layout like in the image */}
+
+
+
+
+
+
+
+
-
atau
-
- Upload Gambar
-
-
handleManualUpload(e)}
- style={{ marginRight: 10, display: "none" }}
- />
- >
- ) : loading ? (
-
- ) : (
- capturedImage &&
- (!fileTemp || fileTemp.error == undefined) && (
-
-
Tinjau Gambar
-
ReadImage(capturedImage)}
- >
- Scan
-
-
-
{
- setFileTemp(null);
- setIsFreeze(false);
- }}
- >
- Hapus
-
-
- )
- )}
- {fileTemp && fileTemp.error != "409" ? (
-
handleSaveTemp(data)}
+
+ ) : (
+ <>
+
- ) : (
- fileTemp && (
- <>
-
KTP Sudah Terdaftar
-
{
- setFileTemp(null);
- setIsFreeze(false);
- }}
- >
- Hapus
-
- >
- )
- )}
-
+
+
-
setModalOpen(false)}
- loading={loading}
- fileTemp={fileTemp}
- onSave={handleSaveTemp}
- onDelete={handleDeleteTemp}
+
+ {/* Back button */}
+
+
+ {!isFreeze ? (
+ <>
+
+ Ambil Gambar{" "}
+ {getDocumentDisplayInfo(
+ selectedDocumentType
+ ).name.toUpperCase()}
+
+
atau
+
+ Upload Gambar
+
+
handleManualUpload(e)}
+ style={{ marginRight: 10, display: "none" }}
+ />
+ >
+ ) : loading ? (
+
+ ) : (
+ capturedImage &&
+ (!fileTemp || fileTemp.error == undefined) && (
+
+
Tinjau Gambar
+
ReadImage(capturedImage)}
+ >
+ Scan
+
+
+
{
+ setFileTemp(null);
+ setIsFreeze(false);
+ setCapturedImage(null); // Clear captured image on delete
+ setKTPdetected(false); // Reset KTP detected state
+ }}
+ style={{ cursor: "pointer", color: "#dc3545" }} // Add styling for delete
+ >
+ Hapus
+
+
+ )
+ )}
+ {fileTemp && fileTemp.error != "409" ? (
+
+ {/* Header untuk bagian save - sama seperti document selection */}
+
+
+
+ {getDocumentDisplayInfo(selectedDocumentType).icon}
+
+
+
+ Verifikasi Data{" "}
+ {getDocumentDisplayInfo(selectedDocumentType).name}
+
+
+ Silakan periksa dan lengkapi data{" "}
+ {getDocumentDisplayInfo(selectedDocumentType).fullName}
+
+
+
+
+
+
+ handleSaveTemp(data, selectedDocumentType)
+ }
+ />
+
+ ) : (
+ fileTemp &&
+ fileTemp.error == "409" && (
+ <>
+
Dokumen Sudah Terdaftar
+
{
+ setFileTemp(null);
+ setIsFreeze(false);
+ setCapturedImage(null); // Clear captured image on delete
+ setKTPdetected(false); // Reset KTP detected state
+ }}
+ style={{ cursor: "pointer", color: "#007bff" }} // Add styling for retry/clear
+ >
+ Coba Lagi
+
+ >
+ )
+ )}
+
+ >
+ )}
+
+ {/* New Document Modal */}
+ setShowNewDocumentModal(false)}
+ onSubmit={handleNewDocumentSubmit}
/>
);
@@ -676,19 +1129,332 @@ const spinnerStyle = `
}
`;
+// Modal styles
+const modalStyles = {
+ overlay: {
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ zIndex: 1000,
+ },
+ modal: {
+ backgroundColor: "white",
+ borderRadius: "16px",
+ width: "90%",
+ maxWidth: "400px",
+ boxShadow: "0 10px 25px rgba(0, 0, 0, 0.2)",
+ maxHeight: "80vh", // Limit modal height
+ overflowY: "auto", // Enable scrolling for long forms
+ },
+ header: {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ padding: "20px 20px 0 20px",
+ borderBottom: "1px solid #e9ecef",
+ marginBottom: "20px",
+ },
+ title: {
+ margin: 0,
+ fontSize: "18px",
+ fontWeight: "bold",
+ color: "#333",
+ },
+ closeButton: {
+ background: "none",
+ border: "none",
+ fontSize: "24px",
+ cursor: "pointer",
+ color: "#666",
+ padding: "0",
+ width: "30px",
+ height: "30px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ content: {
+ padding: "0 20px 20px 20px",
+ },
+ label: {
+ display: "block",
+ marginBottom: "8px",
+ fontWeight: "bold",
+ color: "#333",
+ fontSize: "14px",
+ },
+ input: {
+ width: "100%",
+ padding: "12px",
+ border: "2px solid #e9ecef",
+ borderRadius: "8px",
+ fontSize: "16px",
+ outline: "none",
+ transition: "border-color 0.3s ease",
+ boxSizing: "border-box",
+ },
+ formFieldRow: {
+ display: "flex",
+ alignItems: "center",
+ marginBottom: "10px",
+ gap: "10px",
+ },
+ fieldInput: {
+ flexGrow: 1,
+ padding: "10px",
+ border: "1px solid #ddd",
+ borderRadius: "8px",
+ fontSize: "15px",
+ boxSizing: "border-box",
+ },
+ removeFieldButton: {
+ background: "#dc3545",
+ color: "white",
+ border: "none",
+ borderRadius: "50%",
+ width: "30px",
+ height: "30px",
+ fontSize: "20px",
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ cursor: "pointer",
+ flexShrink: 0,
+ },
+ addFieldButton: {
+ background: "#007bff",
+ color: "white",
+ border: "none",
+ borderRadius: "8px",
+ padding: "10px 15px",
+ fontSize: "15px",
+ cursor: "pointer",
+ marginTop: "10px",
+ width: "100%",
+ },
+ footer: {
+ display: "flex",
+ gap: "10px",
+ padding: "20px",
+ borderTop: "1px solid #e9ecef",
+ },
+ cancelButton: {
+ flex: 1,
+ padding: "12px",
+ border: "2px solid #e9ecef",
+ borderRadius: "8px",
+ backgroundColor: "white",
+ color: "#666",
+ cursor: "pointer",
+ fontSize: "16px",
+ fontWeight: "bold",
+ },
+ submitButton: {
+ flex: 1,
+ padding: "12px",
+ border: "none",
+ borderRadius: "8px",
+ backgroundColor: "#429241",
+ color: "white",
+ cursor: "pointer",
+ fontSize: "16px",
+ fontWeight: "bold",
+ },
+};
+
const styles = {
- spinnerContainer: {
+ dashboardHeader: {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ padding: "15px 20px",
+ backgroundColor: "#f8f9fa",
+ borderBottom: "1px solid #e9ecef",
+ boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
+ },
+ logoAndTitle: {
+ display: "flex",
+ alignItems: "center",
+ gap: "10px",
+ },
+ logo: {
+ width: "40px",
+ height: "40px",
+ borderRadius: "8px",
+ },
+ h1: {
+ margin: 0,
+ fontSize: "24px",
+ fontWeight: "bold",
+ color: "#333",
+ },
+ dropdownContainer: {
+ position: "relative",
+ },
+ dropdownToggle: {
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ padding: "8px",
+ borderRadius: "8px",
+ transition: "background-color 0.2s",
+ },
+ dropdownMenu: {
+ position: "absolute",
+ top: "100%",
+ right: 0,
+ backgroundColor: "white",
+ borderRadius: "8px",
+ boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
+ minWidth: "120px",
+ zIndex: 100,
+ marginTop: "10px",
+ overflow: "hidden", // Ensures rounded corners apply to children
+ },
+ dropdownItem: {
+ display: "block",
+ width: "100%",
+ padding: "10px 15px",
+ border: "none",
+ backgroundColor: "transparent",
+ textAlign: "left",
+ cursor: "pointer",
+ fontSize: "16px",
+ color: "#333",
+ transition: "background-color 0.2s",
+ },
+ selectionContainer: {
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ minHeight: "calc(100vh - 70px)", // Adjust based on header height
+ padding: "20px",
+ boxSizing: "border-box",
+ backgroundColor: "#f0f2f5",
+ },
+ selectionContent: {
+ backgroundColor: "white",
+ borderRadius: "16px",
+ padding: "30px",
textAlign: "center",
- padding: 40,
+ boxShadow: "0 8px 20px rgba(0,0,0,0.1)",
+ maxWidth: "600px",
+ width: "100%",
+ },
+ selectionTitle: {
+ fontSize: "28px",
+ fontWeight: "bold",
+ marginBottom: "10px",
+ color: "#333",
+ },
+ selectionSubtitle: {
+ fontSize: "16px",
+ color: "#666",
+ marginBottom: "30px",
+ },
+ documentGrid: {
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
+ gap: "20px",
+ justifyContent: "center",
+ },
+ documentCard: {
+ backgroundColor: "#f8f9fa",
+ borderRadius: "12px",
+ padding: "20px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: "10px",
+ cursor: "pointer",
+ border: "1px solid #e9ecef",
+ transition: "transform 0.2s, box-shadow 0.2s",
+ },
+ documentIconContainer: {
+ width: "60px",
+ height: "60px",
+ borderRadius: "50%",
+ backgroundColor: "#e0f7fa", // Light blue for icons
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ documentIcon: {
+ fontSize: "30px",
+ },
+ plusIcon: {
+ fontSize: "40px",
+ color: "#43a0a7",
+ fontWeight: "200",
+ },
+ documentLabel: {
+ fontSize: "15px",
+ fontWeight: "bold",
+ color: "#333",
+ textTransform: "capitalize",
+ },
+ backButton: {
+ backgroundColor: "#6c757d",
+ color: "white",
+ padding: "10px 15px",
+ borderRadius: "8px",
+ border: "none",
+ fontSize: "14px",
+ fontWeight: "bold",
+ cursor: "pointer",
+ marginBottom: "15px",
+ width: "100%",
+ },
+ spinnerContainer: {
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ height: "100px", // Adjust as needed
},
spinner: {
border: "4px solid #f3f3f3",
- borderTop: "4px solid #3498db",
+ borderTop: "4px solid #429241",
borderRadius: "50%",
- width: 40,
- height: 40,
+ width: "40px",
+ height: "40px",
animation: "spin 1s linear infinite",
- margin: "0 auto",
+ },
+ saveHeader: {
+ backgroundColor: "#e0f7fa",
+ borderRadius: "12px",
+ padding: "15px",
+ marginBottom: "20px",
+ display: "flex",
+ alignItems: "center",
+ gap: "15px",
+ },
+ saveHeaderContent: {
+ display: "flex",
+ alignItems: "center",
+ gap: "15px",
+ },
+ saveHeaderIcon: {
+ fontSize: "30px",
+ },
+ saveHeaderText: {
+ textAlign: "left",
+ },
+ saveHeaderTitle: {
+ fontSize: "18px",
+ fontWeight: "bold",
+ color: "#212529",
+ },
+ saveHeaderSubtitle: {
+ fontSize: "14px",
+ color: "#495057",
},
};
+
export default CameraCanvas;
diff --git a/src/Login.js b/src/Login.js
index 71742c3..80914da 100644
--- a/src/Login.js
+++ b/src/Login.js
@@ -18,7 +18,7 @@ const Login = () => {
try {
const loginResponse = await fetch(
- "https://bot.kediritechnopark.com/webhook/login/psi",
+ "https://bot.kediritechnopark.com/webhook/solid-data/login",
{
method: "POST",
headers: {
@@ -48,8 +48,8 @@ const Login = () => {
return (
-

-
Kawal PSI
+

+
SOLID DATA
Silakan masuk untuk melanjutkan ke dashboard
diff --git a/src/Login.module.css b/src/Login.module.css
index 09cfcf7..b2a6db5 100644
--- a/src/Login.module.css
+++ b/src/Login.module.css
@@ -28,9 +28,8 @@
font-size: 28px;
font-weight: 700;
margin-bottom: 10px;
- color: #ef4444; /* ๐ด Warna merah PSI */
+ color: #43a0a7;
}
-
.subtitle {
font-size: 14px;
color: #6b7280;
@@ -56,7 +55,7 @@
}
.button {
- background-color: #ef4444; /* ๐ด Warna merah PSI */
+ background-color: #43a0a7;
color: #ffffff;
padding: 12px 24px;
border-radius: 24px;
@@ -69,7 +68,7 @@
}
.button:hover {
- background-color: #b71c1c; /* versi lebih gelap saat hover */
+ background-color: #357734; /* darker shade of #43a0a7 */
}
.error {
diff --git a/src/ProfileTab.js b/src/ProfileTab.js
index 70581c4..40f33c2 100644
--- a/src/ProfileTab.js
+++ b/src/ProfileTab.js
@@ -1,14 +1,15 @@
import React, { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
-import styles from "./ProfileTab.module.css";
+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 [profile, setProfile] = useState({});
- const [profileTemp, setProfileTemp] = useState({});
+ const [user, setUser] = useState({});
+ const [userTemp, setUserTemp] = useState({});
useEffect(() => {
const handleClickOutside = (event) => {
@@ -27,34 +28,62 @@ const ProfileTab = () => {
};
useEffect(() => {
- const dummyProfile = {
- username: "admin",
+ 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";
+ }
};
- setProfile(dummyProfile);
- setProfileTemp(dummyProfile);
+ verifyTokenAndFetchData();
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
- setProfile((prev) => ({ ...prev, [name]: value }));
+ setUser((prev) => ({ ...prev, [name]: value }));
};
const handleSave = async () => {
try {
- if (!profile.oldPassword || !profile.newPassword) {
+ if (!user.oldPassword || !user.newPassword) {
alert("Password lama dan baru tidak boleh kosong.");
return;
}
const payload = {
- username: profile.username,
- oldPassword: profile.oldPassword,
- newPassword: profile.newPassword,
+ username: user.username,
+ oldPassword: user.oldPassword,
+ newPassword: user.newPassword,
};
const response = await fetch(
- "https://bot.kediritechnopark.com/webhook/reset-password/psi",
+ "https://bot.kediritechnopark.com/webhook/solid-data/reset-password",
{
method: "PUT",
headers: {
@@ -77,21 +106,26 @@ const ProfileTab = () => {
const handleCancel = () => {
setIsEditing(false);
- setProfile(profileTemp);
+ setUser(userTemp);
};
return (
-
-
-
-

-
Kawal PSI Profile
+
+
+
+

+
SOLID
+
+ DATA
+
-
+
-
{isMenuOpen && (
-
+
+
@@ -134,43 +176,43 @@ const ProfileTab = () => {
-
-
-
-
+
+
+
+
Account
{!isEditing ? (
) : (
-
+
-
)}
-
+
{!isEditing && (
-
-
+
+
@@ -179,25 +221,25 @@ const ProfileTab = () => {
{isEditing && (
<>
-
-
-
- ยฉ 2025 Kediri Technopark โข Dermalounge AI Admin
+
+ ยฉ 2025 Kediri Technopark โข Dashboard SOLID DATA
);
diff --git a/src/ProfileTab.module.css b/src/ProfileTab.module.css
index aff1414..ccf028a 100644
--- a/src/ProfileTab.module.css
+++ b/src/ProfileTab.module.css
@@ -1,4 +1,4 @@
-/* ProfileTab.module.css - Modern Design */
+/* ProfileTab.module.css - Modern Design with Unified Header */
/* Modern Color Palette */
:root {
@@ -14,7 +14,7 @@
--neutral-800: #262626;
--neutral-900: #171717;
--white: #ffffff;
- --success-green: #10b981;
+ --success-green: #43a0a7;
--warning-amber: #f59e0b;
--error-red: #ef4444;
--text-primary: #0f172a;
@@ -50,6 +50,7 @@ body {
flex-direction: column;
}
+/* --- UNIFIED HEADER (sama dengan Dashboard.css) --- */
.dashboardHeader {
background-color: var(--white);
color: var(--text-primary);
@@ -58,7 +59,7 @@ body {
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
- border-bottom: 3px solid #ef4444;
+ border-bottom: 3px solid #43a0a7; /* Warna dari Dashboard.css */
position: sticky;
top: 0;
z-index: 50;
@@ -80,10 +81,18 @@ body {
}
.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: #ed4344;
+ color: #154666;
letter-spacing: -0.025em;
}
@@ -96,6 +105,12 @@ body {
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);
@@ -155,7 +170,7 @@ body {
margin-bottom: 0;
}
-/* Main Content */
+/* --- MAIN CONTENT --- */
.mainContent {
flex-grow: 1;
padding: 2rem 1.5rem;
@@ -205,7 +220,7 @@ body {
}
.editButton {
- background-color: #ef4444;
+ background-color: #43a0a7; /* Diseragamkan dengan warna header */
color: var(--text-light);
border: none;
padding: 0.75rem 1.5rem;
@@ -218,7 +233,7 @@ body {
}
.editButton:hover {
- background-color: var(--dark-blue);
+ background-color: #357734;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
@@ -328,7 +343,11 @@ body {
}
.licenseCard {
- background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
+ background: linear-gradient(
+ 135deg,
+ #43a0a7 0%,
+ #357734 100%
+ ); /* Diseragamkan dengan warna header */
color: var(--text-light);
padding: 1.5rem;
border-radius: 1rem;
@@ -421,7 +440,7 @@ body {
border-top: 1px solid var(--border-light);
}
-/* Responsive Design */
+/* --- RESPONSIVE DESIGN --- */
@media (min-width: 768px) {
.dashboardHeader {
padding: 1rem 2rem;
@@ -436,6 +455,10 @@ body {
font-size: 1.75rem;
}
+ .userDisplayName {
+ font-size: 0.875rem;
+ }
+
.mainContent {
padding: 2.5rem 2rem;
gap: 2.5rem;
diff --git a/src/ShowImage.js b/src/ShowImage.js
index 8628fc0..a3b8e9c 100644
--- a/src/ShowImage.js
+++ b/src/ShowImage.js
@@ -18,7 +18,7 @@ const ShowImage = () => {
try {
const response = await fetch(
- `https://bot.kediritechnopark.com/webhook/0f4420a8-8517-49ba-8ec5-75adde117813/ktp/img/${nik}`,
+ `https://bot.kediritechnopark.com/webhook/ed467164-05c0-4692-bb81-a8f13116bb1b/ktp/img/ikasapta/:nik/${nik}`,
{
method: "GET",
headers: {