diff --git a/frontend/package.json b/frontend/package.json index f60681e2fd5bd1861ba38f2fda2689266debfaba..09643bb51f941f2d97e9afb780cdb74692b5ac0d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-progress": "^1.1.0", @@ -30,8 +31,9 @@ "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@tanstack/react-router": "^1.16.5", + "@tanstack/react-router": "^1.62.0", "@tanstack/react-table": "^8.20.5", + "@tanstack/router-zod-adapter": "^1.62.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3187db4342b075afc7acd2183c83ef7524fb964d..c036b207e8983a65926062d696e2e6c45b3f0b70 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@radix-ui/react-checkbox': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.0 version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) @@ -57,11 +60,14 @@ dependencies: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) '@tanstack/react-router': - specifier: ^1.16.5 - version: 1.35.3(react-dom@18.3.1)(react@18.3.1) + specifier: ^1.62.0 + version: 1.62.0(react-dom@18.3.1)(react@18.3.1) '@tanstack/react-table': specifier: ^8.20.5 version: 8.20.5(react-dom@18.3.1)(react@18.3.1) + '@tanstack/router-zod-adapter': + specifier: ^1.62.0 + version: 1.62.0(@tanstack/react-router@1.62.0)(zod@3.23.8) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -138,7 +144,7 @@ dependencies: devDependencies: '@tanstack/router-devtools': specifier: ^1.16.5 - version: 1.35.3(@tanstack/react-router@1.35.3)(csstype@3.1.3)(react-dom@18.3.1)(react@18.3.1) + version: 1.35.3(@tanstack/react-router@1.62.0)(csstype@3.1.3)(react-dom@18.3.1)(react@18.3.1) '@tanstack/router-vite-plugin': specifier: ^1.16.5 version: 1.34.8(vite@5.4.8) @@ -1928,12 +1934,6 @@ packages: resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} dev: false - /@radix-ui/primitive@1.0.1: - resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} - dependencies: - '@babel/runtime': 7.24.7 - dev: false - /@radix-ui/primitive@1.1.0: resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} dev: false @@ -2063,20 +2063,6 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} peerDependencies: @@ -2090,20 +2076,6 @@ packages: react: 18.3.1 dev: false - /@radix-ui/react-context@1.0.1(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-context@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} peerDependencies: @@ -2130,38 +2102,37 @@ packages: react: 18.3.1 dev: false - /@radix-ui/react-dialog@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} + /@radix-ui/react-dialog@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) '@types/react': 18.3.3 '@types/react-dom': 18.3.0 aria-hidden: 1.2.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.5.5(@types/react@18.3.3)(react@18.3.1) + react-remove-scroll: 2.6.0(@types/react@18.3.3)(react@18.3.1) dev: false /@radix-ui/react-direction@1.1.0(@types/react@18.3.3)(react@18.3.1): @@ -2177,33 +2148,32 @@ packages: react: 18.3.1 dev: false - /@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} + /@radix-ui/react-dismissable-layer@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.3)(react@18.3.1) '@types/react': 18.3.3 '@types/react-dom': 18.3.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-dismissable-layer@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==} + /@radix-ui/react-dismissable-layer@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2226,20 +2196,6 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-focus-guards@1.0.1(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-focus-guards@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==} peerDependencies: @@ -2253,27 +2209,17 @@ packages: react: 18.3.1 dev: false - /@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==} + /@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} peerDependencies: '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true - '@types/react-dom': - optional: true dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1) '@types/react': 18.3.3 - '@types/react-dom': 18.3.0 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) dev: false /@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): @@ -2298,21 +2244,6 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-id@1.0.1(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-id@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} peerDependencies: @@ -2410,27 +2341,6 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-portal@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) - '@types/react': 18.3.3 - '@types/react-dom': 18.3.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - dev: false - /@radix-ui/react-portal@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==} peerDependencies: @@ -2452,22 +2362,21 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} + /@radix-ui/react-portal@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) '@types/react': 18.3.3 '@types/react-dom': 18.3.0 react: 18.3.1 @@ -2516,27 +2425,6 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.3)(react@18.3.1) - '@types/react': 18.3.3 - '@types/react-dom': 18.3.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - dev: false - /@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} peerDependencies: @@ -2723,21 +2611,6 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-slot@1.0.2(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-slot@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -2858,20 +2731,6 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -2885,21 +2744,6 @@ packages: react: 18.3.1 dev: false - /@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} peerDependencies: @@ -2914,21 +2758,6 @@ packages: react: 18.3.1 dev: false - /@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1) - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} peerDependencies: @@ -2943,20 +2772,6 @@ packages: react: 18.3.1 dev: false - /@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.24.7 - '@types/react': 18.3.3 - react: 18.3.1 - dev: false - /@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.3)(react@18.3.1): resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} peerDependencies: @@ -3582,31 +3397,35 @@ packages: '@swc/counter': 0.1.3 dev: true - /@tanstack/history@1.31.16: - resolution: {integrity: sha512-rahAZXlR879P7dngDH7BZwGYiODA9D5Hqo6nUHn9GAURcqZU5IW0ZiT54dPtV5EPES7muZZmknReYueDHs7FFQ==} + /@tanstack/history@1.61.1: + resolution: {integrity: sha512-2CqERleeqO3hkhJmyJm37tiL3LYgeOpmo8szqdjgtnnG0z7ZpvzkZz6HkfOr9Ca/ha7mhAiouSvLYuLkM37AMg==} engines: {node: '>=12'} - /@tanstack/react-router@1.35.3(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-9B4BAK/WzAa+xsc5LVkDgOcIIFQlfZv7xykg7VWW88PpiY0dQku4IxkUNY5vtQ4YkdJ9Z0QIt33tVnvVIFS4OQ==} + /@tanstack/react-router@1.62.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Vry/GwXiIHHpFilXy3M82Oyh1O1ULJNHVv8XbZ2QtcvftxkXcotsWD1Rt3KhgvXjBA8Zb/ueYN9ASPtkTdMoqQ==} engines: {node: '>=12'} peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' + '@tanstack/router-generator': 1.58.12 + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + '@tanstack/router-generator': + optional: true dependencies: - '@tanstack/history': 1.31.16 - '@tanstack/react-store': 0.2.1(react-dom@18.3.1)(react@18.3.1) + '@tanstack/history': 1.61.1 + '@tanstack/react-store': 0.5.5(react-dom@18.3.1)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - /@tanstack/react-store@0.2.1(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-tEbMCQjbeVw9KOP/202LfqZMSNAVi6zYkkp1kBom8nFuMx/965Hzes3+6G6b/comCwVxoJU8Gg9IrcF8yRPthw==} + /@tanstack/react-store@0.5.5(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-1orYXGatBqXCYKuroFwV8Ll/6aDa5E3pU6RR4h7RvRk7TmxF1+zLCsWALZaeijXkySNMGmvawSbUXRypivg2XA==} peerDependencies: - react: '>=16' - react-dom: '>=16' + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 dependencies: - '@tanstack/store': 0.1.3 + '@tanstack/store': 0.5.5 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.2.2(react@18.3.1) @@ -3634,7 +3453,7 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@tanstack/router-devtools@1.35.3(@tanstack/react-router@1.35.3)(csstype@3.1.3)(react-dom@18.3.1)(react@18.3.1): + /@tanstack/router-devtools@1.35.3(@tanstack/react-router@1.62.0)(csstype@3.1.3)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-mLG3JJJTc16gu2dKFJagAh9weyraoewVJG4myVb3dde/DdJ6XPuQbJLXv3s5YbPykNtLFmHJXWwNTjndyiaCWQ==} engines: {node: '>=12'} peerDependencies: @@ -3642,7 +3461,7 @@ packages: react: '>=16.8' react-dom: '>=16.8' dependencies: - '@tanstack/react-router': 1.35.3(react-dom@18.3.1)(react@18.3.1) + '@tanstack/react-router': 1.62.0(react-dom@18.3.1)(react@18.3.1) clsx: 2.1.1 date-fns: 2.30.0 goober: 2.1.14(csstype@3.1.3) @@ -3685,8 +3504,19 @@ packages: - vite dev: true - /@tanstack/store@0.1.3: - resolution: {integrity: sha512-GnolmC8Fr4mvsHE1fGQmR3Nm0eBO3KnZjDU0a+P3TeQNM/dDscFGxtA7p31NplQNW3KwBw4t1RVFmz0VeKLxcw==} + /@tanstack/router-zod-adapter@1.62.0(@tanstack/react-router@1.62.0)(zod@3.23.8): + resolution: {integrity: sha512-eJHaUYTV3jrcUGGvtywSCRhHsrQertWnMfp8jslAFluvINfX2zWbJ+F9NRQDrMC5GkYYgv4Qze1g/d88u096rg==} + engines: {node: '>=12'} + peerDependencies: + '@tanstack/react-router': '>=1.43.2' + zod: '>=3' + dependencies: + '@tanstack/react-router': 1.62.0(react-dom@18.3.1)(react@18.3.1) + zod: 3.23.8 + dev: false + + /@tanstack/store@0.5.5: + resolution: {integrity: sha512-EOSrgdDAJExbvRZEQ/Xhh9iZchXpMN+ga1Bnk8Nmygzs8TfiE6hbzThF+Pr2G19uHL6+DTDTHhJ8VQiOd7l4tA==} /@tanstack/table-core@8.20.5: resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} @@ -6421,8 +6251,8 @@ packages: tslib: 2.6.3 dev: false - /react-remove-scroll@2.5.5(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} + /react-remove-scroll@2.5.7(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==} engines: {node: '>=10'} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -6440,8 +6270,8 @@ packages: use-sidecar: 1.1.2(@types/react@18.3.3)(react@18.3.1) dev: false - /react-remove-scroll@2.5.7(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==} + /react-remove-scroll@2.6.0(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} engines: {node: '>=10'} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -7323,7 +7153,7 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 dependencies: - '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: diff --git a/frontend/src/api/endpoints/candidates.endpoint.ts b/frontend/src/api/endpoints/candidates.endpoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..27d1b1af5a3e94bb8d748c4deffa00b521cb5c45 --- /dev/null +++ b/frontend/src/api/endpoints/candidates.endpoint.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { CandidatesDto } from "../models/candidates.model"; +import api from "../utils/api"; + +export namespace CandidatesEndpoint { + export const getActiveCandidates = (vacancyId: number) => + api.get(`/vacancies/candidates/active/${vacancyId}`, { + schema: z.object({ + candidates: z.array(CandidatesDto.ActiveCandidate), + }), + }); + + export const getDeclinedCandidates = (vacancyId: number) => + api.get(`/vacancies/candidates/declined/${vacancyId}`, { + schema: z.object({ + candidates: z.array(CandidatesDto.DeclinedCandidate), + }), + }); + + export const getPotentialCandidates = (vacancyId: number) => + api.get(`/vacancies/candidates/potential/${vacancyId}`, { + schema: z.object({ + candidates: z.array(CandidatesDto.PotentialCandidate), + }), + }); +} diff --git a/frontend/src/api/endpoints/skill.endpoint.ts b/frontend/src/api/endpoints/skill.endpoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ffbb28ebded5230eed56a13103f3b60e0bcd6db --- /dev/null +++ b/frontend/src/api/endpoints/skill.endpoint.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { SkillDto } from "../models/skill.model"; +import api from "../utils/api"; + +export namespace SkillEndpoint { + export const list = () => { + return api.get("/vacancies/skills/all", { + schema: z.array(SkillDto.Item), + }); + }; + + export const create = (names: string[]) => { + return api.post( + "/vacancies/skills/new", + names.map((name) => ({ name })), + { + schema: z.array(SkillDto.Item), + }, + ); + }; +} diff --git a/frontend/src/api/endpoints/tasks.endpoint.ts b/frontend/src/api/endpoints/tasks.endpoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..652d68d361fee809ad384879c726f9bc3b210393 --- /dev/null +++ b/frontend/src/api/endpoints/tasks.endpoint.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { SkillDto } from "../models/skill.model"; +import api from "../utils/api"; +import { TaskDto } from "../models/task.model"; + +export namespace TasksEndpoint { + export const list = () => { + return api.get("/tasks/all", { + schema: z.array(TaskDto.Item), + }); + }; +} diff --git a/frontend/src/api/endpoints/vacanvy.endpoint.ts b/frontend/src/api/endpoints/vacanvy.endpoint.ts index 69c00dd98e1ed38a9972d505d0c3859d02279935..e18948a9de279d145208c75411885e52d5e6213a 100644 --- a/frontend/src/api/endpoints/vacanvy.endpoint.ts +++ b/frontend/src/api/endpoints/vacanvy.endpoint.ts @@ -2,6 +2,8 @@ import api from "api/utils/api"; import { Query } from "../utils/buildQueryString"; import { VacancyDto } from "../models/vacancy.model"; import { paged } from "../models/paged.model"; +import { z } from "zod"; +import { Priority } from "@/types/priority.type"; export namespace VacancyEndpoint { export interface ListTemplate extends Query { @@ -19,7 +21,37 @@ export namespace VacancyEndpoint { }); export const getById = (id: string) => - api.get(`/vacancies/${id}`, { + api.get(`/vacancies/roadmap/${id}`, { schema: VacancyDto.DetailedItem, }); + + interface VacancyTemplate { + name: string; + priority: Priority.Priority; + deadline: string; + profession: string; + area: string; + supervisor: string; + city: string; + experienceFrom: string; + experienceTo: string; + education: string; + keySkills: number[]; + additionalSkills: number[]; + description: string; + typeOfEmployment: string; + quantity: number; + direction: string; + salary_low: number; + salary_high: number; + stages: { + order: number; + name: string; + duration: number; + }[]; + } + export const create = (vacancy: VacancyTemplate) => + api.post("/vacancies/new", vacancy, { + schema: z.number(), + }); } diff --git a/frontend/src/api/models/candidates.model.ts b/frontend/src/api/models/candidates.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..04a523aab1630f44970ad842f4b08308691a9203 --- /dev/null +++ b/frontend/src/api/models/candidates.model.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { VacancyDto } from "./vacancy.model"; + +export namespace CandidatesDto { + export const Candidate = z.object({ + candidate_id: z.number(), + source: z.string(), + similarity: z.number(), + }); + export type Candidate = z.infer<typeof Candidate>; + + export const ActiveCandidate = Candidate.extend({ + stage_name: z.string(), + date_of_accept: z.string(), + }); + export type ActiveCandidate = z.infer<typeof ActiveCandidate>; + + export const DeclinedCandidate = Candidate.extend({ + date_of_decline: z.string(), + reason: z.string(), + }); + export type DeclinedCandidate = z.infer<typeof DeclinedCandidate>; + + export const PotentialCandidate = z.object({ + source: z.string(), + similarity: z.number(), + }); + export type PotentialCandidate = z.infer<typeof PotentialCandidate>; +} diff --git a/frontend/src/api/models/skill.model.ts b/frontend/src/api/models/skill.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..a13974ad8fdf83a8137d6701559660e8ace2b4f2 --- /dev/null +++ b/frontend/src/api/models/skill.model.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export namespace SkillDto { + export const Item = z.object({ + name: z.string(), + id: z.number(), + }); + export type Item = z.infer<typeof Item>; +} diff --git a/frontend/src/api/models/task.model.ts b/frontend/src/api/models/task.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac4d791fd8e1d0af611157419cc814f020b0fc95 --- /dev/null +++ b/frontend/src/api/models/task.model.ts @@ -0,0 +1,5 @@ +import { z } from "node_modules/zod/lib"; + +export namespace TaskDto { + export const Item = z.object({}); +} diff --git a/frontend/src/api/models/vacancy.model.ts b/frontend/src/api/models/vacancy.model.ts index 20109e201cb4c54bb27b355d48a70d9e8fcb0f91..a378aaa1ce8133f9fa67e9441e8f4613ccba5672 100644 --- a/frontend/src/api/models/vacancy.model.ts +++ b/frontend/src/api/models/vacancy.model.ts @@ -1,28 +1,8 @@ import { z } from "zod"; import { Priority } from "@/types/priority.type"; +import { SkillDto } from "./skill.model"; export namespace VacancyDto { - const DetailedVacancySchema = z.object({ - id: z.number(), - name: z.string(), - priority: Priority.Schema, - deadline: z.string().datetime(), - profession: z.string(), - area: z.string(), - supervisor: z.string(), - city: z.string(), - experience_from: z.number(), - experience_to: z.number(), - education: z.string(), - quantity: z.number(), - description: z.string(), - type_of_employment: z.string(), - vacancy_skills: z.array(z.any()), - recruiter: z.null(), - hr: z.null(), - created_at: z.string().datetime(), - }); - const SourceSchema = z.object({ name: z.string(), count: z.number(), @@ -60,17 +40,16 @@ export namespace VacancyDto { quantity: z.number(), description: z.string(), type_of_employment: z.string(), - vacancy_skills: z.array( - z.object({ - name: z.string(), - id: z.number(), - }), - ), + vacancy_skills: z.array(SkillDto.Item), + // additional_skills: z.array(z.any()), + recruiter: z.null(), + hr: z.null(), + created_at: z.string(), }); export type Item = z.infer<typeof Item>; export const DetailedItem = z.object({ - vacancy: DetailedVacancySchema, + vacancy: Item, stages: z.array(StageSchema), }); export type DetailedItem = z.infer<typeof DetailedItem>; @@ -92,7 +71,16 @@ export const mockVacancy: VacancyDto.DetailedItem = { quantity: 1, description: "Разработчик", type_of_employment: "ÐŸÐ¾Ð»Ð½Ð°Ñ Ð·Ð°Ð½ÑтоÑть", - vacancy_skills: [], + vacancy_skills: [ + { + id: 1, + name: "JavaScript", + }, + { + id: 2, + name: "TypeScript", + }, + ], recruiter: null, hr: null, created_at: "2021-01-01T00:00:00", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 51f0d2d097c3c542d3c487f306d5c7d17b0f37fe..0097dd35ed0c11df97c96a291c551e22b64369f6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -66,6 +66,15 @@ #app { @apply bg-gray-50 text-gray-900 transition-all duration-150 overflow-hidden flex antialiased; } + *::-webkit-scrollbar { + @apply w-1 h-1; + } + *::-webkit-scrollbar-track { + @apply bg-gray-100; + } + *::-webkit-scrollbar-thumb { + @apply bg-gray-300 rounded-full; + } } html, diff --git a/frontend/src/components/DropdownMultiple.tsx b/frontend/src/components/DropdownMultiple.tsx index 4a3594a613b354165d9ddeb0c46028f16c074618..c7d653761c0f9b57d51bce3f5d32472761a20065 100644 --- a/frontend/src/components/DropdownMultiple.tsx +++ b/frontend/src/components/DropdownMultiple.tsx @@ -67,12 +67,12 @@ const DropdownMultiple = observer(<T,>(p: ComboboxMultipleProps<T>) => { return ( <Combobox value={p.value} multiple onChange={p.onChange}> - <div className="relative text-sm"> - {p.label && <Label className="text-sm">{p.label}</Label>} + <div className="relative text-sm pt-0.5"> + {p.label && <Label className="font-medium">{p.label}</Label>} <div className={cn( "relative h-fit flex items-center w-full", - p.label && "mt-2", + p.label && "mt-1", )} > <ComboboxInput @@ -92,8 +92,9 @@ const DropdownMultiple = observer(<T,>(p: ComboboxMultipleProps<T>) => { displayValue={() => (inputFocused ? "" : placeholder)} onChange={(event) => setQuery(event.target.value)} /> - <ComboboxButton className="h-5 w-5 absolute right-2 text-accent-foreground"> + <ComboboxButton className="h-5 w-5 items-center justify-center flex absolute right-2 text-accent-foreground"> <ChevronDownIcon + strokeWidth={1.5} className={cn("transition-all", inputFocused && "rotate-180")} /> </ComboboxButton> @@ -107,12 +108,12 @@ const DropdownMultiple = observer(<T,>(p: ComboboxMultipleProps<T>) => { afterLeave={() => setQuery("")} > <ComboboxOptions - className="absolute z-10 mt-1 max-h-60 w-full border overflow-auto rounded-xl py-2 bg-card text-card-foreground" + className="absolute z-10 mt-1 max-h-60 w-full border overflow-auto rounded-lg py-2 bg-card text-card-foreground" style={{ scrollbarWidth: "thin", }} > - {filteredOptions.length === 0 && query !== "" ? ( + {filteredOptions.length === 0 ? ( <div className="px-4 py-2 text-muted-foreground"> Ðичего не найдено </div> @@ -131,7 +132,7 @@ const DropdownMultiple = observer(<T,>(p: ComboboxMultipleProps<T>) => { {({ selected }) => ( <> <span>{p.render(option)}</span> - {selected && <CheckIcon className="w-5 h-5" />} + {selected && <CheckIcon className="size-3" />} </> )} </ComboboxOption> diff --git a/frontend/src/components/dropdowns/SkillsDropdown.tsx b/frontend/src/components/dropdowns/SkillsDropdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..407159113f88a9615bb31c2ffd9ec77a7a49c607 --- /dev/null +++ b/frontend/src/components/dropdowns/SkillsDropdown.tsx @@ -0,0 +1,45 @@ +import { SkillEndpoint } from "@/api/endpoints/skill.endpoint"; +import { observable } from "mobx"; +import { observer } from "mobx-react-lite"; +import { FC, useEffect } from "react"; +import { SkillDto } from "@/api/models/skill.model"; +import DropdownMultiple from "../DropdownMultiple"; + +interface Props { + value: SkillDto.Item[]; + onChange: (value: SkillDto.Item[]) => void; + label?: string; + filter?: (value: SkillDto.Item) => boolean; +} + +export const skillsStore = observable<{ skills: SkillDto.Item[] }>({ + skills: [], +}); + +export const SkillsDropdown: FC<Props> = observer((x) => { + useEffect(() => { + const fetchSkills = async () => { + skillsStore.skills = await SkillEndpoint.list(); + }; + + if (skillsStore.skills.length === 0) { + fetchSkills(); + } + }, []); + + const skills = x.filter + ? skillsStore.skills.filter(x.filter) + : skillsStore.skills; + + return ( + <DropdownMultiple + label={x.label} + value={x.value} + onChange={x.onChange} + options={skills} + compare={(a) => a.name} + render={(item) => item.name} + placeholder="Выберите навыки" + /> + ); +}); diff --git a/frontend/src/components/pages/tasks/reject-candidate.tsx b/frontend/src/components/pages/tasks/reject-candidate.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e12070c27f7c726161feb030ce136a425be305f5 --- /dev/null +++ b/frontend/src/components/pages/tasks/reject-candidate.tsx @@ -0,0 +1,55 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { observer } from "mobx-react-lite"; +import { FC, useState } from "react"; + +interface Props { + onReject: (reason: string) => void; +} + +export const RejectCandidate: FC<Props> = observer((x) => { + const [reason, setReason] = useState(""); + + return ( + <Dialog> + <DialogTrigger className="text-red-500 underline hover:no-underline"> + Отказ + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Отказ</DialogTitle> + <DialogDescription>Укажите причину отказа</DialogDescription> + </DialogHeader> + <Textarea + placeholder="ОтказалÑÑ Ð¾Ñ‚ предложениÑ" + value={reason} + onChange={(e) => setReason(e.target.value)} + /> + <DialogFooter> + <DialogClose asChild> + <Button + variant="destructive" + disabled={!reason} + onClick={() => { + x.onReject(reason); + setReason(""); + }} + > + Отказать + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}); diff --git a/frontend/src/components/pages/vacancy/StagesForm.tsx b/frontend/src/components/pages/vacancy/StagesForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..692e3a0588b2121cef8f03e07df262f244a1f5bb --- /dev/null +++ b/frontend/src/components/pages/vacancy/StagesForm.tsx @@ -0,0 +1,64 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NewVacancyStore } from "@/stores/new-vacancy"; +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { observer } from "mobx-react-lite"; +import { FC } from "react"; + +interface Props { + vm: NewVacancyStore; +} + +export const StagesForm: FC<Props> = observer((x) => { + return ( + <div className="col-span-2 w-full space-y-3"> + <h3 className="text-xl font-medium">Ðтапы отбора</h3> + <div className="flex w-full *:flex-1 gap-4"> + <div className="flex gap-2 flex-col"> + <Label>Ðазвание Ñтапа</Label> + {x.vm.stages.map((v) => ( + <div key={v.id}> + <Input + autoFocus + className="bg-primary text-primary-foreground border-none font-medium" + value={v.name} + onChange={(e) => { + v.name = e.target.value; + }} + /> + </div> + ))} + <Button variant="secondary" size="sm" onClick={() => x.vm.addStage()}> + <PlusIcon /> + </Button> + </div> + <div className="flex gap-2 flex-col"> + <Label>SLA Ñтапа в днÑÑ…</Label> + {x.vm.stages.map((v) => ( + <div key={v.id} className="flex items-center gap-2"> + <Input + className="w-[100px]" + value={v.sla} + type="number" + onChange={(e) => { + const number = Number(e.target.value); + if (!isNaN(number) && number >= 0) { + v.sla = number; + } + }} + /> + <Button + size="icon" + variant="ghost" + onClick={() => x.vm.removeStage(v)} + > + <Trash2Icon className="size-5" /> + </Button> + </div> + ))} + </div> + </div> + </div> + ); +}); diff --git a/frontend/src/components/pages/vacancy/add-candidate.modal.tsx b/frontend/src/components/pages/vacancy/add-candidate.modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da8a97444ec00b23e23f8f17f10be821d514b837 --- /dev/null +++ b/frontend/src/components/pages/vacancy/add-candidate.modal.tsx @@ -0,0 +1,55 @@ +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { PlusIcon } from "lucide-react"; +import { observer } from "mobx-react-lite"; +import { FC } from "react"; +import { IconInput } from "@/components/ui/input"; + +interface Props { + onSubmit: (candidate: { source: string; resumeLink: string }) => void; +} + +export const AddCandidateModal: FC<Props> = observer((x) => { + return ( + <Dialog> + <DialogTrigger + className={buttonVariants({ variant: "outline", size: "sm" })} + > + <> + <PlusIcon className="size-4" /> + Добавить кандидата + </> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Добавить кандидата</DialogTitle> + <DialogDescription>Укажите данные кандидата</DialogDescription> + </DialogHeader> + <IconInput + id="source" + label="ИÑточник" + placeholder="Ðапример, LinkedIn" + /> + <IconInput + id="resumeLink" + label="СÑылка на резюме" + placeholder="Ðапример, https://linkedin.com/in/username" + /> + <DialogFooter> + <DialogClose asChild> + <Button>Добавить</Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}); diff --git a/frontend/src/components/pages/vacancy/analytics.view.tsx b/frontend/src/components/pages/vacancy/analytics.view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b108301867134567885e7910241e685192adedb6 --- /dev/null +++ b/frontend/src/components/pages/vacancy/analytics.view.tsx @@ -0,0 +1,31 @@ +import { VacancyStore } from "@/stores/vanacy.store"; +import { observer } from "mobx-react-lite"; +import { FC } from "react"; + +interface Props { + vm: VacancyStore; +} + +const Card: FC<{ + title: string; + value: string; +}> = observer((x) => { + return ( + <div className="font-medium rounded-xl p-4 bg-white flex flex-col gap-2"> + <h3 className="text-xl">{x.value}</h3> + <p className="text-nowrap">{x.title}</p> + </div> + ); +}); + +export const AnalyticsView: FC<Props> = observer((x) => { + return ( + <div className="flex flex-wrap gap-4"> + <Card title="ÐаполненноÑть рынка" value="чел. на ваканÑию" /> + <Card title="Диапазон ожиданий по ЗП" value="МЛР– МЛÐ" /> + <Card title="Медиальное значение по ожидаемой ЗП" value="МЛÐ" /> + <Card title="Диапазон по ЗП у конкурентов" value="МЛР– МЛÐ" /> + <Card title="Медиальное значение по ЗП у конкурентов" value="МЛР– МЛÐ" /> + </div> + ); +}); diff --git a/frontend/src/components/pages/vacancy/candidates.view.tsx b/frontend/src/components/pages/vacancy/candidates.view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb5e1f21450204077cc41e672ffe016d52d4d813 --- /dev/null +++ b/frontend/src/components/pages/vacancy/candidates.view.tsx @@ -0,0 +1,123 @@ +import { VacancyStore } from "@/stores/vanacy.store"; +import { FCVM } from "@/utils/vm"; +import { observer } from "mobx-react-lite"; +import { useEffect } from "react"; +import { ChartSection } from "./chart-section"; +import { Column, DataTable } from "@/components/ui/data-table"; +import { CandidatesDto } from "@/api/models/candidates.model"; +import { AddCandidateModal } from "./add-candidate.modal"; + +const activeColumns: Column<CandidatesDto.ActiveCandidate>[] = [ + { + header: "â„–", + accessor: (x) => x.candidate_id, + }, + { + header: "Дата отклика", + accessor: (x) => new Date(x.date_of_accept).toLocaleDateString("ru-RU"), + }, + { + header: "СтатуÑ", + accessor: (x) => x.stage_name, + }, + { + header: "ИÑточник", + accessor: (x) => x.source, + }, + { + header: "СхожеÑть\nÑ Ð²Ð°ÐºÐ°Ð½Ñией", + accessor: (x) => `${x.similarity}%`, + }, + { + header: "Резюме", + accessor: (x) => ( + <a + href={"https://google.com"} + target="_blank" + rel="noreferrer" + className="text-blue-500 underline" + > + Файл + </a> + ), + }, +]; + +const declinedColumns: Column<CandidatesDto.DeclinedCandidate>[] = [ + { + header: "â„–", + accessor: (x) => x.candidate_id, + }, + { + header: "Дата отказа", + accessor: (x) => new Date(x.date_of_decline).toLocaleDateString("ru-RU"), + }, + { + header: "Причина", + accessor: (x) => x.reason, + }, + { + header: "ИÑточник", + accessor: (x) => x.source, + }, + { + header: "СхожеÑть\nÑ Ð²Ð°ÐºÐ°Ð½Ñией", + accessor: (x) => `${x.similarity}%`, + }, + { + header: "Резюме", + accessor: (x) => ( + <a + href={"https://google.com"} + target="_blank" + rel="noreferrer" + className="text-blue-500 underline" + > + Файл + </a> + ), + }, +]; + +const potentialColumns: Column<CandidatesDto.PotentialCandidate>[] = [ + { + header: "ИÑточник", + accessor: (x) => x.source, + }, + { + header: "% ÑхожеÑти Ñ Ð²Ð°ÐºÐ°Ð½Ñией", + accessor: (x) => `${x.similarity}%`, + }, +]; + +export const CandidatesView: FCVM<VacancyStore> = observer((x) => { + useEffect(() => { + x.vm.loadCandidates(); + }, [x.vm]); + + return ( + <div className="flex flex-col gap-4"> + <ChartSection + title="Кандидаты по ваканÑии" + actions={<AddCandidateModal onSubmit={(v) => void 0} />} + > + {x.vm.activeCandidates && ( + <DataTable data={x.vm.activeCandidates} columns={activeColumns} /> + )} + </ChartSection> + <ChartSection title="Кандидаты Ñ Ð¾Ñ‚ÐºÐ°Ð·Ð°Ð¼Ð¸"> + {x.vm.declinedCandidates && ( + <DataTable data={x.vm.declinedCandidates} columns={declinedColumns} /> + )} + </ChartSection> + <ChartSection title="Потенциальные кандидаты"> + {x.vm.potentialCandidates && ( + <DataTable + data={x.vm.potentialCandidates} + columns={potentialColumns} + /> + )} + </ChartSection> + </div> + ); +}); diff --git a/frontend/src/components/pages/vacancy/chart-section.tsx b/frontend/src/components/pages/vacancy/chart-section.tsx index fb9de854272ff03aefd5d27e76822927053920c6..fc4e5fd3d04623c9e2fbc4d5625a2c129838ef18 100644 --- a/frontend/src/components/pages/vacancy/chart-section.tsx +++ b/frontend/src/components/pages/vacancy/chart-section.tsx @@ -1,19 +1,63 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/cn"; +import { ReactNode } from "@tanstack/react-router"; +import { ChevronDownIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; -import { FC, PropsWithChildren } from "react"; +import { FC, PropsWithChildren, useState } from "react"; interface Props extends PropsWithChildren { title: string; - description: string; + description?: string; + collapsible?: boolean; + actions?: ReactNode; + allowOverflow?: boolean; } -export const ChartSection: FC<Props> = observer((x) => { - return ( - <section className="bg-white p-5 rounded-2xl border overflow-hidden w-full"> - <h2 className="text-2xl font-medium text-slate-500">{x.title}</h2> - <p className="text-sm mt-2">{x.description}</p> - <div className="flex gap-8 xl:gap-32 mt-3 overflow-auto"> - {x.children} - </div> - </section> - ); -}); +export const ChartSection: FC<Props> = observer( + ({ collapsible = true, ...x }) => { + const [collapsed, setCollapsed] = useState(false); + + return ( + <section className="bg-white rounded-2xl border w-full py-5"> + <div className="flex justify-between px-5 pb-0 items-center gap-1"> + <h2 + className={cn( + "text-2xl font-medium", + collapsible && "text-slate-500", + )} + > + {x.title} + </h2> + <div className="flex items-center gap-1"> + {!collapsed && x.actions} + {collapsible && ( + <Button + variant="ghost" + size="icon" + className={cn( + "size-8 flex items-center justify-center", + !collapsed && "rotate-180", + )} + onClick={() => setCollapsed(!collapsed)} + > + <ChevronDownIcon className="siz-6" /> + </Button> + )} + </div> + </div> + {!collapsed && x.description && ( + <p className="text-sm mt-2 px-5">{x.description}</p> + )} + <div + className={cn( + "flex flex-col md:flex-row gap-8 xl:gap-32 mt-3 overflow-auto px-5", + collapsed && "hidden", + x.allowOverflow && "overflow-visible", + )} + > + {x.children} + </div> + </section> + ); + }, +); diff --git a/frontend/src/components/pages/vacancy/overview.view.tsx b/frontend/src/components/pages/vacancy/overview.view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d38425f7db6aab50ee47e454b6b623842b297b2 --- /dev/null +++ b/frontend/src/components/pages/vacancy/overview.view.tsx @@ -0,0 +1,90 @@ +import { VacancyStore } from "@/stores/vanacy.store"; +import { cn } from "@/utils/cn"; +import { observer } from "mobx-react-lite"; +import { FC } from "react"; + +interface Props { + vm: VacancyStore; +} + +const LabelValue: FC<{ label: string; value: string; row?: boolean }> = ({ + label, + value, + row = false, +}) => { + return ( + <div + className={cn( + "space-y-2 text-sm", + row ? "flex items-center gap-2 space-y-0" : "", + )} + > + <div className="font-semibold">{label}:</div> + <div>{value}</div> + </div> + ); +}; + +const LabelList: FC<{ label: string; values: string[] }> = ({ + label, + values, +}) => { + return ( + <div className="space-y-3 pt-2"> + <h2 className="text-xl font-medium">{label}</h2> + <ul className="flex flex-wrap gap-2"> + {values.map((x) => ( + <li + key={x} + className="bg-slate-200 rounded-md px-3 py-1 text-slate-500" + > + {x} + </li> + ))} + </ul> + </div> + ); +}; + +export const OverviewView: FC<Props> = observer((x) => { + return ( + <div className="space-y-4 pt-8"> + <div className="space-y-4 bg-white p-4 rounded-xl"> + <h2 className="text-xl font-medium">ОÑÐ½Ð¾Ð²Ð½Ð°Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ</h2> + <div className="flex gap-8 flex-wrap"> + <LabelValue + label="Руководитель" + value={x.vm.vacancy.vacancy.supervisor} + /> + <LabelValue label="Город" value={x.vm.vacancy.vacancy.city} /> + <LabelValue + label="Режим работы" + value={x.vm.vacancy.vacancy.type_of_employment} + /> + </div> + <LabelValue label="ОпиÑание" value={x.vm.vacancy.vacancy.description} /> + <LabelList + label="ИÑточники Ñ€Ð°Ð·Ð¼ÐµÑ‰ÐµÐ½Ð¸Ñ Ð²Ð°ÐºÐ°Ð½Ñии" + values={x.vm.vacancy.stages[0].sources.map((x) => x.name)} + /> + </div> + <div className="space-y-4 bg-white p-4 rounded-xl"> + <h2 className="text-xl font-medium">Ð¢Ñ€ÐµÐ±Ð¾Ð²Ð°Ð½Ð¸Ñ Ðº кандидату</h2> + <LabelValue + row + label="Опыт работы" + value={`${x.vm.vacancy.vacancy.experience_from} – ${x.vm.vacancy.vacancy.experience_to} лет`} + /> + <LabelValue + row + label="Образование" + value={x.vm.vacancy.vacancy.education} + /> + <LabelList + label="Ключевые навыки" + values={x.vm.vacancy.vacancy.vacancy_skills.map((x) => x.name)} + /> + </div> + </div> + ); +}); diff --git a/frontend/src/components/pages/vacancy/stats.view.tsx b/frontend/src/components/pages/vacancy/stats.view.tsx index 078a127cb7891143f579b65c6c879cb57c5ef7f6..2e90886de1aec2fd5f6c422c03c6c7d0bb0937f2 100644 --- a/frontend/src/components/pages/vacancy/stats.view.tsx +++ b/frontend/src/components/pages/vacancy/stats.view.tsx @@ -18,12 +18,12 @@ interface Props { const COLORS = [ "bg-teal-300", - "bg-indigo-300", "bg-blue-300", "bg-yellow-300", "bg-orange-300", "bg-green-300", "bg-red-300", + "bg-indigo-300", "bg-purple-300", "bg-pink-300", "bg-gray-300", diff --git a/frontend/src/components/sidebar/Sidebar.tsx b/frontend/src/components/sidebar/Sidebar.tsx index 51f09883b09b8cb367a3f8fe13e07088866e41a9..c7db00699e22877274f5bbd1ca773df7475f3bc0 100644 --- a/frontend/src/components/sidebar/Sidebar.tsx +++ b/frontend/src/components/sidebar/Sidebar.tsx @@ -1,7 +1,12 @@ import { AnimatePresence, motion } from "framer-motion"; import { observer } from "mobx-react-lite"; import { Logo } from "../ui/logo"; -import { Link, useMatches } from "@tanstack/react-router"; +import { + Link, + useLocation, + useMatches, + useNavigate, +} from "@tanstack/react-router"; import { RouteType } from "@/types/router.type"; import { BriefcaseIcon, @@ -12,7 +17,7 @@ import { } from "lucide-react"; import { cn } from "@/utils/cn"; import { AuthState } from "./AuthState"; -import { Button } from "../ui/button"; +import { Button, buttonVariants } from "../ui/button"; import { FC } from "react"; import { ScrollArea } from "../ui/scroll-area"; @@ -26,14 +31,18 @@ const items: { to: RouteType; label: string; icon: React.ElementType; + active?: (v: string) => boolean; + disabled?: boolean; }[] = [ { to: "/", label: "ВаканÑии", icon: BriefcaseIcon, + active: (pathname) => + pathname.includes("/vacancy") && !pathname.includes("/vacancy/new"), }, { - to: "/login", + to: "/tasks", label: "Мои задачи", icon: CheckSquareIcon, }, @@ -41,16 +50,21 @@ const items: { to: "/login", label: "Мои кандидаты", icon: UsersIcon, + disabled: true, }, { to: "/login", label: "КачеÑтво подбора", icon: StarIcon, + disabled: true, }, ]; export const Sidebar: FC<{ hideSidebar?: boolean }> = observer( ({ hideSidebar }) => { + const { pathname } = useLocation(); + const navigate = useNavigate(); + return ( <AnimatePresence mode="popLayout" initial={false}> {!hideSidebar && ( @@ -68,8 +82,11 @@ export const Sidebar: FC<{ hideSidebar?: boolean }> = observer( <li key={i}> <Link to={item.to} + disabled={item.disabled} className={cn( - "font-medium flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 rounded-md", + item.active?.(pathname) && "active", + "font-medium flex items-center gap-2 px-4 py-2 text-sm text-slate-700 rounded-md", + !item.disabled && "hover:bg-slate-100", "[&.active]:text-primary", )} > @@ -80,7 +97,13 @@ export const Sidebar: FC<{ hideSidebar?: boolean }> = observer( ))} </ul> <div className="mx-6 my-5"> - <Button className="w-full gap-1"> + <Button + onClick={() => { + navigate({ to: "/vacancy/new" }); + }} + className={"w-full gap-1"} + disabled={pathname.includes("/vacancy/new")} + > <PlusIcon className="size-5" /> Создать ваканÑию </Button> diff --git a/frontend/src/components/ui/auto-form/types.ts b/frontend/src/components/ui/auto-form/types.ts index b9774670240ce0f234be7dd4aa59c4e712652f65..09c57367a50c93a154a6bd84a9d7ed403091aec2 100644 --- a/frontend/src/components/ui/auto-form/types.ts +++ b/frontend/src/components/ui/auto-form/types.ts @@ -2,7 +2,7 @@ import { ControllerRenderProps, FieldValues } from "react-hook-form"; import * as z from "zod"; import { INPUT_COMPONENTS } from "./config"; -export type FieldConfigItem = { +export interface FieldConfigItem { description?: React.ReactNode; inputProps?: React.InputHTMLAttributes<HTMLInputElement> & { showLabel?: boolean; @@ -15,7 +15,7 @@ export type FieldConfigItem = { renderParent?: (props: { children: React.ReactNode; }) => React.ReactElement | null; -}; +} export type FieldConfig<SchemaType extends z.infer<z.ZodObject<any, any>>> = { // If SchemaType.key is an object, create a nested FieldConfig, otherwise FieldConfigItem @@ -31,12 +31,12 @@ export enum DependencyType { SETS_OPTIONS, } -type BaseDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> = { +interface BaseDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> { sourceField: keyof SchemaType; type: DependencyType; targetField: keyof SchemaType; when: (sourceFieldValue: any, targetFieldValue: any) => boolean; -}; +} export type ValueDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> = BaseDependency<SchemaType> & { @@ -64,7 +64,7 @@ export type Dependency<SchemaType extends z.infer<z.ZodObject<any, any>>> = /** * A FormInput component can handle a specific Zod type (e.g. "ZodBoolean") */ -export type AutoFormInputComponentProps = { +export interface AutoFormInputComponentProps { zodInputProps: React.InputHTMLAttributes<HTMLInputElement>; field: ControllerRenderProps<FieldValues, any>; fieldConfigItem: FieldConfigItem; @@ -73,4 +73,4 @@ export type AutoFormInputComponentProps = { fieldProps: any; zodItem: z.ZodAny; className?: string; -}; +} diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx index f92d0586e3ec6c24d648e530f5b09b4eedef858f..ecd1269340e717c7fe2566ae37a1880cbaec1919 100644 --- a/frontend/src/components/ui/data-table.tsx +++ b/frontend/src/components/ui/data-table.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/utils/cn"; import { Table, TableCaption, @@ -8,19 +9,21 @@ import { TableCell, TableFooter, } from "./table"; +import { ReactNode } from "@tanstack/react-router"; export interface Column<T> { - header: string; - accessor: (item: T) => React.ReactNode; + header: ReactNode; + accessor?: (item: T) => ReactNode; className?: string; } interface DataTableProps<T> { caption?: string; - data: T[]; + data: NoInfer<T>[]; columns: Column<T>[]; footer?: React.ReactNode; onRowClick?: (item: T) => void; + className?: string; } export const DataTable = <T,>({ @@ -29,14 +32,18 @@ export const DataTable = <T,>({ columns, footer, onRowClick, + className, }: DataTableProps<T>) => { return ( - <Table> + <Table className={className}> {caption && <TableCaption>{caption}</TableCaption>} <TableHeader> <TableRow> {columns.map((column, index) => ( - <TableHead key={index} className={column.className}> + <TableHead + key={index} + className={cn("whitespace-pre text-nowrap", column.className)} + > {column.header} </TableHead> ))} @@ -47,15 +54,25 @@ export const DataTable = <T,>({ <TableRow key={rowIndex} onClick={onRowClick ? () => onRowClick(item) : undefined} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={(e) => { + if (e.key === "Enter") { + onRowClick?.(item); + } + }} className={ onRowClick ? "cursor-pointer hover:bg-slate-100 transition-colors" - : undefined + : "hover:bg-transparent" } > {columns.map((column, colIndex) => ( <TableCell key={colIndex} className={column.className}> - {column.accessor(item)} + {column.accessor + ? column.accessor(item) + : typeof item === "string" || typeof item === "number" + ? item + : null} </TableCell> ))} </TableRow> diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20cd688d77a1b7be817ddf78608d9ab85da4068b --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/utils/cn"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className, + )} + {...props} + /> +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className, + )} + {...props} + > + {children} + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-1.5 text-center sm:text-left", + className, + )} + {...props} + /> +); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className, + )} + {...props} + /> +); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className, + )} + {...props} + /> +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 0858f3f67f64fc34f60220ef70af516a96d85f27..b03234c2d7029dadc16cf059fdd2864e3b7d9105 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { cn } from "@/utils/cn"; +import { Label } from "./label"; export type InputProps = React.InputHTMLAttributes<HTMLInputElement>; @@ -27,27 +28,34 @@ const IconInput = React.forwardRef< leftIcon?: React.ReactElement; rightIcon?: React.ReactElement; containerClassName?: string; + label?: string; } ->(({ leftIcon, rightIcon, className, containerClassName, ...props }, ref) => { - return ( - <div className={cn("relative", containerClassName)}> - <Input - ref={ref} - className={cn(leftIcon && "pl-10", rightIcon && "pr-10", className)} - {...props} - /> - {leftIcon && ( - <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-foreground *:size-4"> - {leftIcon} - </div> - )} - {rightIcon && ( - <div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-foreground *:size-4"> - {rightIcon} - </div> - )} - </div> - ); -}); +>( + ( + { leftIcon, rightIcon, className, containerClassName, label, ...props }, + ref, + ) => { + return ( + <div className={cn("relative", containerClassName)}> + {label && <Label htmlFor={props.id}>{label}</Label>} + <Input + ref={ref} + className={cn(leftIcon && "pl-10", rightIcon && "pr-10", className)} + {...props} + /> + {leftIcon && ( + <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-foreground *:size-4"> + {leftIcon} + </div> + )} + {rightIcon && ( + <div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-foreground *:size-4"> + {rightIcon} + </div> + )} + </div> + ); + }, +); export { Input, IconInput }; diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx index 192ba951cb0cf1140e03585c9b605d50ccf9eddd..a6d7e1c494f5b00bbfb7070946d9dcea8cab2cad 100644 --- a/frontend/src/components/ui/table.tsx +++ b/frontend/src/components/ui/table.tsx @@ -6,7 +6,7 @@ const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes<HTMLTableElement> >(({ className, ...props }, ref) => ( - <div className="relative w-full overflow-auto"> + <div className="relative w-full overflow-auto bg-white border rounded-md"> <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 6fdd237ebe345fe2309346d823af37e46d25a76c..7308f6d4113b575f56f6965b0c299ac0c3b941db 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -12,7 +12,7 @@ const TabsList = React.forwardRef< <TabsPrimitive.List ref={ref} className={cn( - "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", + "inline-flex h-fit items-center rounded-md bg-muted p-1 text-muted-foreground max-w-full overflow-x-auto", className, )} {...props} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index e62f66c96aedac7ade45777decf57bd7aeaf5161..9029a3dd7ec867f69046beced212069eb27a7d20 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -14,6 +14,8 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRoute } from './routes/__root' import { Route as BaseIndexImport } from './routes/_base/index' +import { Route as BaseTasksImport } from './routes/_base/tasks' +import { Route as BaseVacancyNewImport } from './routes/_base/vacancy/new' import { Route as BaseVacancyIdImport } from './routes/_base/vacancy/$id' // Create Virtual Routes @@ -46,6 +48,16 @@ const BaseLoginLazyRoute = BaseLoginLazyImport.update({ getParentRoute: () => BaseLazyRoute, } as any).lazy(() => import('./routes/_base/login.lazy').then((d) => d.Route)) +const BaseTasksRoute = BaseTasksImport.update({ + path: '/tasks', + getParentRoute: () => BaseLazyRoute, +} as any) + +const BaseVacancyNewRoute = BaseVacancyNewImport.update({ + path: '/vacancy/new', + getParentRoute: () => BaseLazyRoute, +} as any) + const BaseVacancyIdRoute = BaseVacancyIdImport.update({ path: '/vacancy/$id', getParentRoute: () => BaseLazyRoute, @@ -62,6 +74,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BaseLazyImport parentRoute: typeof rootRoute } + '/_base/tasks': { + id: '/_base/tasks' + path: '/tasks' + fullPath: '/tasks' + preLoaderRoute: typeof BaseTasksImport + parentRoute: typeof BaseLazyImport + } '/_base/login': { id: '/_base/login' path: '/login' @@ -90,6 +109,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BaseVacancyIdImport parentRoute: typeof BaseLazyImport } + '/_base/vacancy/new': { + id: '/_base/vacancy/new' + path: '/vacancy/new' + fullPath: '/vacancy/new' + preLoaderRoute: typeof BaseVacancyNewImport + parentRoute: typeof BaseLazyImport + } } } @@ -97,10 +123,12 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren({ BaseLazyRoute: BaseLazyRoute.addChildren({ + BaseTasksRoute, BaseLoginLazyRoute, BaseRegisterLazyRoute, BaseIndexRoute, BaseVacancyIdRoute, + BaseVacancyNewRoute, }), }) @@ -118,12 +146,18 @@ export const routeTree = rootRoute.addChildren({ "/_base": { "filePath": "_base.lazy.tsx", "children": [ + "/_base/tasks", "/_base/login", "/_base/register", "/_base/", - "/_base/vacancy/$id" + "/_base/vacancy/$id", + "/_base/vacancy/new" ] }, + "/_base/tasks": { + "filePath": "_base/tasks.tsx", + "parent": "/_base" + }, "/_base/login": { "filePath": "_base/login.lazy.tsx", "parent": "/_base" @@ -139,6 +173,10 @@ export const routeTree = rootRoute.addChildren({ "/_base/vacancy/$id": { "filePath": "_base/vacancy/$id.tsx", "parent": "/_base" + }, + "/_base/vacancy/new": { + "filePath": "_base/vacancy/new.tsx", + "parent": "/_base" } } } diff --git a/frontend/src/routes/_base/index.tsx b/frontend/src/routes/_base/index.tsx index 12d05bc8e66c77f70b64953f7cceb6c999bde16b..011da71ff4ba0ea048ac33f380d6ab08ca443350 100644 --- a/frontend/src/routes/_base/index.tsx +++ b/frontend/src/routes/_base/index.tsx @@ -19,11 +19,11 @@ const columns: Column<VacancyDto.Item>[] = [ }, { header: "Дата ÑозданиÑ", - accessor: (item) => new Date(item.deadline).toLocaleDateString(), + accessor: (item) => new Date(item.created_at).toLocaleDateString("ru-RU"), }, { header: "До дедлайна", - accessor: (item) => new Date(item.deadline).toLocaleDateString(), + accessor: (item) => new Date(item.deadline).toLocaleDateString("ru-RU"), }, ]; diff --git a/frontend/src/routes/_base/tasks.tsx b/frontend/src/routes/_base/tasks.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d3d9bb79391085e2fc30344e3b501f17c7b50a46 --- /dev/null +++ b/frontend/src/routes/_base/tasks.tsx @@ -0,0 +1,110 @@ +import { TasksEndpoint } from "@/api/endpoints/tasks.endpoint"; +import { MainLayout } from "@/components/hoc/layouts/main.layout"; +import { RejectCandidate } from "@/components/pages/tasks/reject-candidate"; +import { Button } from "@/components/ui/button"; +import { Column, DataTable } from "@/components/ui/data-table"; +import { checkAuth } from "@/utils/check-grant"; +import { createFileRoute } from "@tanstack/react-router"; +import { CheckIcon, UserXIcon } from "lucide-react"; +import { observer } from "mobx-react-lite"; +import { useCallback, useMemo } from "react"; + +interface Task { + id: number; + stage: string; + candidate_number: number; + vacancy_name: string; + deadline: string; + resume_link: string; +} + +const Page = observer(() => { + const { tasks } = Route.useLoaderData(); + + const onReject = useCallback((task: Task) => { + console.log(task); + }, []); + + const columns = useMemo<Column<Task>[]>( + () => [ + { + header: "Ðтап", + accessor: (v) => ( + <div className="flex items-center text-nowrap gap-1"> + <button className="border rounded-md size-6 flex items-center justify-center group"> + <CheckIcon className="size-4 group-hover:opacity-100 opacity-0" /> + </button> + {v.stage} + </div> + ), + }, + { + header: "â„–", + accessor: (v) => v.candidate_number, + }, + { + header: "ВаканÑиÑ", + accessor: (v) => v.vacancy_name, + }, + { + header: "До дедлайна", + accessor: (v) => { + const deadline = new Date(v.deadline); + const now = new Date(); + const diff = deadline.getTime() - now.getTime(); + const days = Math.ceil(diff / (1000 * 3600 * 24)); + return `${days} дн.`; + }, + }, + { + header: "Резюме", + accessor: (v) => ( + <a + href={v.resume_link} + target="_blank" + rel="noreferrer" + className="text-blue-500 underline hover:no-underline" + > + Файл + </a> + ), + }, + { + header: <UserXIcon className="size-5" />, + accessor: (v) => <RejectCandidate onReject={() => onReject(v)} />, + }, + ], + [onReject], + ); + + return ( + <MainLayout + header={ + <div className="space-y-6"> + <h1 className="text-3xl font-semibold">Мои задачи</h1> + </div> + } + > + <DataTable data={tasks} columns={columns} /> + </MainLayout> + ); +}); + +export const Route = createFileRoute("/_base/tasks")({ + component: Page, + beforeLoad: checkAuth, + loader: async () => { + // const tasks = await TasksEndpoint.list(); + const tasks: Task[] = [ + { + id: 1, + stage: "HR Скрининг", + candidate_number: 1, + vacancy_name: "vacancy", + deadline: "2024-10-06T11:27:41.106023", + resume_link: "https://google.com", + }, + ]; + return { tasks }; + }, +}); diff --git a/frontend/src/routes/_base/vacancy/$id.tsx b/frontend/src/routes/_base/vacancy/$id.tsx index c24715d2b84e82cfdb25883631ee0a73aaf32634..4dbd63d777294ba5d1470bf87082f0055803883d 100644 --- a/frontend/src/routes/_base/vacancy/$id.tsx +++ b/frontend/src/routes/_base/vacancy/$id.tsx @@ -1,6 +1,9 @@ import { VacancyEndpoint } from "@/api/endpoints/vacanvy.endpoint"; import { mockVacancy } from "@/api/models/vacancy.model"; import { MainLayout } from "@/components/hoc/layouts/main.layout"; +import { AnalyticsView } from "@/components/pages/vacancy/analytics.view"; +import { CandidatesView } from "@/components/pages/vacancy/candidates.view"; +import { OverviewView } from "@/components/pages/vacancy/overview.view"; import { Stats } from "@/components/pages/vacancy/stats.view"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; @@ -17,12 +20,21 @@ import { Priority } from "@/types/priority.type"; import { checkAuth } from "@/utils/check-grant"; import { pluralize } from "@/utils/pluralize"; import { useViewModel } from "@/utils/vm"; -import { createFileRoute, useLoaderData } from "@tanstack/react-router"; -import { observable } from "mobx"; +import { + createFileRoute, + useLoaderData, + useNavigate, + useSearch, +} from "@tanstack/react-router"; import { observer } from "mobx-react-lite"; +import { useState } from "react"; +import { z } from "zod"; const Page = observer(() => { const { vacancy } = Route.useLoaderData(); + const [tab, setTab] = useState( + new URLSearchParams(window.location.search).get("tab") ?? "overview", + ); const vm = useViewModel(VacancyStore, vacancy); const deadline = new Date(vm.vacancy.vacancy.deadline); @@ -47,14 +59,14 @@ const Page = observer(() => { {vm.vacancy.vacancy.name} </h1> <p className="text-slate-500">{vm.vacancy.vacancy.area}</p> - <div className="flex justify-between items-end"> - <div className="space-y-2"> + <div className="flex justify-between items-end gap-2"> + <div className="space-y-2 basis-[300px]"> <span className="flex items-center text-slate-800"> {deadline.toLocaleDateString("ru-RU")} - <div className="rounded-full bg-slate-500 size-2 inline-block mx-2"></div> + <div className="rounded-full bg-slate-500 min-w-2 size-2 inline-block mx-2"></div> ещё {daysLeft} {pluralize(daysLeft, ["день", "днÑ", "дней"])} </span> - <Progress value={percentage} className="w-[300px] h-2" /> + <Progress value={percentage} className="basis-[300px] h-2" /> </div> <div> <Label htmlFor="priority">Приоритет</Label> @@ -79,19 +91,25 @@ const Page = observer(() => { </div> } > - <Tabs value={vm.tab} onValueChange={(value) => (vm.tab = value)}> - <TabsList> + <Tabs value={tab} onValueChange={(value) => setTab(value)}> + <TabsList className="overflow-x-auto"> <TabsTrigger value="overview">О ваканÑии</TabsTrigger> <TabsTrigger value="stats">СтатиÑтика по ваканÑии</TabsTrigger> <TabsTrigger value="candidates">Кандидаты</TabsTrigger> <TabsTrigger value="analytics">Ðнализ рынка по ваканÑии</TabsTrigger> </TabsList> - <TabsContent value="overview">overview</TabsContent> + <TabsContent value="overview"> + <OverviewView vm={vm} /> + </TabsContent> <TabsContent value="stats"> <Stats vacancy={vm.vacancy} /> </TabsContent> - <TabsContent value="candidates">candidates</TabsContent> - <TabsContent value="analytics">sources</TabsContent> + <TabsContent value="candidates"> + <CandidatesView vm={vm} /> + </TabsContent> + <TabsContent value="analytics"> + <AnalyticsView vm={vm} /> + </TabsContent> </Tabs> </MainLayout> ); @@ -101,8 +119,7 @@ export const Route = createFileRoute("/_base/vacancy/$id")({ component: Page, beforeLoad: checkAuth, loader: async (x) => { - // const vacancy = await VacancyEndpoint.getById(x.params.id); - const vacancy = mockVacancy; + const vacancy = await VacancyEndpoint.getById(x.params.id); return { vacancy }; }, }); diff --git a/frontend/src/routes/_base/vacancy/new.tsx b/frontend/src/routes/_base/vacancy/new.tsx new file mode 100644 index 0000000000000000000000000000000000000000..287f406974d16d0dd95a14124dde03e0b1a7f69a --- /dev/null +++ b/frontend/src/routes/_base/vacancy/new.tsx @@ -0,0 +1,238 @@ +import { MainLayout } from "@/components/hoc/layouts/main.layout"; +import { ChartSection } from "@/components/pages/vacancy/chart-section"; +import { IconInput, Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Priority } from "@/types/priority.type"; +import { checkAuth } from "@/utils/check-grant"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { observer } from "mobx-react-lite"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { NewVacancyStore } from "@/stores/new-vacancy"; +import { useViewModel } from "@/utils/vm"; +import { SkillsDropdown } from "@/components/dropdowns/SkillsDropdown"; +import { Textarea } from "@/components/ui/textarea"; +import DropdownMultiple from "@/components/DropdownMultiple"; +import { StagesForm } from "@/components/pages/vacancy/StagesForm"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import AutoFormInput from "@/components/ui/auto-form/fields/input"; +import AutoForm from "@/components/ui/auto-form"; +import { z } from "zod"; + +const Page = observer(() => { + const vm = useViewModel(NewVacancyStore); + const navigate = useNavigate(); + + return ( + <MainLayout title="ÐÐ¾Ð²Ð°Ñ Ð²Ð°ÐºÐ°Ð½ÑиÑ"> + <div className="space-y-4"> + <ChartSection + title="ОÑÐ½Ð¾Ð²Ð½Ð°Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ" + collapsible={false} + allowOverflow + > + <div className="flex flex-wrap gap-4"> + <IconInput + id="title" + className="w-[300px]" + value={vm.name} + onChange={(e) => { + vm.name = e.target.value; + }} + label="Ðазвание ваканÑии" + placeholder="Фронтенд разработчик" + /> + <div> + <Label htmlFor="priority">Приоритет</Label> + <Select + key={vm.priority} + value={vm.priority.toString()} + onValueChange={(value) => { + vm.priority = Number(value); + }} + > + <SelectTrigger id="priority" className="w-[180px]"> + <SelectValue placeholder="Выберите приоритет" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="1">{Priority.locale[1]}</SelectItem> + <SelectItem value="2">{Priority.locale[2]}</SelectItem> + <SelectItem value="3">{Priority.locale[3]}</SelectItem> + </SelectContent> + </Select> + </div> + </div> + </ChartSection> + <ChartSection + title="ОÑÐ½Ð¾Ð²Ð½Ð°Ñ Ð¸Ð½Ñ„Ð¾Ñ€Ð¼Ð°Ñ†Ð¸Ñ" + collapsible={false} + allowOverflow + > + <div className="flex flex-wrap gap-4"> + <div className="grid grid-cols-[1fr_auto_1fr] w-fit items-center gap-x-1 h-fit"> + <Label + htmlFor="experience_from" + className="col-span-3 mt-1.5 mb-1" + > + Опыт работы, лет + </Label> + <IconInput + id="experience_from" + type="number" + placeholder="от" + value={vm.experienceFrom} + onChange={(e) => { + const value = Number(e.target.value); + if ( + value > Number(vm.experienceTo || 99) || + value < 0 || + value > 99 + ) { + return; + } + vm.experienceFrom = e.target.value; + }} + className="w-24" + /> + <span>–</span> + <IconInput + id="experience_to" + type="number" + placeholder="до" + value={vm.experienceTo} + onChange={(e) => { + const value = Number(e.target.value); + if (value > 99 || value < 0) { + return; + } + vm.experienceTo = e.target.value; + }} + className="w-24" + /> + </div> + <IconInput + type="number" + label="Кол-во меÑÑ‚" + placeholder="1" + value={vm.quantity} + onChange={(e) => { + const value = Number(e.target.value); + if (value < 1 || value > 99) { + return; + } + vm.quantity = value; + }} + className="w-24" + /> + <IconInput + label="Образование" + placeholder="Ð’Ñ‹Ñшее" + value={vm.education} + onChange={(e) => { + vm.education = e.target.value; + }} + className="w-[400px]" + /> + <div className="w-full flex gap-4 *:w-[250px]"> + <SkillsDropdown + label="Ключевые навыки" + value={vm.keySkills} + onChange={(value) => { + vm.keySkills = value; + }} + filter={(value) => !vm.additionalSkills.includes(value)} + /> + <SkillsDropdown + label="Дополнительные навыки" + value={vm.additionalSkills} + filter={(value) => !vm.keySkills.includes(value)} + onChange={(value) => { + vm.additionalSkills = value; + }} + /> + </div> + </div> + </ChartSection> + <ChartSection + title="ПодробноÑти о ваканÑии" + collapsible={false} + allowOverflow + > + <div className="grid grid-cols-2 w-full gap-6 gap-x-4"> + <div> + <Label htmlFor="description">ОпиÑание</Label> + <Textarea + id="description" + placeholder="О чем ваканÑиÑ?" + value={vm.description} + onChange={(e) => { + vm.description = e.target.value; + }} + /> + </div> + <div className="grid grid-cols-2"> + <div className="grid grid-cols-[1fr_auto_1fr] w-fit items-center gap-x-1 h-fit"> + <Label + htmlFor="experience_from" + className="col-span-3 mt-1.5 mb-1" + > + Вилка зарплаты, руб + </Label> + <IconInput + id="salary_low" + type="number" + placeholder="от" + onChange={(e) => { + vm.salaryLow = e.target.value; + }} + className="w-24" + /> + <span>–</span> + <IconInput + id="salary_high" + type="number" + placeholder="до" + value={vm.salaryHigh} + onChange={(e) => { + vm.salaryHigh = e.target.value; + }} + className="w-24" + /> + </div> + </div> + <StagesForm vm={vm} /> + </div> + </ChartSection> + <div className="w-full flex justify-end"> + <Button + onClick={() => { + if (vm.validate()) { + toast.promise(vm.create(navigate), { + loading: "Создание ваканÑии...", + success: "ВаканÑÐ¸Ñ Ñоздана", + error: "Ошибка ÑÐ¾Ð·Ð´Ð°Ð½Ð¸Ñ Ð²Ð°ÐºÐ°Ð½Ñии", + }); + } + }} + > + Создать ваканÑию + </Button> + </div> + </div> + </MainLayout> + ); +}); + +export const Route = createFileRoute("/_base/vacancy/new")({ + component: Page, + beforeLoad: checkAuth, + loader: async () => { + return {}; + }, +}); diff --git a/frontend/src/stores/new-vacancy.ts b/frontend/src/stores/new-vacancy.ts new file mode 100644 index 0000000000000000000000000000000000000000..38201b1e6d7801015c0c95d60ff90d89bf98ba7a --- /dev/null +++ b/frontend/src/stores/new-vacancy.ts @@ -0,0 +1,119 @@ +import { VacancyEndpoint } from "@/api/endpoints/vacanvy.endpoint"; +import { SkillDto } from "@/api/models/skill.model"; +import { Priority } from "@/types/priority.type"; +import { DisposableVm } from "@/utils/vm"; +import { NavigateFn } from "@tanstack/react-router"; +import { makeAutoObservable } from "mobx"; +import { toast } from "sonner"; + +export class StageStore { + name = ""; + sla = 0; + readonly id = Math.random(); + + constructor() { + makeAutoObservable(this); + } +} + +export class NewVacancyStore implements DisposableVm { + name = ""; + priority: Priority.Priority = Priority.Priority.LOW; + deadline: Date = new Date(); + profession = ""; + area = ""; + supervisor = ""; + city = ""; + experienceFrom = ""; + experienceTo = ""; + education = ""; + keySkills: SkillDto.Item[] = []; + additionalSkills: SkillDto.Item[] = []; + description = ""; + typeOfEmployment = ""; + quantity = 1; + direction = ""; + salaryLow = ""; + salaryHigh = ""; + + loading = false; + + stages: StageStore[] = []; + + addStage() { + this.stages.push(new StageStore()); + } + + removeStage(stage: StageStore) { + this.stages = this.stages.filter((x) => x !== stage); + } + + constructor() { + makeAutoObservable(this); + } + + validate(): boolean { + if (this.name.length < 3) { + toast.error("Ðазвание ваканÑии должно быть не менее 3 Ñимволов"); + return false; + } + + if (Number(this.salaryLow) > Number(this.salaryHigh)) { + toast.error("ÐœÐ¸Ð½Ð¸Ð¼Ð°Ð»ÑŒÐ½Ð°Ñ Ð·Ð°Ñ€Ð¿Ð»Ð°Ñ‚Ð° не может быть больше макÑимальной"); + return false; + } + + if (this.stages.length < 1) { + toast.error("Ðеобходимо добавить Ñ…Ð¾Ñ‚Ñ Ð±Ñ‹ один Ñтап"); + return false; + } + + if (this.keySkills.length < 1) { + toast.error("Ðеобходимо добавить Ñ…Ð¾Ñ‚Ñ Ð±Ñ‹ один ключевой навык"); + return false; + } + + if (Number(this.experienceFrom) > Number(this.experienceTo)) { + toast.error("Ðеверно указаны годы опыта"); + return false; + } + + return true; + } + + async create(navigate: NavigateFn) { + const id = await VacancyEndpoint.create({ + name: this.name, + priority: this.priority, + deadline: this.deadline.toISOString().split("T")[0] + "T00:00:00", + profession: this.profession, + area: this.area, + supervisor: this.supervisor, + city: this.city, + experienceFrom: this.experienceFrom, + experienceTo: this.experienceTo, + education: this.education, + keySkills: this.keySkills.map((x) => x.id), + additionalSkills: this.additionalSkills.map((x) => x.id), + description: this.description, + typeOfEmployment: this.typeOfEmployment, + quantity: this.quantity, + direction: this.direction, + salary_low: Number(this.salaryLow), + salary_high: Number(this.salaryHigh), + stages: this.stages.map((x, i) => ({ + order: i + 1, + name: x.name, + duration: x.sla, + })), + }); + + navigate({ to: "/vacancy/$id", params: { id: id.toString() } }); + + return id; + } + + dispose(): void { + return; + } +} diff --git a/frontend/src/stores/vanacy.store.ts b/frontend/src/stores/vanacy.store.ts index 487bea1e90d1db08b85b402931c01dfe942dcb5f..4b2de9962d3e41095c36f210f3f04709a65758fa 100644 --- a/frontend/src/stores/vanacy.store.ts +++ b/frontend/src/stores/vanacy.store.ts @@ -1,13 +1,33 @@ +import { CandidatesEndpoint } from "@/api/endpoints/candidates.endpoint"; +import { CandidatesDto } from "@/api/models/candidates.model"; import { mockVacancy, VacancyDto } from "@/api/models/vacancy.model"; import { DisposableVm } from "@/utils/vm"; import { makeAutoObservable, observable } from "mobx"; export class VacancyStore implements DisposableVm { - tab = "overview"; + activeCandidates: CandidatesDto.ActiveCandidate[] = []; + potentialCandidates: CandidatesDto.PotentialCandidate[] = []; + declinedCandidates: CandidatesDto.DeclinedCandidate[] = []; constructor(public readonly vacancy: VacancyDto.DetailedItem) { makeAutoObservable(this); } - dispose(): void {} + async loadCandidates() { + if (this.activeCandidates.length) return; + + const [active, potential, declined] = await Promise.all([ + CandidatesEndpoint.getActiveCandidates(this.vacancy.vacancy.id), + CandidatesEndpoint.getPotentialCandidates(this.vacancy.vacancy.id), + CandidatesEndpoint.getDeclinedCandidates(this.vacancy.vacancy.id), + ]); + + this.activeCandidates = active.candidates; + this.potentialCandidates = potential.candidates; + this.declinedCandidates = declined.candidates; + } + + dispose(): void { + return; + } } diff --git a/frontend/src/utils/check-grant.ts b/frontend/src/utils/check-grant.ts index cfa6022c0fe447f6dcad260ec18327424e8fd322..4eac81a266639f18be34180723410452adfea583 100644 --- a/frontend/src/utils/check-grant.ts +++ b/frontend/src/utils/check-grant.ts @@ -2,7 +2,6 @@ import { AuthService } from "@/stores/auth.service"; import { redirect } from "@tanstack/react-router"; export const checkAuth = () => { - console.log("check"); if (AuthService.auth.state === "authenticated") { return; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 24080de3dfa5871acef7f63964b122172ce7f25a..3dd1ad8218b41ec11e10a41bcef07b2b88e6b24d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -21,25 +21,11 @@ export default defineConfig({ }), VitePWA({ registerType: "autoUpdate", - workbox: { - runtimeCaching: [ - { - urlPattern: /^https:\/\/api\.lct\.larek\.tech\/consumers\/q$/, - handler: "CacheFirst", - options: { - cacheName: "api-cache", - expiration: { - maxEntries: 1, // Keep only one entry - }, - cacheableResponse: { - statuses: [0, 200], // Cache successful responses and opaque responses - }, - }, - }, - ], + manifest: { + theme_color: "#015AAE", + name: "Дашборд рекуртера", }, }), - // basicSsl() ], build: { target: "esnext",