diff --git a/.gitignore b/.gitignore
index 5d0d55fc..0ea1f643 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,5 @@ node_modules/
*.key
*.crt
robots.txt
-sitemap.xml
\ No newline at end of file
+sitemap.xml
+sitemap-*.xml
diff --git a/.pnp.cjs b/.pnp.cjs
index 12c3aac7..19c2c56c 100755
--- a/.pnp.cjs
+++ b/.pnp.cjs
@@ -8690,6 +8690,7 @@ const RAW_RUNTIME_STATE =
["react-chartjs-2", "virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:5.3.0"],\
["react-dom", "virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:19.1.0"],\
["react-hook-form", "virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:7.59.0"],\
+ ["react-pdf", "virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:9.2.1"],\
["tailwindcss", "npm:4.1.11"],\
["three", "npm:0.177.0"],\
["ts-node", "virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:10.9.2"],\
@@ -18090,6 +18091,18 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["canvas", [\
+ ["npm:3.2.1", {\
+ "packageLocation": "./.yarn/unplugged/canvas-npm-3.2.1-b6952492b7/node_modules/canvas/",\
+ "packageDependencies": [\
+ ["canvas", "npm:3.2.1"],\
+ ["node-addon-api", "npm:7.1.1"],\
+ ["node-gyp", "npm:11.2.0"],\
+ ["prebuild-install", "npm:7.1.3"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["chai", [\
["npm:5.2.0", {\
"packageLocation": "./.yarn/cache/chai-npm-5.2.0-373e52d821-dfd1cb719c.zip/node_modules/chai/",\
@@ -18216,6 +18229,13 @@ const RAW_RUNTIME_STATE =
}]\
]],\
["chownr", [\
+ ["npm:1.1.4", {\
+ "packageLocation": "./.yarn/cache/chownr-npm-1.1.4-5bd400ab08-ed57952a84.zip/node_modules/chownr/",\
+ "packageDependencies": [\
+ ["chownr", "npm:1.1.4"]\
+ ],\
+ "linkType": "HARD"\
+ }],\
["npm:3.0.0", {\
"packageLocation": "./.yarn/cache/chownr-npm-3.0.0-5275e85d25-43925b8770.zip/node_modules/chownr/",\
"packageDependencies": [\
@@ -19145,6 +19165,16 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["decompress-response", [\
+ ["npm:6.0.0", {\
+ "packageLocation": "./.yarn/cache/decompress-response-npm-6.0.0-359de2878c-bd89d23141.zip/node_modules/decompress-response/",\
+ "packageDependencies": [\
+ ["decompress-response", "npm:6.0.0"],\
+ ["mimic-response", "npm:3.1.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["dedent", [\
["npm:1.6.0", {\
"packageLocation": "./.yarn/cache/dedent-npm-1.6.0-2a2b4ba2b1-671b8f5e39.zip/node_modules/dedent/",\
@@ -19176,6 +19206,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["deep-extend", [\
+ ["npm:0.6.0", {\
+ "packageLocation": "./.yarn/cache/deep-extend-npm-0.6.0-e182924219-1c6b0abcdb.zip/node_modules/deep-extend/",\
+ "packageDependencies": [\
+ ["deep-extend", "npm:0.6.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["deep-is", [\
["npm:0.1.4", {\
"packageLocation": "./.yarn/cache/deep-is-npm-0.1.4-88938b5a67-7f0ee496e0.zip/node_modules/deep-is/",\
@@ -19311,6 +19350,13 @@ const RAW_RUNTIME_STATE =
["detect-libc", "npm:2.0.4"]\
],\
"linkType": "HARD"\
+ }],\
+ ["npm:2.1.2", {\
+ "packageLocation": "./.yarn/cache/detect-libc-npm-2.1.2-d0c382b1e2-acc675c29a.zip/node_modules/detect-libc/",\
+ "packageDependencies": [\
+ ["detect-libc", "npm:2.1.2"]\
+ ],\
+ "linkType": "HARD"\
}]\
]],\
["detect-newline", [\
@@ -20614,6 +20660,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["expand-template", [\
+ ["npm:2.0.3", {\
+ "packageLocation": "./.yarn/cache/expand-template-npm-2.0.3-80de959306-1c9e7afe9a.zip/node_modules/expand-template/",\
+ "packageDependencies": [\
+ ["expand-template", "npm:2.0.3"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["expect", [\
["npm:30.0.3", {\
"packageLocation": "./.yarn/cache/expect-npm-30.0.3-b2a8f9af08-6bb88a42d6.zip/node_modules/expect/",\
@@ -21292,6 +21347,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["fs-constants", [\
+ ["npm:1.0.0", {\
+ "packageLocation": "./.yarn/cache/fs-constants-npm-1.0.0-59576b2177-a0cde99085.zip/node_modules/fs-constants/",\
+ "packageDependencies": [\
+ ["fs-constants", "npm:1.0.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["fs-extra", [\
["npm:10.1.0", {\
"packageLocation": "./.yarn/cache/fs-extra-npm-10.1.0-86573680ed-5f579466e7.zip/node_modules/fs-extra/",\
@@ -21493,6 +21557,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["github-from-package", [\
+ ["npm:0.0.0", {\
+ "packageLocation": "./.yarn/cache/github-from-package-npm-0.0.0-519f80c9a1-737ee3f52d.zip/node_modules/github-from-package/",\
+ "packageDependencies": [\
+ ["github-from-package", "npm:0.0.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["glob", [\
["npm:10.4.5", {\
"packageLocation": "./.yarn/cache/glob-npm-10.4.5-8c63175f05-19a9759ea7.zip/node_modules/glob/",\
@@ -22131,6 +22204,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["ini", [\
+ ["npm:1.3.8", {\
+ "packageLocation": "./.yarn/cache/ini-npm-1.3.8-fb5040b4c0-ec93838d23.zip/node_modules/ini/",\
+ "packageDependencies": [\
+ ["ini", "npm:1.3.8"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["inquirer", [\
["npm:6.5.2", {\
"packageLocation": "./.yarn/cache/inquirer-npm-6.5.2-4f6408c247-a5aa53a8f8.zip/node_modules/inquirer/",\
@@ -25391,6 +25473,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["make-cancellable-promise", [\
+ ["npm:1.3.2", {\
+ "packageLocation": "./.yarn/cache/make-cancellable-promise-npm-1.3.2-6612d27c4e-10aa0450c7.zip/node_modules/make-cancellable-promise/",\
+ "packageDependencies": [\
+ ["make-cancellable-promise", "npm:1.3.2"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["make-dir", [\
["npm:3.1.0", {\
"packageLocation": "./.yarn/cache/make-dir-npm-3.1.0-d1d7505142-56aaafefc4.zip/node_modules/make-dir/",\
@@ -25418,6 +25509,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["make-event-props", [\
+ ["npm:1.6.2", {\
+ "packageLocation": "./.yarn/cache/make-event-props-npm-1.6.2-89d60d5202-ecf0b742e4.zip/node_modules/make-event-props/",\
+ "packageDependencies": [\
+ ["make-event-props", "npm:1.6.2"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["make-fetch-happen", [\
["npm:14.0.3", {\
"packageLocation": "./.yarn/cache/make-fetch-happen-npm-14.0.3-23b30e8691-c40efb5e52.zip/node_modules/make-fetch-happen/",\
@@ -25536,6 +25636,26 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["merge-refs", [\
+ ["npm:1.3.0", {\
+ "packageLocation": "./.yarn/cache/merge-refs-npm-1.3.0-3b965c4c45-403d20d283.zip/node_modules/merge-refs/",\
+ "packageDependencies": [\
+ ["merge-refs", "npm:1.3.0"]\
+ ],\
+ "linkType": "SOFT"\
+ }],\
+ ["virtual:a4e997d1238027b6f64c1eb66438b0f71064cabb137a4a0d099c7a75ff9f332487bec2fb27637d58df006dcf5422d5deaee216de4f09141ad8bb94929b4c6a68#npm:1.3.0", {\
+ "packageLocation": "./.yarn/__virtual__/merge-refs-virtual-d21ed373bc/0/cache/merge-refs-npm-1.3.0-3b965c4c45-403d20d283.zip/node_modules/merge-refs/",\
+ "packageDependencies": [\
+ ["@types/react", "npm:19.1.8"],\
+ ["merge-refs", "virtual:a4e997d1238027b6f64c1eb66438b0f71064cabb137a4a0d099c7a75ff9f332487bec2fb27637d58df006dcf5422d5deaee216de4f09141ad8bb94929b4c6a68#npm:1.3.0"]\
+ ],\
+ "packagePeers": [\
+ "@types/react"\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["merge-stream", [\
["npm:2.0.0", {\
"packageLocation": "./.yarn/cache/merge-stream-npm-2.0.0-2ac83efea5-867fdbb30a.zip/node_modules/merge-stream/",\
@@ -25680,6 +25800,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["mimic-response", [\
+ ["npm:3.1.0", {\
+ "packageLocation": "./.yarn/cache/mimic-response-npm-3.1.0-a4a24b4e96-0d6f07ce6e.zip/node_modules/mimic-response/",\
+ "packageDependencies": [\
+ ["mimic-response", "npm:3.1.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["min-indent", [\
["npm:1.0.1", {\
"packageLocation": "./.yarn/cache/min-indent-npm-1.0.1-77031f50e1-7e207bd5c2.zip/node_modules/min-indent/",\
@@ -25845,6 +25974,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["mkdirp-classic", [\
+ ["npm:0.5.3", {\
+ "packageLocation": "./.yarn/cache/mkdirp-classic-npm-0.5.3-3b5c991910-95371d831d.zip/node_modules/mkdirp-classic/",\
+ "packageDependencies": [\
+ ["mkdirp-classic", "npm:0.5.3"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["module-details-from-path", [\
["npm:1.0.4", {\
"packageLocation": "./.yarn/cache/module-details-from-path-npm-1.0.4-c3d0545459-10863413e9.zip/node_modules/module-details-from-path/",\
@@ -26039,6 +26177,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["napi-build-utils", [\
+ ["npm:2.0.0", {\
+ "packageLocation": "./.yarn/cache/napi-build-utils-npm-2.0.0-95da9c2e4e-5833aaeb5c.zip/node_modules/napi-build-utils/",\
+ "packageDependencies": [\
+ ["napi-build-utils", "npm:2.0.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["napi-postinstall", [\
["npm:0.2.5", {\
"packageLocation": "./.yarn/cache/napi-postinstall-npm-0.2.5-2d85d6ee0e-c4a1a8ca61.zip/node_modules/napi-postinstall/",\
@@ -26194,6 +26341,16 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["node-abi", [\
+ ["npm:3.87.0", {\
+ "packageLocation": "./.yarn/cache/node-abi-npm-3.87.0-502d02db75-41cfc361ed.zip/node_modules/node-abi/",\
+ "packageDependencies": [\
+ ["node-abi", "npm:3.87.0"],\
+ ["semver", "npm:7.7.2"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["node-abort-controller", [\
["npm:3.1.1", {\
"packageLocation": "./.yarn/cache/node-abort-controller-npm-3.1.1-e246ed42cd-f7ad0e7a8e.zip/node_modules/node-abort-controller/",\
@@ -26203,6 +26360,16 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["node-addon-api", [\
+ ["npm:7.1.1", {\
+ "packageLocation": "./.yarn/unplugged/node-addon-api-npm-7.1.1-bfb302df19/node_modules/node-addon-api/",\
+ "packageDependencies": [\
+ ["node-addon-api", "npm:7.1.1"],\
+ ["node-gyp", "npm:11.2.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["node-emoji", [\
["npm:1.11.0", {\
"packageLocation": "./.yarn/cache/node-emoji-npm-1.11.0-dd2f09050c-5dac6502db.zip/node_modules/node-emoji/",\
@@ -26812,6 +26979,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["path2d", [\
+ ["npm:0.2.2", {\
+ "packageLocation": "./.yarn/cache/path2d-npm-0.2.2-61baf92922-1bb76c7f27.zip/node_modules/path2d/",\
+ "packageDependencies": [\
+ ["path2d", "npm:0.2.2"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["pathe", [\
["npm:2.0.3", {\
"packageLocation": "./.yarn/cache/pathe-npm-2.0.3-0924246ee0-c118dc5a8b.zip/node_modules/pathe/",\
@@ -26830,6 +27006,17 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["pdfjs-dist", [\
+ ["npm:4.8.69", {\
+ "packageLocation": "./.yarn/cache/pdfjs-dist-npm-4.8.69-789befba80-dc297f2a36.zip/node_modules/pdfjs-dist/",\
+ "packageDependencies": [\
+ ["canvas", "npm:3.2.1"],\
+ ["path2d", "npm:0.2.2"],\
+ ["pdfjs-dist", "npm:4.8.69"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["pend", [\
["npm:1.2.0", {\
"packageLocation": "./.yarn/cache/pend-npm-1.2.0-7a13d93266-8a87e63f7a.zip/node_modules/pend/",\
@@ -27086,6 +27273,27 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["prebuild-install", [\
+ ["npm:7.1.3", {\
+ "packageLocation": "./.yarn/cache/prebuild-install-npm-7.1.3-8e79c3a0a2-25919a42b5.zip/node_modules/prebuild-install/",\
+ "packageDependencies": [\
+ ["detect-libc", "npm:2.1.2"],\
+ ["expand-template", "npm:2.0.3"],\
+ ["github-from-package", "npm:0.0.0"],\
+ ["minimist", "npm:1.2.8"],\
+ ["mkdirp-classic", "npm:0.5.3"],\
+ ["napi-build-utils", "npm:2.0.0"],\
+ ["node-abi", "npm:3.87.0"],\
+ ["prebuild-install", "npm:7.1.3"],\
+ ["pump", "npm:3.0.3"],\
+ ["rc", "npm:1.2.8"],\
+ ["simple-get", "npm:4.0.1"],\
+ ["tar-fs", "npm:2.1.4"],\
+ ["tunnel-agent", "npm:0.6.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["prelude-ls", [\
["npm:1.2.1", {\
"packageLocation": "./.yarn/cache/prelude-ls-npm-1.2.1-3e4d272a55-b00d617431.zip/node_modules/prelude-ls/",\
@@ -27386,6 +27594,19 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["rc", [\
+ ["npm:1.2.8", {\
+ "packageLocation": "./.yarn/cache/rc-npm-1.2.8-d6768ac936-24a0765315.zip/node_modules/rc/",\
+ "packageDependencies": [\
+ ["deep-extend", "npm:0.6.0"],\
+ ["ini", "npm:1.3.8"],\
+ ["minimist", "npm:1.2.8"],\
+ ["rc", "npm:1.2.8"],\
+ ["strip-json-comments", "npm:2.0.1"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["react", [\
["npm:19.1.0", {\
"packageLocation": "./.yarn/cache/react-npm-19.1.0-9804a7da5b-530fb9a622.zip/node_modules/react/",\
@@ -27564,6 +27785,40 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["react-pdf", [\
+ ["npm:9.2.1", {\
+ "packageLocation": "./.yarn/cache/react-pdf-npm-9.2.1-a56cdc884b-69b5456b39.zip/node_modules/react-pdf/",\
+ "packageDependencies": [\
+ ["react-pdf", "npm:9.2.1"]\
+ ],\
+ "linkType": "SOFT"\
+ }],\
+ ["virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:9.2.1", {\
+ "packageLocation": "./.yarn/__virtual__/react-pdf-virtual-a4e997d123/0/cache/react-pdf-npm-9.2.1-a56cdc884b-69b5456b39.zip/node_modules/react-pdf/",\
+ "packageDependencies": [\
+ ["@types/react", "npm:19.1.8"],\
+ ["@types/react-dom", "virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:19.1.6"],\
+ ["clsx", "npm:2.1.1"],\
+ ["dequal", "npm:2.0.3"],\
+ ["make-cancellable-promise", "npm:1.3.2"],\
+ ["make-event-props", "npm:1.6.2"],\
+ ["merge-refs", "virtual:a4e997d1238027b6f64c1eb66438b0f71064cabb137a4a0d099c7a75ff9f332487bec2fb27637d58df006dcf5422d5deaee216de4f09141ad8bb94929b4c6a68#npm:1.3.0"],\
+ ["pdfjs-dist", "npm:4.8.69"],\
+ ["react", "npm:19.1.0"],\
+ ["react-dom", "virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:19.1.0"],\
+ ["react-pdf", "virtual:a21e24cf4a793f04e2e07474217597c8b8491086897b7ceb58f7c959b6c65ef5decbff14fa97445a76a680e4e81febb25c198102b2ba038b85eb731f05365753#npm:9.2.1"],\
+ ["tiny-invariant", "npm:1.3.3"],\
+ ["warning", "npm:4.0.3"]\
+ ],\
+ "packagePeers": [\
+ "@types/react-dom",\
+ "@types/react",\
+ "react-dom",\
+ "react"\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["react-reconciler", [\
["npm:0.31.0", {\
"packageLocation": "./.yarn/cache/react-reconciler-npm-0.31.0-8f5bda4868-97920e1866.zip/node_modules/react-reconciler/",\
@@ -28614,6 +28869,27 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["simple-concat", [\
+ ["npm:1.0.1", {\
+ "packageLocation": "./.yarn/cache/simple-concat-npm-1.0.1-48df70de29-62f7508e67.zip/node_modules/simple-concat/",\
+ "packageDependencies": [\
+ ["simple-concat", "npm:1.0.1"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
+ ["simple-get", [\
+ ["npm:4.0.1", {\
+ "packageLocation": "./.yarn/cache/simple-get-npm-4.0.1-fa2a97645d-b0649a581d.zip/node_modules/simple-get/",\
+ "packageDependencies": [\
+ ["decompress-response", "npm:6.0.0"],\
+ ["once", "npm:1.4.0"],\
+ ["simple-concat", "npm:1.0.1"],\
+ ["simple-get", "npm:4.0.1"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["simple-swizzle", [\
["npm:0.2.2", {\
"packageLocation": "./.yarn/cache/simple-swizzle-npm-0.2.2-8dee37fad1-df5e4662a8.zip/node_modules/simple-swizzle/",\
@@ -29173,6 +29449,13 @@ const RAW_RUNTIME_STATE =
}]\
]],\
["strip-json-comments", [\
+ ["npm:2.0.1", {\
+ "packageLocation": "./.yarn/cache/strip-json-comments-npm-2.0.1-e7883b2d04-b509231cbd.zip/node_modules/strip-json-comments/",\
+ "packageDependencies": [\
+ ["strip-json-comments", "npm:2.0.1"]\
+ ],\
+ "linkType": "HARD"\
+ }],\
["npm:3.1.1", {\
"packageLocation": "./.yarn/cache/strip-json-comments-npm-3.1.1-dcb2324823-9681a6257b.zip/node_modules/strip-json-comments/",\
"packageDependencies": [\
@@ -29449,6 +29732,17 @@ const RAW_RUNTIME_STATE =
}]\
]],\
["tar-fs", [\
+ ["npm:2.1.4", {\
+ "packageLocation": "./.yarn/cache/tar-fs-npm-2.1.4-90a454735f-decb25acdc.zip/node_modules/tar-fs/",\
+ "packageDependencies": [\
+ ["chownr", "npm:1.1.4"],\
+ ["mkdirp-classic", "npm:0.5.3"],\
+ ["pump", "npm:3.0.3"],\
+ ["tar-fs", "npm:2.1.4"],\
+ ["tar-stream", "npm:2.2.0"]\
+ ],\
+ "linkType": "HARD"\
+ }],\
["npm:3.1.0", {\
"packageLocation": "./.yarn/cache/tar-fs-npm-3.1.0-e568911671-7603096775.zip/node_modules/tar-fs/",\
"packageDependencies": [\
@@ -29462,6 +29756,18 @@ const RAW_RUNTIME_STATE =
}]\
]],\
["tar-stream", [\
+ ["npm:2.2.0", {\
+ "packageLocation": "./.yarn/cache/tar-stream-npm-2.2.0-884c79b510-2f4c910b3e.zip/node_modules/tar-stream/",\
+ "packageDependencies": [\
+ ["bl", "npm:4.1.0"],\
+ ["end-of-stream", "npm:1.4.5"],\
+ ["fs-constants", "npm:1.0.0"],\
+ ["inherits", "npm:2.0.4"],\
+ ["readable-stream", "npm:3.6.2"],\
+ ["tar-stream", "npm:2.2.0"]\
+ ],\
+ "linkType": "HARD"\
+ }],\
["npm:3.1.7", {\
"packageLocation": "./.yarn/cache/tar-stream-npm-3.1.7-c34f9aa00f-a09199d21f.zip/node_modules/tar-stream/",\
"packageDependencies": [\
@@ -30277,6 +30583,16 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["tunnel-agent", [\
+ ["npm:0.6.0", {\
+ "packageLocation": "./.yarn/cache/tunnel-agent-npm-0.6.0-64345ab7eb-4c7a1b813e.zip/node_modules/tunnel-agent/",\
+ "packageDependencies": [\
+ ["safe-buffer", "npm:5.2.1"],\
+ ["tunnel-agent", "npm:0.6.0"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["tunnel-rat", [\
["npm:0.1.2", {\
"packageLocation": "./.yarn/cache/tunnel-rat-npm-0.1.2-69bf8f367e-93cd50c7c9.zip/node_modules/tunnel-rat/",\
@@ -31428,6 +31744,16 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
+ ["warning", [\
+ ["npm:4.0.3", {\
+ "packageLocation": "./.yarn/cache/warning-npm-4.0.3-291e921d6d-aebab44512.zip/node_modules/warning/",\
+ "packageDependencies": [\
+ ["loose-envify", "npm:1.4.0"],\
+ ["warning", "npm:4.0.3"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["watchpack", [\
["npm:2.4.4", {\
"packageLocation": "./.yarn/cache/watchpack-npm-2.4.4-01f92bffc4-6c0901f75c.zip/node_modules/watchpack/",\
diff --git a/apps/client/next.config.ts b/apps/client/next.config.ts
index 12fc019b..421ad2f9 100644
--- a/apps/client/next.config.ts
+++ b/apps/client/next.config.ts
@@ -34,6 +34,17 @@ const nextConfig: NextConfig = {
webpackBuildWorker: true,
preloadEntriesOnStart: true,
urlImports: ["https://cdn.jsdelivr.net/"]
+ },
+ webpack: (config) => {
+ config.resolve.alias = {
+ ...config.resolve.alias,
+ canvas: false
+ };
+ config.resolve.fallback = {
+ ...config.resolve.fallback,
+ canvas: false
+ };
+ return config
}
};
diff --git a/apps/client/package.json b/apps/client/package.json
index ec9b1be4..a64fdb6b 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -35,6 +35,7 @@
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.59.0",
+ "react-pdf": "^9.2.1",
"zod": "^3.25.74"
},
"devDependencies": {
diff --git a/apps/client/public/og/resume-interview.png b/apps/client/public/og/resume-interview.png
new file mode 100644
index 00000000..0c2c7493
Binary files /dev/null and b/apps/client/public/og/resume-interview.png differ
diff --git a/apps/client/src/domains/dashboard/components/selectSection.tsx b/apps/client/src/domains/dashboard/components/selectSection.tsx
index 266f86fa..5f16ba44 100644
--- a/apps/client/src/domains/dashboard/components/selectSection.tsx
+++ b/apps/client/src/domains/dashboard/components/selectSection.tsx
@@ -2,11 +2,20 @@ import { useState } from "react";
import InterviewHistory from "./interviewHistory";
import ChangeNickname from "@/domains/dashboard/components/changeNickname";
import Withdrawal from "@/domains/dashboard/components/withDrawl";
+import ResumeBasedInterviewHistory from "@/domains/resume/components/resumeBasedInterviewHistory";
+import ResumeEvaluationHistory from "@/domains/resume/components/resumeEvaluationHistory";
+import ArchivePreview from "@/domains/resume/components/archivePreview";
import { UserInfo } from "@kokomen/types";
import { Button } from "@kokomen/ui";
import { useRouter } from "next/router";
-type Section = "interview" | "changeNickname" | "withdrawal";
+type Section =
+ | "interview"
+ | "resumeBasedInterview"
+ | "resumeEvaluation"
+ | "archive"
+ | "changeNickname"
+ | "withdrawal";
interface SelectSectionProps {
userInfo: UserInfo;
@@ -16,6 +25,21 @@ const interviewSections: { label: string; value: Section }[] = [
{
label: "면접 기록",
value: "interview"
+ },
+ {
+ label: "이력서 기반 면접 질문",
+ value: "resumeBasedInterview"
+ },
+ {
+ label: "이력서 평가 결과",
+ value: "resumeEvaluation"
+ }
+];
+
+const archiveSections: { label: string; value: Section }[] = [
+ {
+ label: "아카이브",
+ value: "archive"
}
];
@@ -62,6 +86,23 @@ export default function SelectSection({ userInfo }: SelectSectionProps) {
))}
+ 아카이브
+ {archiveSections.map((sec) => (
+
+ ))}
+
+
유저 정보 관리
@@ -121,6 +162,12 @@ function SelectedSection({
switch (section) {
case "interview":
return ;
+ case "resumeBasedInterview":
+ return ;
+ case "resumeEvaluation":
+ return ;
+ case "archive":
+ return ;
case "changeNickname":
return ;
case "withdrawal":
diff --git a/apps/client/src/domains/resume/api/index.ts b/apps/client/src/domains/resume/api/index.ts
index 3ef35182..f4f37f93 100644
--- a/apps/client/src/domains/resume/api/index.ts
+++ b/apps/client/src/domains/resume/api/index.ts
@@ -3,7 +3,9 @@ import {
ResumeEvaluationResult,
ResumeFailed,
ResumeOutput,
- ResumePending
+ ResumePending,
+ ResumeEvaluationsResponse,
+ CamelCasedProperties
} from "@kokomen/types";
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import { delay, exponentialDelay } from "@kokomen/utils";
@@ -147,9 +149,38 @@ function getResumeEvaluationResult(
.then((res) => res.data)
.then(mapToCamelCase);
}
+
+function getResumeEvaluations(
+ page: number = 0,
+ size: number = 20,
+ context?: GetServerSidePropsContext
+): Promise> {
+ const instance = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/resumes/evaluations",
+ withCredentials: true
+ });
+
+ return instance
+ .get>("", {
+ params: {
+ page,
+ size
+ },
+ headers: context
+ ? {
+ Cookie: context.req.headers.cookie
+ }
+ : undefined
+ })
+ .then((res) => res.data)
+ .then(mapToCamelCase);
+}
+
export {
submitResumeEvaluation,
getResumeEvaluationState,
- getResumeEvaluationResult
+ getResumeEvaluationResult,
+ getResumeEvaluations
};
export * from "./archive";
+export * from "./resumeBasedInterview";
diff --git a/apps/client/src/domains/resume/api/resumeBasedInterview.ts b/apps/client/src/domains/resume/api/resumeBasedInterview.ts
new file mode 100644
index 00000000..e9fb733c
--- /dev/null
+++ b/apps/client/src/domains/resume/api/resumeBasedInterview.ts
@@ -0,0 +1,230 @@
+import { mapToCamelCase } from "@/utils/convertConvention";
+import {
+ ResumeInterviewPending,
+ ResumeInterviewFailed,
+ ResumeBasedInterviewQuestion,
+ ResumeInterviewSuccess,
+ Interview,
+ InterviewMode,
+ ResumeBasedInterviewGenerationsResponse,
+ CamelCasedProperties
+} from "@kokomen/types";
+import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
+import { delay, exponentialDelay } from "@kokomen/utils";
+import { GetServerSidePropsContext } from "next";
+
+// 요청별 재시도 상태를 관리하는 Map
+const retryStateMap = new Map();
+
+// 요청 식별자 생성 함수
+const createRequestId = (config: AxiosRequestConfig): string => {
+ const { method, url, data } = config;
+ return `${method}:${url}:${JSON.stringify(data || {})}`;
+};
+
+// 재시도 상태 관리 함수들
+const getRetryCount = (requestId: string): number => {
+ return retryStateMap.get(requestId) || 0;
+};
+
+const incrementRetryCount = (requestId: string): number => {
+ const currentCount = getRetryCount(requestId);
+ const newCount = currentCount + 1;
+ retryStateMap.set(requestId, newCount);
+ return newCount;
+};
+
+const resetRetryCount = (requestId: string): void => {
+ retryStateMap.delete(requestId);
+};
+
+// 이력서 제출 부분
+const resumeBasedInterviewServerInstance = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/interviews/resume-based",
+ withCredentials: true
+});
+resumeBasedInterviewServerInstance.interceptors.response.use(
+ (response: AxiosResponse) => {
+ // 성공 시 해당 요청의 retry 상태 정리
+ const requestId = createRequestId(response.config);
+ resetRetryCount(requestId);
+ return response;
+ },
+
+ // 에러 응답 처리
+ async (error: AxiosError) => {
+ const requestId = createRequestId(error.config as AxiosRequestConfig);
+ const retryCount = incrementRetryCount(requestId);
+ const maxRetries = 3;
+
+ if (retryCount >= maxRetries) {
+ resetRetryCount(requestId);
+ return Promise.reject(error);
+ }
+
+ await exponentialDelay(retryCount);
+ return resumeBasedInterviewServerInstance.request(
+ error.config as AxiosRequestConfig
+ );
+ }
+);
+
+function generateResumeBasedInterviewQuestion(data: FormData) {
+ return resumeBasedInterviewServerInstance
+ .post<{ resume_based_interview_result_id: number }>(
+ "/questions/generate",
+ data
+ )
+ .then((res) => res.data)
+ .then(mapToCamelCase);
+}
+
+const resumeBasedInterviewQuestionPollingServerInstance = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/interviews/resume-based",
+ withCredentials: true
+});
+
+// 폴링에 대한 요청 완료시
+const onFullFilledPolling = async (
+ response: AxiosResponse<
+ ResumeInterviewSuccess | ResumeInterviewPending | ResumeInterviewFailed
+ >
+) => {
+ const requestId = createRequestId(response.config);
+ console.log(response.data);
+
+ if (response.data.state === "COMPLETED") {
+ resetRetryCount(requestId);
+ return response;
+ }
+
+ if (response.data.state === "FAILED")
+ return Promise.reject("이력서 평가 중 오류가 발생했어요");
+
+ const retryCount = incrementRetryCount(requestId);
+ const maxRetries = 50;
+
+ if (retryCount >= maxRetries) {
+ resetRetryCount(requestId);
+ return Promise.reject("서버가 응답하지 않습니다.");
+ }
+
+ await delay(1000);
+ return resumeBasedInterviewQuestionPollingServerInstance.request(
+ response.config
+ );
+};
+
+// 폴링 에러 처리 함수
+const onRejectedPolling = async (error: AxiosError) => {
+ const requestId = createRequestId(error.config as AxiosRequestConfig);
+ const retryCount = incrementRetryCount(requestId);
+ const maxRetries = 3;
+
+ if (retryCount >= maxRetries) {
+ resetRetryCount(requestId);
+ return Promise.reject(error);
+ }
+
+ await exponentialDelay(retryCount);
+ return resumeBasedInterviewQuestionPollingServerInstance.request(
+ error.config as AxiosRequestConfig
+ );
+};
+
+// 인터뷰 면접 답변 폴링을 위한 서버 인스턴스
+resumeBasedInterviewQuestionPollingServerInstance.interceptors.response.use(
+ onFullFilledPolling,
+ onRejectedPolling
+);
+
+function checkResumeBasedInterviewQuestion(
+ resumeBasedInterviewQuestionId: number
+) {
+ return resumeBasedInterviewQuestionPollingServerInstance
+ .get(`/${resumeBasedInterviewQuestionId}/check`)
+ .then((res) => res.data)
+ .then(mapToCamelCase);
+}
+
+const resumeBasedInterviewResultServerInstance = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/interviews/resume-based",
+ withCredentials: true
+});
+
+function getResumeInterviewResult(
+ resumeBasedInterviewResultId: number,
+ context: GetServerSidePropsContext
+) {
+ return resumeBasedInterviewResultServerInstance
+ .get(`/${resumeBasedInterviewResultId}`, {
+ headers: {
+ Cookie: context.req.headers.cookie
+ }
+ })
+ .then((res) => res.data)
+ .then(mapToCamelCase);
+}
+
+function getResumeBasedInterviewGenerations(
+ page: number = 0,
+ context?: GetServerSidePropsContext
+): Promise> {
+ const instance = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/interviews/resume-based",
+ withCredentials: true
+ });
+
+ return instance
+ .get>(
+ "/questions/generations",
+ {
+ params: {
+ page
+ },
+ headers: context
+ ? {
+ Cookie: context.req.headers.cookie
+ }
+ : undefined
+ }
+ )
+ .then((res) => res.data)
+ .then(mapToCamelCase);
+}
+
+const resumeBasedInterviewCreateInstance = axios.create({
+ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
+ withCredentials: true
+});
+
+function createResumeBasedInterview({
+ resumeBasedInterviewResultId,
+ generatedQuestionId,
+ maxQuestionCount,
+ mode
+}: {
+ resumeBasedInterviewResultId: number;
+ generatedQuestionId: number;
+ maxQuestionCount: number;
+ mode: InterviewMode;
+}): Promise {
+ return resumeBasedInterviewCreateInstance
+ .post(
+ `/interviews/resume-based/${resumeBasedInterviewResultId}`,
+ {
+ generated_question_id: generatedQuestionId,
+ max_question_count: maxQuestionCount,
+ mode
+ }
+ )
+ .then((res) => res.data);
+}
+
+export {
+ generateResumeBasedInterviewQuestion,
+ checkResumeBasedInterviewQuestion,
+ getResumeInterviewResult,
+ createResumeBasedInterview,
+ getResumeBasedInterviewGenerations
+};
diff --git a/apps/client/src/domains/resume/components/archivePreview.tsx b/apps/client/src/domains/resume/components/archivePreview.tsx
new file mode 100644
index 00000000..89ab4ac6
--- /dev/null
+++ b/apps/client/src/domains/resume/components/archivePreview.tsx
@@ -0,0 +1,242 @@
+"use client"
+
+import { getArchivedResumes } from "@/domains/resume/api";
+import { ArchivedResumeAndPortfolio, CamelCasedProperties } from "@kokomen/types";
+import { archiveKeys } from "@/utils/querykeys";
+import { useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+import { Button, Modal } from "@kokomen/ui";
+import { FileText, Loader2 } from "lucide-react";
+import dynamic from "next/dynamic";
+
+// eslint-disable-next-line @rushstack/typedef-var
+const PdfViewer = dynamic(
+ () => import("../components/pdfViewer"),
+ {
+ ssr: false,
+ loading: () =>
+
+
+ }
+);
+
+
+type TabType = "RESUME" | "PORTFOLIO";
+
+
+export default function ArchivePreview() {
+ const [activeTab, setActiveTab] = useState("RESUME");
+ const [selectedFile, setSelectedFile] =
+ useState | null>(null);
+ const [numPages, setNumPages] = useState();
+ const [pageNumber, setPageNumber] = useState(1);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: archiveKeys.resumes("ALL"),
+ queryFn: () => getArchivedResumes("ALL"),
+ staleTime: 1000 * 60 * 60,
+ gcTime: 1000 * 60 * 5
+ });
+
+ const resumes = data?.resumes || [];
+ const portfolios = data?.portfolios || [];
+
+ const currentList =
+ activeTab === "RESUME" ? resumes : portfolios;
+
+ const handleFileSelect = (
+ file: CamelCasedProperties
+ ): void => {
+ setSelectedFile(file);
+ setPageNumber(1);
+ setNumPages(undefined);
+ setIsModalOpen(true);
+ };
+
+ const handleCloseModal = (): void => {
+ setIsModalOpen(false);
+ setSelectedFile(null);
+ setPageNumber(1);
+ setNumPages(undefined);
+ };
+
+ const handleDocumentLoadSuccess = ({ numPages }: { numPages: number }): void => {
+ console.log('numPages', numPages);
+ setNumPages(numPages);
+ setPageNumber(1);
+ };
+
+ const goToPrevPage = (): void => {
+ setPageNumber((prev) => Math.max(1, prev - 1));
+ };
+
+ const goToNextPage = (): void => {
+ setPageNumber((prev) => Math.min(numPages || 0, prev + 1));
+ };
+
+
+ if (isLoading) {
+ return (
+
+
+
+ 아카이브 미리보기
+
+
+ 아카이빙된 이력서와 포트폴리오를 미리보기할 수 있습니다
+
+
+
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+
+ 아카이브 미리보기
+
+
+ 아카이빙된 이력서와 포트폴리오를 미리보기할 수 있습니다
+
+
+
+
+ 데이터를 불러오는 중 오류가 발생했습니다.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ 아카이브 미리보기
+
+
+ 아카이빙된 이력서와 포트폴리오를 미리보기할 수 있습니다
+
+
+
+
+ {/* 파일 목록 */}
+
+ {/* 탭 */}
+
+
+
+
+
+ {/* 파일 리스트 */}
+
+ {currentList.length === 0 ? (
+
+
+
+ {activeTab === "RESUME"
+ ? "아카이빙된 이력서가 없습니다"
+ : "아카이빙된 포트폴리오가 없습니다"}
+
+
+ ) : (
+ currentList.map((file) => (
+
+ ))
+ )}
+
+
+
+
+ {/* PDF 미리보기 모달 */}
+ {selectedFile && (
+
+
+
+ {new Date(selectedFile.createdAt).toLocaleDateString("ko-KR")}
+
+
+
+
+
+
+
+
+
+ {pageNumber} / {numPages || "-"}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/domains/resume/components/pdfViewer.tsx b/apps/client/src/domains/resume/components/pdfViewer.tsx
new file mode 100644
index 00000000..82964bfd
--- /dev/null
+++ b/apps/client/src/domains/resume/components/pdfViewer.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import { Document, Page, pdfjs } from "react-pdf";
+import { JSX } from "react";
+import "react-pdf/dist/Page/TextLayer.css";
+import "react-pdf/dist/Page/AnnotationLayer.css";
+
+
+// workerSrc 정의 하지 않으면 pdf 보여지지 않습니다.
+pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
+const options = {
+ cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
+ standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
+ };
+
+type PdfViewerProps = {
+ fileUrl: string;
+ pageNumber: number;
+ // eslint-disable-next-line no-unused-vars
+ onLoadSuccess: ({ numPages }: { numPages: number }) => void;
+};
+
+export default function PdfViewer({
+ fileUrl,
+ pageNumber,
+ onLoadSuccess
+}: PdfViewerProps): JSX.Element {
+ // const [isLoading, setIsLoading] = useState(true);
+
+ return (
+
+ {
+ onLoadSuccess({ numPages });
+ }}
+ error={
+
+
PDF를 불러올 수 없습니다.
+
+ 파일이 손상되었거나 지원되지 않는 형식일 수 있습니다.
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/apps/client/src/domains/resume/components/resumeBasedInterviewForm.tsx b/apps/client/src/domains/resume/components/resumeBasedInterviewForm.tsx
new file mode 100644
index 00000000..51958b96
--- /dev/null
+++ b/apps/client/src/domains/resume/components/resumeBasedInterviewForm.tsx
@@ -0,0 +1,225 @@
+import { generateResumeBasedInterviewQuestion } from "@/domains/resume/api";
+import { ArchiveButton } from "@/domains/resume/components/resumeArchiveButton";
+import useExtendedRouter from "@/hooks/useExtendedRouter";
+import { withApiErrorCapture } from "@/utils/error";
+import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
+import { generateFormData } from "@kokomen/utils";
+import { CamelCasedProperties, UserInfo } from "@kokomen/types";
+import { Button, FileField, useToast } from "@kokomen/ui";
+import { useMutation } from "@tanstack/react-query";
+import { isAxiosError } from "axios";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import z from "zod";
+import { publishResumeBasedInterviewEvent } from "@/domains/resume/utils/resumeInterviewEventEmitter";
+
+const jobCareers = ["0-1년", "1-3년", "3-5년", "5-10년", "10년 이상"];
+
+// SSR 환경에서 FileList가 정의되지 않을 수 있으므로 custom 검증 사용
+const fileListSchema: z.ZodTypeAny =
+ typeof FileList !== "undefined"
+ ? z.instanceof(FileList)
+ : z.custom((val): val is FileList => {
+ return typeof FileList !== "undefined" && val instanceof FileList;
+ });
+
+const resumeBasedInterviewFormFields = z
+ .object({
+ // FileList를 직접 받거나, 이미 업로드된 경우를 위해 optional 처리
+ resume: fileListSchema.optional(),
+ resume_id: z.string().optional(),
+
+ portfolio: fileListSchema.optional(),
+ portfolio_id: z.string().optional(),
+
+ job_career: z.enum(jobCareers as [string, ...string[]]).default("0-1년")
+ })
+ // 1. 이력서 검증: ID가 있거나, 파일이 선택되었거나
+ .refine((data) => data.resume_id || (data.resume && data.resume.length > 0), {
+ message: "이력서를 선택해주세요",
+ path: ["resume"] // 에러 메시지를 표시할 필드 위치
+ });
+type ResumeBasedInterviewFormFields = z.infer<
+ typeof resumeBasedInterviewFormFields
+>;
+
+export default function ResumeBasedInterviewForm({ user }: { user: UserInfo }) {
+ const { toast } = useToast();
+ const form = useForm({
+ resolver: standardSchemaResolver(resumeBasedInterviewFormFields),
+ defaultValues: {
+ job_career: "0-1년"
+ }
+ });
+ const [displayName, setDisplayName] = useState<{
+ resume: string;
+ portfolio: string;
+ }>({ resume: "", portfolio: "" });
+
+ useEffect(() => {
+ const resume = form.getValues("resume");
+ const portfolio = form.getValues("portfolio");
+ if (resume instanceof FileList && resume.length > 0) {
+ setDisplayName({ ...displayName, resume: "" });
+ form.setValue("resume_id", "");
+ }
+ if (portfolio instanceof FileList && portfolio.length > 0) {
+ setDisplayName({ ...displayName, portfolio: "" });
+ form.setValue("portfolio_id", "");
+ }
+ }, [form.watch("resume_id"), form.watch("portfolio_id")]);
+
+ const router = useExtendedRouter();
+ const mutation = useMutation<
+ CamelCasedProperties<{ resume_based_interview_result_id: number }>,
+ Error,
+ FormData
+ >({
+ mutationFn: generateResumeBasedInterviewQuestion,
+ onSuccess: (data) => {
+ publishResumeBasedInterviewEvent("resumeBasedInterview:submitted", {
+ resume_based_interview_result_id: data.resumeBasedInterviewResultId
+ });
+ toast({
+ title: "이력서 분석 중입니다. 잠시 후 평가 결과를 알려드려요",
+ variant: "info"
+ });
+ router.replace({
+ pathname: "/resume"
+ });
+ },
+ onError: withApiErrorCapture((error) => {
+ if (isAxiosError(error) && error.response?.status === 401) {
+ router.navigateToLogin();
+ return;
+ } else {
+ toast({
+ title: "이력서 분석 실패",
+ description:
+ "이력서 분석 중 오류가 발생했어요. 잠시 후 다시 시도해주세요",
+ variant: "error"
+ });
+ }
+ })
+ });
+ const [isParsing, setIsParsing] = useState(false);
+
+ async function onSubmit(data: ResumeBasedInterviewFormFields) {
+ try {
+ setIsParsing(true);
+ const formData = generateFormData(data);
+ mutation.mutate(formData);
+ } catch (error) {
+ console.log(error);
+ } finally {
+ setIsParsing(false);
+ }
+ }
+ const onclickArchiveButton = (data: {
+ resume_id?: string;
+ resume_name?: string;
+ portfolio_id?: string;
+ portfolio_name?: string;
+ }) => {
+ if (data.resume_id) {
+ form.setValue("resume_id", data.resume_id);
+ setDisplayName({
+ ...displayName,
+ resume: data.resume_name || ""
+ });
+ }
+ if (data.portfolio_id) {
+ form.setValue("portfolio_id", data.portfolio_id);
+ setDisplayName({
+ ...displayName,
+ portfolio: data.portfolio_name || ""
+ });
+ }
+ };
+
+ const isPending = isParsing || mutation.isPending;
+
+ return (
+
+
+
+
이력서 평가
+
+ 이력서와 포트폴리오를 업로드하고, 지원하려는 직무 정보를
+ 입력해주세요.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/domains/resume/components/resumeBasedInterviewHistory.tsx b/apps/client/src/domains/resume/components/resumeBasedInterviewHistory.tsx
new file mode 100644
index 00000000..f581378b
--- /dev/null
+++ b/apps/client/src/domains/resume/components/resumeBasedInterviewHistory.tsx
@@ -0,0 +1,237 @@
+import { getResumeBasedInterviewGenerations } from "@/domains/resume/api/resumeBasedInterview";
+import { ResumeBasedInterviewGenerationsResponse } from "@kokomen/types";
+import { CamelCasedProperties } from "@/utils/convertConvention";
+import { resumeBasedInterviewKeys } from "@/utils/querykeys";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useInfiniteObserver } from "@kokomen/utils";
+import { useRef, JSX } from "react";
+import {
+ Calendar,
+ FileText,
+ ExternalLink,
+ CheckCircle,
+ XCircle,
+ Clock,
+ Loader2
+} from "lucide-react";
+import Link from "next/link";
+import { formatDate } from "@/utils/date";
+
+export default function ResumeBasedInterviewHistory(): JSX.Element {
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ isError
+ } = useInfiniteQuery<
+ CamelCasedProperties
+ >({
+ queryKey: resumeBasedInterviewKeys.infinite(),
+ queryFn: ({ pageParam = 0 }) =>
+ getResumeBasedInterviewGenerations(pageParam as number),
+ getNextPageParam: (lastPage) => {
+ return lastPage.hasNext ? lastPage.currentPage + 1 : undefined;
+ },
+ initialPageParam: 0
+ });
+
+ const loadMoreRef = useRef(null);
+
+ useInfiniteObserver(loadMoreRef, () => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ });
+
+ const getStateBadge = (state: string): JSX.Element | null => {
+ switch (state) {
+ case "COMPLETED":
+ return (
+
+
+ 완료
+
+ );
+ case "PENDING":
+ return (
+
+
+ 진행중
+
+ );
+ case "FAILED":
+ return (
+
+
+ 실패
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ 이력서 기반 면접 질문 히스토리
+
+
+ 생성된 이력서 기반 면접 질문들을 확인해보세요
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
+
+ 이력서 기반 면접 질문 히스토리
+
+
+ 생성된 이력서 기반 면접 질문들을 확인해보세요
+
+
+
+
+ 데이터를 불러오는 중 오류가 발생했습니다.
+
+
+
+ );
+ }
+
+ const generations = data.pages.flatMap((page) => page.data || []);
+
+ return (
+
+
+
+ 이력서 기반 면접 질문 히스토리
+
+
+ 생성된 이력서 기반 면접 질문들을 확인해보세요
+
+
+
+ {generations.length === 0 ? (
+
+
+
+ 생성된 이력서 기반 면접 질문이 없습니다
+
+
+ 이력서를 업로드하여 면접 질문을 생성해보세요
+
+
+ ) : (
+
+ {generations.map((generation) => (
+
+
+
+
+
+ 이력서 기반 면접 질문
+
+ {getStateBadge(generation.state)}
+
+
+
+
+
+ {formatDate(generation.createdAt)}
+
+
+ 연차:
+ {generation.jobCareer}
+
+
+
+
+ {generation.resume && (
+
+ )}
+ {generation.portfolio && (
+
+ )}
+
+
+
+ {generation.state === "COMPLETED" && (
+
+
+ 질문 보기
+
+
+ )}
+
+
+ ))}
+
+ {hasNextPage && (
+
+ {isFetchingNextPage ? (
+
+
+ 더 불러오는 중...
+
+ ) : (
+
+ )}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/domains/resume/components/resumeEvaluationForm.tsx b/apps/client/src/domains/resume/components/resumeEvaluationForm.tsx
index 8bbca71c..bde47ad4 100644
--- a/apps/client/src/domains/resume/components/resumeEvaluationForm.tsx
+++ b/apps/client/src/domains/resume/components/resumeEvaluationForm.tsx
@@ -15,13 +15,22 @@ import { archiveKeys } from "@/utils/querykeys";
import { publishReportEvent } from "@/domains/resume/utils/reportEventEmitter";
const jobCareers = ["0-1년", "1-3년", "3-5년", "5-10년", "10년 이상"];
+
+// SSR 환경에서 FileList가 정의되지 않을 수 있으므로 custom 검증 사용
+const fileListSchema: z.ZodTypeAny =
+ typeof FileList !== "undefined"
+ ? z.instanceof(FileList)
+ : z.custom((val): val is FileList => {
+ return typeof FileList !== "undefined" && val instanceof FileList;
+ });
+
const resumeEvalFormFields = z
.object({
// FileList를 직접 받거나, 이미 업로드된 경우를 위해 optional 처리
- resume: z.instanceof(FileList).optional(),
+ resume: fileListSchema.optional(),
resume_id: z.string().optional(),
- portfolio: z.instanceof(FileList).optional(),
+ portfolio: fileListSchema.optional(),
portfolio_id: z.string().optional(),
job_position: z.string().min(1, { message: "지원 직무를 입력해주세요" }),
diff --git a/apps/client/src/domains/resume/components/resumeEvaluationHistory.tsx b/apps/client/src/domains/resume/components/resumeEvaluationHistory.tsx
new file mode 100644
index 00000000..02d242ad
--- /dev/null
+++ b/apps/client/src/domains/resume/components/resumeEvaluationHistory.tsx
@@ -0,0 +1,209 @@
+import { getResumeEvaluations } from "@/domains/resume/api";
+import { ResumeEvaluationsResponse } from "@kokomen/types";
+import { CamelCasedProperties } from "@/utils/convertConvention";
+import { resumeEvaluationKeys } from "@/utils/querykeys";
+import { useQuery } from "@tanstack/react-query";
+import { useRouter } from "next/router";
+import {
+ Calendar,
+ Briefcase,
+ TrendingUp,
+ CheckCircle,
+ XCircle,
+ Clock
+} from "lucide-react";
+import Link from "next/link";
+import PaginationButtons from "@/shared/paginationButtons";
+import { formatDate } from "@/utils/date";
+
+export default function ResumeEvaluationHistory() {
+ const router = useRouter();
+ const page = Number(router.query.page) || 0;
+ const size = 20;
+
+ const { data, isLoading, isError } = useQuery<
+ CamelCasedProperties
+ >({
+ queryKey: resumeEvaluationKeys.history(page, size),
+ queryFn: () => getResumeEvaluations(page, size)
+ });
+
+ const getStateBadge = (state: string) => {
+ switch (state) {
+ case "COMPLETED":
+ return (
+
+
+ 완료
+
+ );
+ case "PENDING":
+ return (
+
+
+ 진행중
+
+ );
+ case "FAILED":
+ return (
+
+
+ 실패
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ const getScoreColor = (score: number) => {
+ if (score >= 80) return "text-green-600";
+ if (score >= 60) return "text-yellow-600";
+ return "text-red-600";
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ 이력서 평가 히스토리
+
+
+ 이력서 평가 결과를 확인해보세요
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
+
+ 이력서 평가 히스토리
+
+
+ 이력서 평가 결과를 확인해보세요
+
+
+
+
+ 데이터를 불러오는 중 오류가 발생했습니다.
+
+
+
+ );
+ }
+
+ const evaluations = data.evaluations || [];
+
+ return (
+
+
+
+ 이력서 평가 히스토리
+
+
+ 이력서 평가 결과를 확인해보세요
+
+
+
+ {evaluations.length === 0 ? (
+
+
+
+ 평가한 이력서가 없습니다
+
+
+ 이력서를 업로드하여 평가를 받아보세요
+
+
+ ) : (
+
+ {evaluations.map((evaluation) => (
+
+
+
+
+
+ 이력서 평가
+
+ {getStateBadge(evaluation.state)}
+
+
+
+
+
+ {formatDate(evaluation.createdAt)}
+
+
+
+ 직무:
+ {evaluation.jobPosition}
+
+
+ 연차:
+ {evaluation.jobCareer}
+
+
+
+ {evaluation.state === "COMPLETED" && (
+
+
+
+ 총점: {evaluation.totalScore}점
+
+
+ )}
+
+
+ {evaluation.state === "COMPLETED" && (
+
+
+ 결과 보기
+
+
+ )}
+
+
+ ))}
+
+ {data.totalPages > 1 && (
+
+ )}
+
+ )}
+
+ );
+}
+
diff --git a/apps/client/src/domains/resume/components/resumeInterviewModeSelectModal.tsx b/apps/client/src/domains/resume/components/resumeInterviewModeSelectModal.tsx
new file mode 100644
index 00000000..b03066f1
--- /dev/null
+++ b/apps/client/src/domains/resume/components/resumeInterviewModeSelectModal.tsx
@@ -0,0 +1,69 @@
+import { Modal } from "@kokomen/ui";
+import { InterviewMode } from "@kokomen/types";
+import { Mic, FileText } from "lucide-react";
+
+interface ResumeInterviewModeSelectModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ // eslint-disable-next-line no-unused-vars
+ onSelectMode: (mode: InterviewMode) => void;
+}
+
+export default function ResumeInterviewModeSelectModal({
+ isOpen,
+ onClose,
+ onSelectMode
+}: ResumeInterviewModeSelectModalProps) {
+ const handleSelectMode = (mode: InterviewMode) => {
+ onSelectMode(mode);
+ onClose();
+ };
+
+ return (
+
+
+
+ 면접을 진행할 모드를 선택해주세요.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/domains/resume/components/resumeSelectMenuNormal.tsx b/apps/client/src/domains/resume/components/resumeSelectMenuNormal.tsx
index 50be9f80..f397135b 100644
--- a/apps/client/src/domains/resume/components/resumeSelectMenuNormal.tsx
+++ b/apps/client/src/domains/resume/components/resumeSelectMenuNormal.tsx
@@ -1,6 +1,5 @@
import Link from "next/link";
import Image from "next/image";
-import { Tooltip } from "@kokomen/ui";
interface MenuItemProps {
title: string;
description: string;
@@ -109,18 +108,21 @@ export default function ResumeSelectMenuNormal() {
체험해보기
-
+
-
- 곧 출시돼요! 조금만 기다려주세요 :)
-
-
+
+ 체험해보기
+
+
diff --git a/apps/client/src/domains/resume/context/resumeBasedInterviewStore.tsx b/apps/client/src/domains/resume/context/resumeBasedInterviewStore.tsx
new file mode 100644
index 00000000..1b4789bb
--- /dev/null
+++ b/apps/client/src/domains/resume/context/resumeBasedInterviewStore.tsx
@@ -0,0 +1,119 @@
+import { checkResumeBasedInterviewQuestion } from "@/domains/resume/api/resumeBasedInterview";
+import { useResumeBasedInterviewEvent } from "@/domains/resume/utils/resumeInterviewEventEmitter";
+import { RoundSpinner, Tooltip } from "@kokomen/ui";
+import { CheckIcon, X } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import Link from "next/link";
+import React, { createContext, useState } from "react";
+
+type ResumeBasedInterviewState = "IDLE" | "PENDING" | "COMPLETED" | "ERROR";
+interface IResumeBasedInterviewStore {
+ interviewState: ResumeBasedInterviewState;
+ interviewResultId: number | null;
+ // eslint-disable-next-line no-unused-vars
+ setInterviewResultId: (interviewResultId: number) => void;
+}
+const ResumeBasedInterviewStore =
+ createContext(null);
+
+export default function ResumeBasedInterviewStoreProvider({
+ children
+}: {
+ children: React.ReactNode;
+}) {
+ const [interviewState, setInterviewState] =
+ useState("IDLE");
+ const [interviewResultId, setInterviewResultId] = useState(
+ null
+ );
+
+ useResumeBasedInterviewEvent(
+ "resumeBasedInterview:submitted",
+ async (payload) => {
+ try {
+ setInterviewState("PENDING");
+ setInterviewResultId(payload.resume_based_interview_result_id);
+ const response = await checkResumeBasedInterviewQuestion(
+ payload.resume_based_interview_result_id
+ );
+ if (response.state === "COMPLETED") {
+ setInterviewState("COMPLETED");
+ } else if (response.state === "FAILED") {
+ setInterviewState("ERROR");
+ }
+ } catch (error) {
+ setInterviewState("ERROR");
+ }
+ }
+ );
+
+ return (
+
+
+ {interviewState === "PENDING" && (
+
+
+
+ 면접 질문 생성 중...
+
+
+
+
+ )}
+ {interviewState === "COMPLETED" && (
+ {
+ setInterviewState("IDLE");
+ setInterviewResultId(null);
+ }}
+ >
+
+
+
+ 면접 질문 생성 완료
+
+
+
+
+
+ )}
+ {interviewState === "ERROR" && (
+
+ {
+ setInterviewState("IDLE");
+ setInterviewResultId(null);
+ }}
+ >
+
+ 면접 질문 생성 중
오류가 발생했어요
+
+
+
+
+ )}
+
+ {children}
+
+ );
+}
diff --git a/apps/client/src/domains/resume/utils/resumeInterviewEventEmitter.ts b/apps/client/src/domains/resume/utils/resumeInterviewEventEmitter.ts
new file mode 100644
index 00000000..4969979a
--- /dev/null
+++ b/apps/client/src/domains/resume/utils/resumeInterviewEventEmitter.ts
@@ -0,0 +1,27 @@
+/* eslint-disable no-unused-vars */
+import { publishEvent, useSubscribeEvents } from "@/utils/eventEmitter";
+import {
+ ResumeBasedInterviewEventPayloads,
+ ResumeBasedInterviewEventType
+} from "@kokomen/types";
+import { DependencyList } from "react";
+// 이벤트에 대서 콜백 함수 구독하는 훅
+export function useResumeBasedInterviewEvent<
+ K extends ResumeBasedInterviewEventType
+>(
+ event: K,
+ handler: ResumeBasedInterviewEventPayloads[K] extends undefined
+ ? () => void
+ : (payload: ResumeBasedInterviewEventPayloads[K]) => void,
+ deps: DependencyList = []
+): void {
+ const eventEmitter = useSubscribeEvents(
+ [{ event, handler }],
+ []
+ );
+}
+
+export const publishResumeBasedInterviewEvent = publishEvent<
+ ResumeBasedInterviewEventType,
+ ResumeBasedInterviewEventPayloads
+>();
diff --git a/apps/client/src/pages/_app.tsx b/apps/client/src/pages/_app.tsx
index 196c1434..1f359b82 100644
--- a/apps/client/src/pages/_app.tsx
+++ b/apps/client/src/pages/_app.tsx
@@ -7,6 +7,7 @@ import { ErrorBoundary } from "@sentry/nextjs";
import ErrorFallback from "@/shared/errorFallback";
import FeedbackButton from "@/shared/feedbackButton";
import ResumeStoreProvider from "@/domains/resume/context/resumeStore";
+import ResumeBasedInterviewStoreProvider from "@/domains/resume/context/resumeBasedInterviewStore";
const queryClient: QueryClient = new QueryClient();
@@ -17,8 +18,10 @@ export default function App({ Component, pageProps }: AppProps): JSX.Element {
}>
-
-
+
+
+
+
diff --git a/apps/client/src/pages/members/interviews/[interviewId].tsx b/apps/client/src/pages/members/interviews/[interviewId].tsx
index 24430614..ca198d57 100644
--- a/apps/client/src/pages/members/interviews/[interviewId].tsx
+++ b/apps/client/src/pages/members/interviews/[interviewId].tsx
@@ -173,7 +173,9 @@ export const getServerSideProps = async (
]);
if (interviewResult.status === "rejected") {
- return { redirect: { destination: "/error", permanent: false } };
+ // 크롤러가 인덱싱할 수 있도록 redirect 대신 notFound 반환
+ // API가 인증 없이 접근 가능하도록 서버 측 수정이 필요할 수 있음
+ return { notFound: true };
}
const result = interviewResult.value;
diff --git a/apps/client/src/pages/resume/index.tsx b/apps/client/src/pages/resume/index.tsx
index 2b55a88a..accfa1bc 100644
--- a/apps/client/src/pages/resume/index.tsx
+++ b/apps/client/src/pages/resume/index.tsx
@@ -22,6 +22,7 @@ export default function ResumePage({
description="내 이력서는 채용 공고에 얼마나 적합할까? 지금 꼬꼬면에서 이력서와 포트폴리오가 채용 공고에 얼마나 적합한지 평가해보세요."
image="/resume.png"
robots="index, follow"
+ pathname="/resume"
/>
diff --git a/apps/client/src/pages/resume/interview/[interviewId].tsx b/apps/client/src/pages/resume/interview/[interviewId].tsx
new file mode 100644
index 00000000..fc6d0540
--- /dev/null
+++ b/apps/client/src/pages/resume/interview/[interviewId].tsx
@@ -0,0 +1,216 @@
+import Header from "@/shared/header";
+import { getUserInfo } from "@/domains/auth/api";
+import { UserInfo } from "@kokomen/types";
+import {
+ GetServerSidePropsContext,
+ GetServerSidePropsResult,
+ InferGetServerSidePropsType
+} from "next";
+import { Footer } from "@/shared/footer";
+import { AxiosError } from "axios";
+import { SEO } from "@/shared/seo";
+import { getResumeInterviewResult } from "@/domains/resume/api/resumeBasedInterview";
+import { ResumeBasedInterviewQuestion } from "@kokomen/types";
+import { Button, useToast } from "@kokomen/ui";
+import { useRouter } from "next/router";
+import { useMutation } from "@tanstack/react-query";
+import { createResumeBasedInterview } from "@/domains/resume/api/resumeBasedInterview";
+import { InterviewMode } from "@kokomen/types";
+import { useState } from "react";
+import ResumeInterviewModeSelectModal from "@/domains/resume/components/resumeInterviewModeSelectModal";
+import { withApiErrorCapture } from "@/utils/error";
+import useExtendedRouter from "@/hooks/useExtendedRouter";
+import { MessageSquare } from "lucide-react";
+
+export default function ResumeInterviewResultPage({
+ userInfo,
+ questions,
+ interviewId
+}: InferGetServerSidePropsType) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const extendedRouter = useExtendedRouter();
+ const [selectedQuestionId, setSelectedQuestionId] = useState(
+ null
+ );
+ const [isModeModalOpen, setIsModeModalOpen] = useState(false);
+
+ const createInterviewMutation = useMutation({
+ mutationFn: ({
+ generatedQuestionId,
+ mode
+ }: {
+ generatedQuestionId: number;
+ mode: InterviewMode;
+ }) =>
+ createResumeBasedInterview({
+ resumeBasedInterviewResultId: Number(interviewId),
+ generatedQuestionId,
+ maxQuestionCount: questions.length,
+ mode
+ }),
+ onSuccess: (data) => {
+ const mode = "cur_question" in data ? "TEXT" : "VOICE";
+ router.push(`/interviews/${data.interview_id}?mode=${mode}`);
+ },
+ onError: withApiErrorCapture((error) => {
+ if (error instanceof AxiosError && error.response?.status === 401) {
+ extendedRouter.navigateToLogin();
+ return;
+ }
+ toast({
+ title: "면접 생성 실패",
+ description:
+ "면접 생성 중 오류가 발생했어요. 잠시 후 다시 시도해주세요.",
+ variant: "error"
+ });
+ })
+ });
+
+ const handleStartInterview = (questionId: number): void => {
+ setSelectedQuestionId(questionId);
+ setIsModeModalOpen(true);
+ };
+
+ const handleSelectMode = (mode: InterviewMode): void => {
+ if (selectedQuestionId === null) return;
+
+ createInterviewMutation.mutate({
+ generatedQuestionId: selectedQuestionId,
+ mode
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ 생성된 면접 질문
+
+
+ 이력서를 기반으로 생성된 면접 질문입니다. 질문을 선택하여
+ 면접을 시작해보세요.
+
+
+
+
+ {questions.map((question, index) => (
+
+
+
+
+
+ {index + 1}
+
+
+ 질문 {index + 1}
+
+
+
+ {question.question}
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {
+ setIsModeModalOpen(false);
+ setSelectedQuestionId(null);
+ }}
+ onSelectMode={handleSelectMode}
+ />
+ >
+ );
+}
+
+export const getServerSideProps = async (
+ context: GetServerSidePropsContext
+): Promise<
+ GetServerSidePropsResult<{
+ userInfo: UserInfo | null;
+ questions: ResumeBasedInterviewQuestion[];
+ interviewId: string;
+ }>
+> => {
+ const { interviewId } = context.params as { interviewId: string };
+ const interviewIdNumber = parseInt(interviewId, 10);
+
+ if (isNaN(interviewIdNumber)) {
+ return {
+ notFound: true
+ };
+ }
+
+ try {
+ const [userInfoResult, questionsResult] = await Promise.all([
+ getUserInfo(context)
+ .then((res) => res.data)
+ .catch((error) => {
+ if (error instanceof AxiosError && error.response?.status === 401) {
+ return null;
+ }
+ throw error;
+ }),
+ getResumeInterviewResult(interviewIdNumber, context)
+ ]);
+
+ return {
+ props: {
+ userInfo: userInfoResult,
+ questions: questionsResult as ResumeBasedInterviewQuestion[],
+ interviewId
+ }
+ };
+ } catch (error) {
+ if (error instanceof AxiosError && error.response?.status === 401) {
+ return {
+ redirect: {
+ destination: "/login",
+ permanent: false
+ }
+ };
+ }
+ if (error instanceof AxiosError) {
+ if (error.response?.status === 404 || error.response?.status === 403) {
+ return {
+ notFound: true
+ };
+ }
+ }
+ throw error;
+ }
+};
diff --git a/apps/client/src/pages/resume/interview/index.tsx b/apps/client/src/pages/resume/interview/index.tsx
new file mode 100644
index 00000000..49f66ae9
--- /dev/null
+++ b/apps/client/src/pages/resume/interview/index.tsx
@@ -0,0 +1,59 @@
+import Header from "@/shared/header";
+import { getUserInfo } from "@/domains/auth/api";
+import { UserInfo } from "@kokomen/types";
+import { ErrorBoundary } from "@sentry/nextjs";
+import {
+ GetServerSidePropsContext,
+ GetServerSidePropsResult,
+ InferGetServerSidePropsType
+} from "next";
+import { Footer } from "@/shared/footer";
+import { AxiosError } from "axios";
+import { SEO } from "@/shared/seo";
+import ResumeBasedInterviewForm from "@/domains/resume/components/resumeBasedInterviewForm";
+
+export default function ResumeInterviewCreatePage({
+ userInfo
+}: InferGetServerSidePropsType) {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+export const getServerSideProps = async (
+ context: GetServerSidePropsContext
+): Promise> => {
+ const userInfo = await getUserInfo(context)
+ .then((res) => res.data)
+ .catch((error) => {
+ if (error instanceof AxiosError && error.response?.status === 401) {
+ return null;
+ }
+ throw error;
+ });
+
+ return {
+ props: {
+ userInfo
+ }
+ };
+};
diff --git a/apps/client/src/utils/querykeys.ts b/apps/client/src/utils/querykeys.ts
index dc0151d8..2fa3e74d 100644
--- a/apps/client/src/utils/querykeys.ts
+++ b/apps/client/src/utils/querykeys.ts
@@ -109,6 +109,27 @@ const archiveKeys: QueryKeyFactory = {
[...archiveKeys.all, "resumes", type ?? "ALL"] as const
};
+type ResumeBasedInterviewMethods = {
+ generations: (page?: number) => QueryKey;
+ infinite: () => QueryKey;
+};
+const resumeBasedInterviewKeys: QueryKeyFactory = {
+ all: ["resumeBasedInterview"] as const,
+ generations: (page: number = 0): QueryKey =>
+ [...resumeBasedInterviewKeys.all, "generations", page] as const,
+ infinite: (): QueryKey =>
+ [...resumeBasedInterviewKeys.all, "infinite"] as const
+};
+
+type ResumeEvaluationMethods = {
+ history: (page?: number, size?: number) => QueryKey;
+};
+const resumeEvaluationKeys: QueryKeyFactory = {
+ all: ["resumeEvaluation"] as const,
+ history: (page: number = 0, size: number = 20): QueryKey =>
+ [...resumeEvaluationKeys.all, "history", page, size] as const
+};
+
export {
interviewHistoryKeys,
interviewKeys,
@@ -116,9 +137,13 @@ export {
archiveKeys,
purchaseKeys,
recruitKeys,
+ resumeBasedInterviewKeys,
+ resumeEvaluationKeys,
type InterviewHistoryParams,
type InterviewParams,
type MemberRankParams,
type RecruitMethods,
- type ArchiveMethods
+ type ArchiveMethods,
+ type ResumeBasedInterviewMethods,
+ type ResumeEvaluationMethods
};
diff --git a/packages/types/src/events/index.ts b/packages/types/src/events/index.ts
index 11786bb4..3bac9aab 100644
--- a/packages/types/src/events/index.ts
+++ b/packages/types/src/events/index.ts
@@ -1,2 +1,3 @@
export * from "./interview";
export * from "./report";
+export * from "./resumeBasedInterview";
diff --git a/packages/types/src/events/resumeBasedInterview.ts b/packages/types/src/events/resumeBasedInterview.ts
new file mode 100644
index 00000000..f406ccfa
--- /dev/null
+++ b/packages/types/src/events/resumeBasedInterview.ts
@@ -0,0 +1,14 @@
+interface ResumeBasedInterviewEventPayloads {
+ "resumeBasedInterview:submitted": {
+ resume_based_interview_result_id: number;
+ };
+ "resumeBasedInterview:created": undefined;
+ "resumeBasedInterview:updated": undefined;
+ "resumeBasedInterview:error": { error: string };
+}
+
+type ResumeBasedInterviewEventType = keyof ResumeBasedInterviewEventPayloads;
+export type {
+ ResumeBasedInterviewEventType,
+ ResumeBasedInterviewEventPayloads
+};
diff --git a/packages/types/src/interviews/index.ts b/packages/types/src/interviews/index.ts
index dddcc18f..7543f661 100644
--- a/packages/types/src/interviews/index.ts
+++ b/packages/types/src/interviews/index.ts
@@ -156,3 +156,4 @@ export type {
AnswerScore,
InterviewQuestion
};
+export * from "./resumeBasedInterview";
diff --git a/packages/types/src/interviews/resumeBasedInterview.ts b/packages/types/src/interviews/resumeBasedInterview.ts
new file mode 100644
index 00000000..fe90d490
--- /dev/null
+++ b/packages/types/src/interviews/resumeBasedInterview.ts
@@ -0,0 +1,52 @@
+import { InterviewMode } from ".";
+import { Paginated } from "../utils";
+
+type CreateResumeBasedInterview = {
+ generated_question_id: number;
+ max_question_count: number;
+ mode: InterviewMode;
+};
+
+type ResumeBasedInterviewQuestion = {
+ id: number;
+ question: string;
+};
+
+type ResumeInterviewPending = {
+ state: "PENDING";
+};
+type ResumeInterviewFailed = {
+ state: "FAILED";
+};
+type ResumeInterviewSuccess = {
+ state: "COMPLETED";
+};
+
+type ResumeBasedInterviewGeneration = {
+ id: number;
+ job_career: string;
+ state: "PENDING" | "COMPLETED" | "FAILED";
+ created_at: string;
+ resume: {
+ name: string;
+ url: string;
+ } | null;
+ portfolio: {
+ name: string;
+ url: string;
+ } | null;
+};
+
+type ResumeBasedInterviewGenerationsResponse = Paginated<
+ ResumeBasedInterviewGeneration[]
+>;
+
+export type {
+ CreateResumeBasedInterview,
+ ResumeBasedInterviewQuestion,
+ ResumeInterviewPending,
+ ResumeInterviewFailed,
+ ResumeInterviewSuccess,
+ ResumeBasedInterviewGeneration,
+ ResumeBasedInterviewGenerationsResponse
+};
diff --git a/packages/types/src/resume/index.ts b/packages/types/src/resume/index.ts
index 8d565c1c..1e8d0af8 100644
--- a/packages/types/src/resume/index.ts
+++ b/packages/types/src/resume/index.ts
@@ -77,6 +77,24 @@ type ResumeEvaluationResult = {
job_career: string;
result: ResumeOutput["result"];
};
+
+type ResumeEvaluationHistoryItem = {
+ id: number;
+ state: "PENDING" | "COMPLETED" | "FAILED";
+ job_position: string;
+ job_career: string;
+ total_score: number;
+ created_at: string;
+};
+
+type ResumeEvaluationsResponse = {
+ evaluations: ResumeEvaluationHistoryItem[];
+ current_page: number;
+ total_resume_evaluation_count: number;
+ total_pages: number;
+ has_next: boolean;
+};
+
export type {
ResumeInput,
ResumeOutput,
@@ -85,5 +103,7 @@ export type {
ResumeInputWithArchivedFile,
ResumeInputWithNewFile,
ResumeFailed,
- ResumeEvaluationResult
+ ResumeEvaluationResult,
+ ResumeEvaluationHistoryItem,
+ ResumeEvaluationsResponse
};
diff --git a/yarn.lock b/yarn.lock
index 464e1cdf..eca99b9a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5035,6 +5035,7 @@ __metadata:
react-chartjs-2: "npm:^5.3.0"
react-dom: "npm:^19.0.0"
react-hook-form: "npm:^7.59.0"
+ react-pdf: "npm:^9.2.1"
tailwindcss: "npm:^4"
three: "npm:^0.177.0"
ts-node: "npm:^10.9.2"
@@ -11341,7 +11342,7 @@ __metadata:
languageName: node
linkType: hard
-"bl@npm:^4.1.0":
+"bl@npm:^4.0.3, bl@npm:^4.1.0":
version: 4.1.0
resolution: "bl@npm:4.1.0"
dependencies:
@@ -11619,6 +11620,17 @@ __metadata:
languageName: node
linkType: hard
+"canvas@npm:^3.0.0-rc2":
+ version: 3.2.1
+ resolution: "canvas@npm:3.2.1"
+ dependencies:
+ node-addon-api: "npm:^7.0.0"
+ node-gyp: "npm:latest"
+ prebuild-install: "npm:^7.1.3"
+ checksum: 10c0/c0fd572a8b28e075b40a42b523bdf05e985feaeb18b56085432bfb91a3b905af48f89ec73ed4e795de892cb13f7332ceb0c78cf84c64281c41c29995665b89c8
+ languageName: node
+ linkType: hard
+
"chai@npm:^5.1.1":
version: 5.2.0
resolution: "chai@npm:5.2.0"
@@ -11741,6 +11753,13 @@ __metadata:
languageName: node
linkType: hard
+"chownr@npm:^1.1.1":
+ version: 1.1.4
+ resolution: "chownr@npm:1.1.4"
+ checksum: 10c0/ed57952a84cc0c802af900cf7136de643d3aba2eecb59d29344bc2f3f9bf703a301b9d84cdc71f82c3ffc9ccde831b0d92f5b45f91727d6c9da62f23aef9d9db
+ languageName: node
+ linkType: hard
+
"chownr@npm:^3.0.0":
version: 3.0.0
resolution: "chownr@npm:3.0.0"
@@ -11950,7 +11969,7 @@ __metadata:
languageName: node
linkType: hard
-"clsx@npm:^2.1.1":
+"clsx@npm:^2.0.0, clsx@npm:^2.1.1":
version: 2.1.1
resolution: "clsx@npm:2.1.1"
checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
@@ -12503,6 +12522,15 @@ __metadata:
languageName: node
linkType: hard
+"decompress-response@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "decompress-response@npm:6.0.0"
+ dependencies:
+ mimic-response: "npm:^3.1.0"
+ checksum: 10c0/bd89d23141b96d80577e70c54fb226b2f40e74a6817652b80a116d7befb8758261ad073a8895648a29cc0a5947021ab66705cb542fa9c143c82022b27c5b175e
+ languageName: node
+ linkType: hard
+
"dedent@npm:^1.6.0":
version: 1.6.0
resolution: "dedent@npm:1.6.0"
@@ -12522,6 +12550,13 @@ __metadata:
languageName: node
linkType: hard
+"deep-extend@npm:^0.6.0":
+ version: 0.6.0
+ resolution: "deep-extend@npm:0.6.0"
+ checksum: 10c0/1c6b0abcdb901e13a44c7d699116d3d4279fdb261983122a3783e7273844d5f2537dc2e1c454a23fcf645917f93fbf8d07101c1d03c015a87faa662755212566
+ languageName: node
+ linkType: hard
+
"deep-is@npm:^0.1.3":
version: 0.1.4
resolution: "deep-is@npm:0.1.4"
@@ -12629,6 +12664,13 @@ __metadata:
languageName: node
linkType: hard
+"detect-libc@npm:^2.0.0":
+ version: 2.1.2
+ resolution: "detect-libc@npm:2.1.2"
+ checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4
+ languageName: node
+ linkType: hard
+
"detect-libc@npm:^2.0.3, detect-libc@npm:^2.0.4":
version: 2.0.4
resolution: "detect-libc@npm:2.0.4"
@@ -12851,7 +12893,7 @@ __metadata:
languageName: node
linkType: hard
-"end-of-stream@npm:^1.1.0":
+"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1":
version: 1.4.5
resolution: "end-of-stream@npm:1.4.5"
dependencies:
@@ -13806,6 +13848,13 @@ __metadata:
languageName: node
linkType: hard
+"expand-template@npm:^2.0.3":
+ version: 2.0.3
+ resolution: "expand-template@npm:2.0.3"
+ checksum: 10c0/1c9e7afe9acadf9d373301d27f6a47b34e89b3391b1ef38b7471d381812537ef2457e620ae7f819d2642ce9c43b189b3583813ec395e2938319abe356a9b2f51
+ languageName: node
+ linkType: hard
+
"expect-type@npm:^1.2.1":
version: 1.2.2
resolution: "expect-type@npm:1.2.2"
@@ -14390,6 +14439,13 @@ __metadata:
languageName: node
linkType: hard
+"fs-constants@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "fs-constants@npm:1.0.0"
+ checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8
+ languageName: node
+ linkType: hard
+
"fs-extra@npm:^10.0.0":
version: 10.1.0
resolution: "fs-extra@npm:10.1.0"
@@ -14576,6 +14632,13 @@ __metadata:
languageName: node
linkType: hard
+"github-from-package@npm:0.0.0":
+ version: 0.0.0
+ resolution: "github-from-package@npm:0.0.0"
+ checksum: 10c0/737ee3f52d0a27e26332cde85b533c21fcdc0b09fb716c3f8e522cfaa9c600d4a631dec9fcde179ec9d47cca89017b7848ed4d6ae6b6b78f936c06825b1fcc12
+ languageName: node
+ linkType: hard
+
"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
version: 5.1.2
resolution: "glob-parent@npm:5.1.2"
@@ -15124,6 +15187,13 @@ __metadata:
languageName: node
linkType: hard
+"ini@npm:~1.3.0":
+ version: 1.3.8
+ resolution: "ini@npm:1.3.8"
+ checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a
+ languageName: node
+ linkType: hard
+
"inquirer@npm:^6.3.1":
version: 6.5.2
resolution: "inquirer@npm:6.5.2"
@@ -17797,7 +17867,7 @@ __metadata:
languageName: node
linkType: hard
-"loose-envify@npm:^1.4.0":
+"loose-envify@npm:^1.0.0, loose-envify@npm:^1.4.0":
version: 1.4.0
resolution: "loose-envify@npm:1.4.0"
dependencies:
@@ -17933,6 +18003,13 @@ __metadata:
languageName: node
linkType: hard
+"make-cancellable-promise@npm:^1.3.1":
+ version: 1.3.2
+ resolution: "make-cancellable-promise@npm:1.3.2"
+ checksum: 10c0/10aa0450c743dcf20b55414c433ca45926b775b22eb6d25fa386fc499a8f3fc64c70eb575d99bdd16667d300068f51702822c293bc4e72da7ff4f82d0ea48184
+ languageName: node
+ linkType: hard
+
"make-dir@npm:^3.0.0":
version: 3.1.0
resolution: "make-dir@npm:3.1.0"
@@ -17958,6 +18035,13 @@ __metadata:
languageName: node
linkType: hard
+"make-event-props@npm:^1.6.0":
+ version: 1.6.2
+ resolution: "make-event-props@npm:1.6.2"
+ checksum: 10c0/ecf0b742e43a392c07e2267baca2397e750d38cc14ef3cb72ef8bfe4a8c8b0fd99a03a2eeab84a26c2b204f7c231da6af31fa26321fbfd413ded43ba1825e867
+ languageName: node
+ linkType: hard
+
"make-fetch-happen@npm:^14.0.3":
version: 14.0.3
resolution: "make-fetch-happen@npm:14.0.3"
@@ -18060,6 +18144,18 @@ __metadata:
languageName: node
linkType: hard
+"merge-refs@npm:^1.3.0":
+ version: 1.3.0
+ resolution: "merge-refs@npm:1.3.0"
+ peerDependencies:
+ "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10c0/403d20d283a595565a6bef813415df509dad12a5ad157f0ae04861b3aee4a3691971ccae7079e20497d9f367a478ad60e5b63a2ca9ffb2cc3d511284b49b4bd6
+ languageName: node
+ linkType: hard
+
"merge-stream@npm:^2.0.0":
version: 2.0.0
resolution: "merge-stream@npm:2.0.0"
@@ -18178,6 +18274,13 @@ __metadata:
languageName: node
linkType: hard
+"mimic-response@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "mimic-response@npm:3.1.0"
+ checksum: 10c0/0d6f07ce6e03e9e4445bee655202153bdb8a98d67ee8dc965ac140900d7a2688343e6b4c9a72cfc9ef2f7944dfd76eef4ab2482eb7b293a68b84916bac735362
+ languageName: node
+ linkType: hard
+
"min-indent@npm:^1.0.0, min-indent@npm:^1.0.1":
version: 1.0.1
resolution: "min-indent@npm:1.0.1"
@@ -18221,7 +18324,7 @@ __metadata:
languageName: node
linkType: hard
-"minimist@npm:^1.2.0, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8":
+"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8":
version: 1.2.8
resolution: "minimist@npm:1.2.8"
checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6
@@ -18318,6 +18421,13 @@ __metadata:
languageName: node
linkType: hard
+"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3":
+ version: 0.5.3
+ resolution: "mkdirp-classic@npm:0.5.3"
+ checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168
+ languageName: node
+ linkType: hard
+
"mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.6":
version: 0.5.6
resolution: "mkdirp@npm:0.5.6"
@@ -18500,6 +18610,13 @@ __metadata:
languageName: node
linkType: hard
+"napi-build-utils@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "napi-build-utils@npm:2.0.0"
+ checksum: 10c0/5833aaeb5cc5c173da47a102efa4680a95842c13e0d9cc70428bd3ee8d96bb2172f8860d2811799b5daa5cbeda779933601492a2028a6a5351c6d0fcf6de83db
+ languageName: node
+ linkType: hard
+
"napi-postinstall@npm:^0.2.4":
version: 0.2.5
resolution: "napi-postinstall@npm:0.2.5"
@@ -18629,6 +18746,15 @@ __metadata:
languageName: node
linkType: hard
+"node-abi@npm:^3.3.0":
+ version: 3.87.0
+ resolution: "node-abi@npm:3.87.0"
+ dependencies:
+ semver: "npm:^7.3.5"
+ checksum: 10c0/41cfc361edd1b0711d412ca9e1a475180c5b897868bd5583df7ff73e30e6044cc7de307df36c2257203320f17fadf7e82dfdf5a9f6fd510a8578e3fe3ed67ebb
+ languageName: node
+ linkType: hard
+
"node-abort-controller@npm:^3.0.1, node-abort-controller@npm:^3.1.1":
version: 3.1.1
resolution: "node-abort-controller@npm:3.1.1"
@@ -18636,6 +18762,15 @@ __metadata:
languageName: node
linkType: hard
+"node-addon-api@npm:^7.0.0":
+ version: 7.1.1
+ resolution: "node-addon-api@npm:7.1.1"
+ dependencies:
+ node-gyp: "npm:latest"
+ checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9
+ languageName: node
+ linkType: hard
+
"node-emoji@npm:1.11.0":
version: 1.11.0
resolution: "node-emoji@npm:1.11.0"
@@ -19167,6 +19302,13 @@ __metadata:
languageName: node
linkType: hard
+"path2d@npm:^0.2.1":
+ version: 0.2.2
+ resolution: "path2d@npm:0.2.2"
+ checksum: 10c0/1bb76c7f275d07f1bc7ca12171d828e91bf8a12596f0765a52e9d4d47fe1a428455dc1dd4c9002924a9bc554f6ac25e09a6c22eaecf32e5e33fba2985b5168f8
+ languageName: node
+ linkType: hard
+
"path@npm:^0.12.7":
version: 0.12.7
resolution: "path@npm:0.12.7"
@@ -19191,6 +19333,21 @@ __metadata:
languageName: node
linkType: hard
+"pdfjs-dist@npm:4.8.69":
+ version: 4.8.69
+ resolution: "pdfjs-dist@npm:4.8.69"
+ dependencies:
+ canvas: "npm:^3.0.0-rc2"
+ path2d: "npm:^0.2.1"
+ dependenciesMeta:
+ canvas:
+ optional: true
+ path2d:
+ optional: true
+ checksum: 10c0/dc297f2a36aa36834a2892cb78c3cafc7ac01753a2e7c4316a1f6e8c1d337a52a3bfbf7fdff7aaba615893b53f2d06a0efc2176525592b4d7b51021279c101be
+ languageName: node
+ linkType: hard
+
"pend@npm:~1.2.0":
version: 1.2.0
resolution: "pend@npm:1.2.0"
@@ -19399,6 +19556,28 @@ __metadata:
languageName: node
linkType: hard
+"prebuild-install@npm:^7.1.3":
+ version: 7.1.3
+ resolution: "prebuild-install@npm:7.1.3"
+ dependencies:
+ detect-libc: "npm:^2.0.0"
+ expand-template: "npm:^2.0.3"
+ github-from-package: "npm:0.0.0"
+ minimist: "npm:^1.2.3"
+ mkdirp-classic: "npm:^0.5.3"
+ napi-build-utils: "npm:^2.0.0"
+ node-abi: "npm:^3.3.0"
+ pump: "npm:^3.0.0"
+ rc: "npm:^1.2.7"
+ simple-get: "npm:^4.0.0"
+ tar-fs: "npm:^2.0.0"
+ tunnel-agent: "npm:^0.6.0"
+ bin:
+ prebuild-install: bin.js
+ checksum: 10c0/25919a42b52734606a4036ab492d37cfe8b601273d8dfb1fa3c84e141a0a475e7bad3ab848c741d2f810cef892fcf6059b8c7fe5b29f98d30e0c29ad009bedff
+ languageName: node
+ linkType: hard
+
"prelude-ls@npm:^1.2.1":
version: 1.2.1
resolution: "prelude-ls@npm:1.2.1"
@@ -19668,6 +19847,20 @@ __metadata:
languageName: node
linkType: hard
+"rc@npm:^1.2.7":
+ version: 1.2.8
+ resolution: "rc@npm:1.2.8"
+ dependencies:
+ deep-extend: "npm:^0.6.0"
+ ini: "npm:~1.3.0"
+ minimist: "npm:^1.2.0"
+ strip-json-comments: "npm:~2.0.1"
+ bin:
+ rc: ./cli.js
+ checksum: 10c0/24a07653150f0d9ac7168e52943cc3cb4b7a22c0e43c7dff3219977c2fdca5a2760a304a029c20811a0e79d351f57d46c9bde216193a0f73978496afc2b85b15
+ languageName: node
+ linkType: hard
+
"react-chartjs-2@npm:^5.3.0":
version: 5.3.0
resolution: "react-chartjs-2@npm:5.3.0"
@@ -19753,6 +19946,29 @@ __metadata:
languageName: node
linkType: hard
+"react-pdf@npm:^9.2.1":
+ version: 9.2.1
+ resolution: "react-pdf@npm:9.2.1"
+ dependencies:
+ clsx: "npm:^2.0.0"
+ dequal: "npm:^2.0.3"
+ make-cancellable-promise: "npm:^1.3.1"
+ make-event-props: "npm:^1.6.0"
+ merge-refs: "npm:^1.3.0"
+ pdfjs-dist: "npm:4.8.69"
+ tiny-invariant: "npm:^1.0.0"
+ warning: "npm:^4.0.0"
+ peerDependencies:
+ "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10c0/69b5456b3941ea08f03319a94b155db782232dee4b3e03513c4a4c10cc3d81d129fc3284136990b51d5dcf766192abc64d71e1d258ca7e0eb4e6592343fea6a4
+ languageName: node
+ linkType: hard
+
"react-reconciler@npm:^0.31.0":
version: 0.31.0
resolution: "react-reconciler@npm:0.31.0"
@@ -19803,7 +20019,7 @@ __metadata:
languageName: node
linkType: hard
-"readable-stream@npm:^3.0.2, readable-stream@npm:^3.4.0":
+"readable-stream@npm:^3.0.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
@@ -20911,6 +21127,24 @@ __metadata:
languageName: node
linkType: hard
+"simple-concat@npm:^1.0.0":
+ version: 1.0.1
+ resolution: "simple-concat@npm:1.0.1"
+ checksum: 10c0/62f7508e674414008910b5397c1811941d457dfa0db4fd5aa7fa0409eb02c3609608dfcd7508cace75b3a0bf67a2a77990711e32cd213d2c76f4fd12ee86d776
+ languageName: node
+ linkType: hard
+
+"simple-get@npm:^4.0.0":
+ version: 4.0.1
+ resolution: "simple-get@npm:4.0.1"
+ dependencies:
+ decompress-response: "npm:^6.0.0"
+ once: "npm:^1.3.1"
+ simple-concat: "npm:^1.0.0"
+ checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0
+ languageName: node
+ linkType: hard
+
"simple-swizzle@npm:^0.2.2":
version: 0.2.2
resolution: "simple-swizzle@npm:0.2.2"
@@ -21415,6 +21649,13 @@ __metadata:
languageName: node
linkType: hard
+"strip-json-comments@npm:~2.0.1":
+ version: 2.0.1
+ resolution: "strip-json-comments@npm:2.0.1"
+ checksum: 10c0/b509231cbdee45064ff4f9fd73609e2bcc4e84a4d508e9dd0f31f70356473fde18abfb5838c17d56fb236f5a06b102ef115438de0600b749e818a35fbbc48c43
+ languageName: node
+ linkType: hard
+
"strip-literal@npm:^3.0.0":
version: 3.0.0
resolution: "strip-literal@npm:3.0.0"
@@ -21608,6 +21849,18 @@ __metadata:
languageName: node
linkType: hard
+"tar-fs@npm:^2.0.0":
+ version: 2.1.4
+ resolution: "tar-fs@npm:2.1.4"
+ dependencies:
+ chownr: "npm:^1.1.1"
+ mkdirp-classic: "npm:^0.5.2"
+ pump: "npm:^3.0.0"
+ tar-stream: "npm:^2.1.4"
+ checksum: 10c0/decb25acdc6839182c06ec83cba6136205bda1db984e120c8ffd0d80182bc5baa1d916f9b6c5c663ea3f9975b4dd49e3c6bb7b1707cbcdaba4e76042f43ec84c
+ languageName: node
+ linkType: hard
+
"tar-fs@npm:^3.0.8":
version: 3.1.0
resolution: "tar-fs@npm:3.1.0"
@@ -21625,6 +21878,19 @@ __metadata:
languageName: node
linkType: hard
+"tar-stream@npm:^2.1.4":
+ version: 2.2.0
+ resolution: "tar-stream@npm:2.2.0"
+ dependencies:
+ bl: "npm:^4.0.3"
+ end-of-stream: "npm:^1.4.1"
+ fs-constants: "npm:^1.0.0"
+ inherits: "npm:^2.0.3"
+ readable-stream: "npm:^3.1.1"
+ checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692
+ languageName: node
+ linkType: hard
+
"tar-stream@npm:^3.1.5":
version: 3.1.7
resolution: "tar-stream@npm:3.1.7"
@@ -21773,7 +22039,7 @@ __metadata:
languageName: node
linkType: hard
-"tiny-invariant@npm:*, tiny-invariant@npm:^1.3.1, tiny-invariant@npm:^1.3.3":
+"tiny-invariant@npm:*, tiny-invariant@npm:^1.0.0, tiny-invariant@npm:^1.3.1, tiny-invariant@npm:^1.3.3":
version: 1.3.3
resolution: "tiny-invariant@npm:1.3.3"
checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a
@@ -22208,6 +22474,15 @@ __metadata:
languageName: node
linkType: hard
+"tunnel-agent@npm:^0.6.0":
+ version: 0.6.0
+ resolution: "tunnel-agent@npm:0.6.0"
+ dependencies:
+ safe-buffer: "npm:^5.0.1"
+ checksum: 10c0/4c7a1b813e7beae66fdbf567a65ec6d46313643753d0beefb3c7973d66fcec3a1e7f39759f0a0b4465883499c6dc8b0750ab8b287399af2e583823e40410a17a
+ languageName: node
+ linkType: hard
+
"tunnel-rat@npm:^0.1.2":
version: 0.1.2
resolution: "tunnel-rat@npm:0.1.2"
@@ -23144,6 +23419,15 @@ __metadata:
languageName: node
linkType: hard
+"warning@npm:^4.0.0":
+ version: 4.0.3
+ resolution: "warning@npm:4.0.3"
+ dependencies:
+ loose-envify: "npm:^1.0.0"
+ checksum: 10c0/aebab445129f3e104c271f1637fa38e55eb25f968593e3825bd2f7a12bd58dc3738bb70dc8ec85826621d80b4acfed5a29ebc9da17397c6125864d72301b937e
+ languageName: node
+ linkType: hard
+
"watchpack@npm:^2.4.1":
version: 2.4.4
resolution: "watchpack@npm:2.4.4"