diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index b792f598..5451b9eb 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -24,6 +24,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^6.30.1", "react-social-icons": "^6.24.0", + "recharts": "^3.2.1", "tailwind-merge": "^3.3.0", "tailwindcss": "^4.1.11" }, @@ -1794,6 +1795,69 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2668,6 +2732,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2740,6 +2925,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3027,6 +3218,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", + "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", @@ -3302,6 +3503,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3827,6 +4034,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5182,7 +5398,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-number-format": { @@ -5359,6 +5574,33 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/recharts": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz", + "integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==", + "license": "MIT", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -5962,6 +6204,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -6351,6 +6599,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index 8c15d5c4..01a96abb 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -28,6 +28,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^6.30.1", "react-social-icons": "^6.24.0", + "recharts": "^3.2.1", "tailwind-merge": "^3.3.0", "tailwindcss": "^4.1.11" }, diff --git a/app/frontend/src/components/pages/HomePage.tsx b/app/frontend/src/components/pages/HomePage.tsx index af7f52ef..cd32954f 100644 --- a/app/frontend/src/components/pages/HomePage.tsx +++ b/app/frontend/src/components/pages/HomePage.tsx @@ -1,8 +1,11 @@ +import { RegionPage } from "./region"; + const HomePage = () => { return (

Главная

Информация.

+
); }; diff --git a/app/frontend/src/components/pages/region/index.ts b/app/frontend/src/components/pages/region/index.ts new file mode 100644 index 00000000..e3e333e5 --- /dev/null +++ b/app/frontend/src/components/pages/region/index.ts @@ -0,0 +1 @@ +export { RegionPage } from './ui/regionPage' \ No newline at end of file diff --git a/app/frontend/src/components/pages/region/model/types.ts b/app/frontend/src/components/pages/region/model/types.ts new file mode 100644 index 00000000..fef8ce43 --- /dev/null +++ b/app/frontend/src/components/pages/region/model/types.ts @@ -0,0 +1,19 @@ +import type { VacancyWithExperience } from "../../../vacancy/model/types"; + +export interface RegionPageProps { + region: string; + vacancies: VacancyWithExperience[]; + experienceOptions: string[]; + countryRegions: string[]; + filters: { + experience: string; + region: string; + query: string; + }; + pagination: { + currentPage: number; + lastPage: number; + nextPageUrl: string | null; + prevPageUrl: string | null; + } +} \ No newline at end of file diff --git a/app/frontend/src/components/pages/region/ui/regionPage.tsx b/app/frontend/src/components/pages/region/ui/regionPage.tsx new file mode 100644 index 00000000..65bb470d --- /dev/null +++ b/app/frontend/src/components/pages/region/ui/regionPage.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from "react"; +import { VacancyCard } from "../../../shared/ui/vacancyCard"; +import { VacancyDynamicsChart } from "../../../vacancy/dynamics/ui/VacancyDynamicsChart"; +import type { VacancyWithExperience } from "../../../vacancy/model/types"; +import { test_vacancies } from '../../../shared/testData' +import { useVacancyFilters } from "../../../vacancy/filters/model/useVacancyFilters"; +import { useVacancyDynamics } from "../../../vacancy/dynamics/model/useVacancyDynamics"; +import { VacancyFilters } from "../../../vacancy/filters/ui/VacancyFilters"; +import { Building2 } from "lucide-react"; +import { Container, Text, Card, Group, Grid, Divider, Badge, Stack } from "@mantine/core"; + + +const experience_options = ['0-1', '2-4', '5+']; + +export const RegionPage: React.FC = () => { + const { + selectedExperience, + selectedRegion, + searchQuery, + filteredVacancies, + setFilters + } = useVacancyFilters(test_vacancies) + + const dynamicsData = useVacancyDynamics(filteredVacancies); + + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 6; + const lastPage = Math.ceil(filteredVacancies.length / pageSize); + const safeCurrentPage = Math.min(currentPage, lastPage || 1); // Страхуемся, что номер текущей страницы не больше, чем максимально возможное число страниц + const paginatedVacancies = filteredVacancies.slice((safeCurrentPage - 1) * pageSize, safeCurrentPage * pageSize); + + const region = selectedRegion || (paginatedVacancies.length > 0 ? paginatedVacancies[0].city : 'Регион'); + + useEffect(() => { + setCurrentPage(1); + }, [selectedExperience, selectedRegion, searchQuery]) + + // Если приспичит сделать кнопки для переключения страниц + // const handlePageChange = (newPage: number) => { + // if (newPage >= 1 && newPage <= lastPage) setCurrentPage(newPage); + // } + + const calculateGrowth = (dynamics: VacancyWithExperience['dynamics']): number => { + if (dynamics.length < 2) return 0; + const startValue = dynamics[0].count; + const currentValue = dynamics[dynamics.length - 1].count; + + return Math.round(((currentValue - startValue) / startValue) * 100) + } + + return ( + + {/* Название региона */} + + + + {region} + + + {/* Конец названия региона */} + {/* Карточки с аналитикой по специальностям */} + + {paginatedVacancies.length === 0 && ( + Вакансии не найдены + )} + {paginatedVacancies.map((vacancy) => { + const growth = calculateGrowth(vacancy.dynamics); + return ( + + + {vacancy.title} + + + {vacancy.dynamics.reduce((acc, cur) => acc + cur.count, 0)} + + + {growth >= 0 ? `+${growth}` : `-${growth}`} + + + + + + ) + })} + + {/* Конец карточек с аналитикой по специальностям */} + {/* Динамика вакансий */} + {dynamicsData.length > 0 && ( + + )} + {/* Конец динамики вакансий */} + {/* Фильтры */} + v.city)))} + experienceOptions={experience_options} + onChangeFilters={setFilters} + /> + {/* Конец фильров */} + {/* Карточки с вакансиями */} + + {paginatedVacancies.map((vacancy) => ( + + ))} + + {/* Конец карточек с вакансиями */} + + ) +} diff --git a/app/frontend/src/components/shared/testData.ts b/app/frontend/src/components/shared/testData.ts new file mode 100644 index 00000000..9752c0a8 --- /dev/null +++ b/app/frontend/src/components/shared/testData.ts @@ -0,0 +1,244 @@ +import type { VacancyWithExperience } from "../vacancy/model/types"; + +export const test_vacancies: VacancyWithExperience[] = [ + { + id: 1, + title: "Frontend Developer", + description: "Создавать интерфейсы и компоненты", + company: "Tech Corp", + salary: "120000 тенге", + city: "Москва", + key_skills: ["React", "TypeScript", "CSS"], + minExperience: 3, + dynamics: [ + { count: 1200, month: "Янв", year: 2025 }, + { count: 1450, month: "Фев", year: 2025 }, + { count: 1680, month: "Мар", year: 2025 }, + { count: 1920, month: "Апр", year: 2025 }, + { count: 2100, month: "Май", year: 2025 }, + { count: 2350, month: "Июн", year: 2025 }, + { count: 2580, month: "Июл", year: 2025 }, + { count: 2820, month: "Авг", year: 2025 }, + { count: 3050, month: "Сен", year: 2025 }, + { count: 3280, month: "Окт", year: 2025 }, + { count: 3500, month: "Ноя", year: 2025 }, + { count: 3720, month: "Дек", year: 2025 }, + ], + }, + { + id: 2, + title: "Backend Developer", + description: "Разработка серверной части приложений", + company: "Data Systems", + salary: "130000 - 140000 евро", + city: "Санкт-Петербург", + key_skills: ["Node.js", "Express", "PostgreSQL"], + minExperience: 2, + dynamics: [ + { count: 900, month: "Янв", year: 2025 }, + { count: 950, month: "Фев", year: 2025 }, + { count: 970, month: "Мар", year: 2025 }, + { count: 915, month: "Апр", year: 2025 }, + { count: 920, month: "Май", year: 2025 }, + { count: 938, month: "Июн", year: 2025 }, + { count: 945, month: "Июл", year: 2025 }, + { count: 952, month: "Авг", year: 2025 }, + { count: 960, month: "Сен", year: 2025 }, + { count: 968, month: "Окт", year: 2025 }, + { count: 975, month: "Ноя", year: 2025 }, + { count: 983, month: "Дек", year: 2025 }, + ], + }, + { + id: 3, + title: "QA инженер", + description: "Тестирование программного обеспечения", + company: "QualityWorks", + salary: "90000 руб", + city: "Новороссийск", + key_skills: ["Selenium", "Jest", "Git"], + minExperience: 5, + dynamics: [ + { count: 500, month: "Янв", year: 2025 }, + { count: 600, month: "Фев", year: 2025 }, + { count: 620, month: "Мар", year: 2025 }, + { count: 630, month: "Апр", year: 2025 }, + { count: 640, month: "Май", year: 2025 }, + { count: 670, month: "Июн", year: 2025 }, + { count: 690, month: "Июл", year: 2025 }, + { count: 710, month: "Авг", year: 2025 }, + { count: 730, month: "Сен", year: 2025 }, + { count: 750, month: "Окт", year: 2025 }, + { count: 770, month: "Ноя", year: 2025 }, + { count: 790, month: "Дек", year: 2025 }, + ], + }, + { + id: 4, + title: "Data Scientist", + description: "Анализ данных и построение ML-моделей", + company: "AI Solutions", + salary: "150000 белорусских рублей", + city: "Москва", + key_skills: ["Python", "TensorFlow", "SQL", "Pandas"], + minExperience: 2, + dynamics: [ + { count: 800, month: "Янв", year: 2025 }, + { count: 950, month: "Фев", year: 2025 }, + { count: 1100, month: "Мар", year: 2025 }, + { count: 1250, month: "Апр", year: 2025 }, + { count: 1400, month: "Май", year: 2025 }, + { count: 1550, month: "Июн", year: 2025 }, + { count: 1700, month: "Июл", year: 2025 }, + { count: 1850, month: "Авг", year: 2025 }, + { count: 2000, month: "Сен", year: 2025 }, + { count: 2150, month: "Окт", year: 2025 }, + { count: 2300, month: "Ноя", year: 2025 }, + { count: 2450, month: "Дек", year: 2025 }, + ], + }, + { + id: 5, + title: "DevOps Engineer", + description: "Настройка CI/CD и облачной инфраструктуры", + company: "CloudTech", + salary: "140000 - 500000 рублей", + city: "Санкт-Петербург", + key_skills: ["Docker", "Kubernetes", "AWS", "Jenkins"], + minExperience: 4, + dynamics: [ + { count: 600, month: "Янв", year: 2025 }, + { count: 720, month: "Фев", year: 2025 }, + { count: 810, month: "Мар", year: 2025 }, + { count: 890, month: "Апр", year: 2025 }, + { count: 950, month: "Май", year: 2025 }, + { count: 1020, month: "Июн", year: 2025 }, + { count: 1100, month: "Июл", year: 2025 }, + { count: 1180, month: "Авг", year: 2025 }, + { count: 1250, month: "Сен", year: 2025 }, + { count: 1320, month: "Окт", year: 2025 }, + { count: 1400, month: "Ноя", year: 2025 }, + { count: 1470, month: "Дек", year: 2025 }, + ], + }, + { + id: 6, + title: "Fullstack Developer", + description: "Разработка полного цикла веб-приложений", + company: "WebInnovations", + salary: "135000 тенге", + city: "Екатеринбург", + key_skills: ["JavaScript", "React", "Node.js", "MongoDB"], + minExperience: 3, + dynamics: [ + { count: 1100, month: "Янв", year: 2025 }, + { count: 1250, month: "Фев", year: 2025 }, + { count: 1380, month: "Мар", year: 2025 }, + { count: 1520, month: "Апр", year: 2025 }, + { count: 1650, month: "Май", year: 2025 }, + { count: 1780, month: "Июн", year: 2025 }, + { count: 1900, month: "Июл", year: 2025 }, + { count: 2020, month: "Авг", year: 2025 }, + { count: 2150, month: "Сен", year: 2025 }, + { count: 2270, month: "Окт", year: 2025 }, + { count: 2400, month: "Ноя", year: 2025 }, + { count: 2520, month: "Дек", year: 2025 }, + ], + }, + { + id: 7, + title: "Mobile Developer", + description: "Разработка мобильных приложений для iOS и Android", + company: "AppCraft", + salary: "125000 - 200000 евро", + city: "Москва", + key_skills: ["React Native", "Swift", "Kotlin", "Redux"], + minExperience: 2, + dynamics: [ + { count: 700, month: "Янв", year: 2025 }, + { count: 820, month: "Фев", year: 2025 }, + { count: 910, month: "Мар", year: 2025 }, + { count: 980, month: "Апр", year: 2025 }, + { count: 1050, month: "Май", year: 2025 }, + { count: 1120, month: "Июн", year: 2025 }, + { count: 1200, month: "Июл", year: 2025 }, + { count: 1280, month: "Авг", year: 2025 }, + { count: 1350, month: "Сен", year: 2025 }, + { count: 1430, month: "Окт", year: 2025 }, + { count: 1500, month: "Ноя", year: 2025 }, + { count: 1580, month: "Дек", year: 2025 }, + ], + }, + { + id: 8, + title: "UX/UI Designer", + description: "Проектирование пользовательских интерфейсов", + company: "DesignStudio", + salary: "100000 руб", + city: "Санкт-Петербург", + key_skills: ["Figma", "Adobe XD", "User Research", "Prototyping"], + minExperience: 2, + dynamics: [ + { count: 850, month: "Янв", year: 2025 }, + { count: 920, month: "Фев", year: 2025 }, + { count: 980, month: "Мар", year: 2025 }, + { count: 1040, month: "Апр", year: 2025 }, + { count: 1100, month: "Май", year: 2025 }, + { count: 1150, month: "Июн", year: 2025 }, + { count: 1200, month: "Июл", year: 2025 }, + { count: 1250, month: "Авг", year: 2025 }, + { count: 1300, month: "Сен", year: 2025 }, + { count: 1350, month: "Окт", year: 2025 }, + { count: 1400, month: "Ноя", year: 2025 }, + { count: 1450, month: "Дек", year: 2025 }, + ], + }, + { + id: 9, + title: "Product Manager", + description: "Управление продуктом и разработка стратегии", + company: "ProductLabs", + salary: "160000 евро", + city: "Москва", + key_skills: ["Agile", "Analytics", "Roadmapping", "A/B Testing"], + minExperience: 5, + dynamics: [ + { count: 400, month: "Янв", year: 2025 }, + { count: 450, month: "Фев", year: 2025 }, + { count: 480, month: "Мар", year: 2025 }, + { count: 520, month: "Апр", year: 2025 }, + { count: 550, month: "Май", year: 2025 }, + { count: 580, month: "Июн", year: 2025 }, + { count: 610, month: "Июл", year: 2025 }, + { count: 640, month: "Авг", year: 2025 }, + { count: 670, month: "Сен", year: 2025 }, + { count: 700, month: "Окт", year: 2025 }, + { count: 730, month: "Ноя", year: 2025 }, + { count: 760, month: "Дек", year: 2025 }, + ], + }, + { + id: 10, + title: "System Administrator", + description: "Обслуживание IT-инфраструктуры компании", + company: "ITSupport Pro", + salary: "80000 долларов", + city: "Новосибирск", + key_skills: ["Linux", "Windows Server", "Networking", "Bash"], + minExperience: 3, + dynamics: [ + { count: 1200, month: "Янв", year: 2025 }, + { count: 1250, month: "Фев", year: 2025 }, + { count: 1220, month: "Мар", year: 2025 }, + { count: 1180, month: "Апр", year: 2025 }, + { count: 1150, month: "Май", year: 2025 }, + { count: 1120, month: "Июн", year: 2025 }, + { count: 1090, month: "Июл", year: 2025 }, + { count: 1060, month: "Авг", year: 2025 }, + { count: 1030, month: "Сен", year: 2025 }, + { count: 1000, month: "Окт", year: 2025 }, + { count: 970, month: "Ноя", year: 2025 }, + { count: 940, month: "Дек", year: 2025 }, + ], + }, +]; \ No newline at end of file diff --git a/app/frontend/src/components/shared/ui/vacancyCard/VacancyCard.tsx b/app/frontend/src/components/shared/ui/vacancyCard/VacancyCard.tsx new file mode 100644 index 00000000..814f1dbb --- /dev/null +++ b/app/frontend/src/components/shared/ui/vacancyCard/VacancyCard.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Card, Badge, Text, Group, Stack } from '@mantine/core'; +import { formatSalary } from "../../utils/FormatSalary"; +import { truncateText } from "../../utils/TruncateText"; +import type { VacancyProps } from "../../../vacancy/model/types"; +// import { Link } from '@inertiajs/react'; + +export const VacancyCard: React.FC = (props) => { + const { title, description, company, salary, city, key_skills } = props; + + return ( + // + + {/* Информация о должности и город */} + + {title} + {company} • {city} + + + {/* Зарплата и описание*/} + + Зарплата: + + {formatSalary(salary)} + + + {truncateText(description, 50)} + + {/* Ключевые навыки */} + + {key_skills.map((skill) => ( + + {skill} + + ))} + + + // + ); +}; \ No newline at end of file diff --git a/app/frontend/src/components/shared/ui/vacancyCard/index.ts b/app/frontend/src/components/shared/ui/vacancyCard/index.ts new file mode 100644 index 00000000..c664e616 --- /dev/null +++ b/app/frontend/src/components/shared/ui/vacancyCard/index.ts @@ -0,0 +1 @@ +export { VacancyCard } from './VacancyCard' \ No newline at end of file diff --git a/app/frontend/src/components/shared/utils/FormatSalary.tsx b/app/frontend/src/components/shared/utils/FormatSalary.tsx new file mode 100644 index 00000000..e207cc58 --- /dev/null +++ b/app/frontend/src/components/shared/utils/FormatSalary.tsx @@ -0,0 +1,31 @@ +/** + * Форматирует строку с зарплатой. Разделяет тысячи пробелом, заменяет строковое название валюты на соответствующий символ + * @param {string} salaryStr - строка с зарплатой + * @returns Отформатированную строку зарплаты с разделенными пробелами тысячами и символами валют + * @example + * Вернет "100 000 Br" + * formatSalary('100000 белорусских рублей') + */ + +export const formatSalary = (salaryStr: string): string => { + const rules: [RegExp, string][] = [ + [/(евро|euro|eur)/gi, '€'], + [/(доллар|usd|\$)(ов|а)?/gi, '$'], + [/белорусских\s*рубл(ей)?/gi, 'Br'], + [/\b(бел|byn|br)\b/gi, 'Br'], + [/(рубл|руб|rub|р\.?)(ей|я|ю|ем)?/gi, '₽'], + [/(тенге|kzt|₸|тг)/gi, '₸'] + ] + // Регулярка ищет в строке числа и разбивает по тысячам пробелами + const formatted = salaryStr.replace(/\d+/g, (match) => { + return parseInt(match).toLocaleString('ru-RU'); + }); + + let result = formatted; + + for (const [regExp, sign] of rules) { + result = result.replace(regExp, sign); + } + // Регулярка убирает лишние пробелы + return result.replace(/\s+/g, ' ').trim(); +} \ No newline at end of file diff --git a/app/frontend/src/components/shared/utils/TruncateText.tsx b/app/frontend/src/components/shared/utils/TruncateText.tsx new file mode 100644 index 00000000..ce576bbf --- /dev/null +++ b/app/frontend/src/components/shared/utils/TruncateText.tsx @@ -0,0 +1 @@ +export const truncateText = (text: string, maxLength: number): string => text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; \ No newline at end of file diff --git a/app/frontend/src/components/vacancy/dynamics/model/useVacancyDynamics.ts b/app/frontend/src/components/vacancy/dynamics/model/useVacancyDynamics.ts new file mode 100644 index 00000000..7973b664 --- /dev/null +++ b/app/frontend/src/components/vacancy/dynamics/model/useVacancyDynamics.ts @@ -0,0 +1,24 @@ +import { useMemo } from "react"; +import type { VacancyWithDynamics } from "../../model/types"; + +const monthOrder = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек']; + +export function useVacancyDynamics(vacancies: VacancyWithDynamics[]) { + return useMemo(() => { + const monthCounts: Record = {}; + + monthOrder.forEach((month) => { + monthCounts[month] = 0; + }) + + for (const vacancy of vacancies) { + for (const dynamics of vacancy.dynamics) { + monthCounts[dynamics.month] = (monthCounts[dynamics.month] || 0) + dynamics.count; + } + } + + const data = monthOrder.map((month) => ({ month, count: monthCounts[month] })).filter((item) => item.count > 0); + + return data; + }, [vacancies]); +} \ No newline at end of file diff --git a/app/frontend/src/components/vacancy/dynamics/ui/VacancyDynamicsChart.tsx b/app/frontend/src/components/vacancy/dynamics/ui/VacancyDynamicsChart.tsx new file mode 100644 index 00000000..f2667738 --- /dev/null +++ b/app/frontend/src/components/vacancy/dynamics/ui/VacancyDynamicsChart.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { AreaChart, Area, XAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { ChartColumn } from "lucide-react"; +import { Card, Group, Title } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; + +interface VacancyDynamicsChartProps { + data: { month: string; count: number}[] +} + +export const VacancyDynamicsChart: React.FC = ({ data }) => { + const isMobile = useMediaQuery('(max-width: 768px)'); + return ( + + + + + Динамика вакансий + + +
+ + + + + + + + + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/app/frontend/src/components/vacancy/filters/model/useVacancyFilters.ts b/app/frontend/src/components/vacancy/filters/model/useVacancyFilters.ts new file mode 100644 index 00000000..7d6a0800 --- /dev/null +++ b/app/frontend/src/components/vacancy/filters/model/useVacancyFilters.ts @@ -0,0 +1,57 @@ +import { useState, useMemo } from "react"; +import type { VacancyWithExperience } from "../../model/types"; + +const experienceRange: Record = { + '0-1': [0, 1], + '2-4': [2, 4], + '5+': [5, Infinity], +} + +export function useVacancyFilters(vacancies: VacancyWithExperience[]) { + const [selectedExperience, setSelectedExperience] = useState(''); + const [selectedRegion, setSelectedRegion] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredVacancies = useMemo(() => { + return vacancies.filter((vacancy) => { + /** + * Функция фильтра для вакансии + * + * @param {VacancyWithExperience} vacancy - Объект вакансии + * @param {Record} experienceRange - это словарь, в котором ключи - строки, а значения - кортежи с двумя числами + * @param {string} selectedExperience - это строка, которую пользователь выбрал с селекте + * @returns {boolean} true, если опыт вакансии входит в выбранный диапазон или фильтр не выбран + */ + const experienceMatch = selectedExperience ? (() => { + const range = experienceRange[selectedExperience]; + if (!range) return true; // Если не выбран диапазон, то проверку проходят все вакансии и показываются + const [min, max] = range; + return vacancy.minExperience >= min && vacancy.minExperience <= max; + })() : true + + const regionMatch = selectedRegion ? vacancy.city === selectedRegion : true; + + const queryMatch = searchQuery + ? vacancy.title.toLowerCase().includes(searchQuery.toLowerCase()) || + vacancy.description.toLowerCase().includes(searchQuery.toLowerCase()) || + vacancy.key_skills.some((skill) => skill.toLowerCase().includes(searchQuery.toLowerCase())) + : true; // Если нет ни одного совпадения, то не фильтрует по словам, а просто показывает результат + + return experienceMatch && regionMatch && queryMatch; + }) + }, [vacancies, selectedExperience, selectedRegion, searchQuery]); + + function setFilters(params: { experience?: string; region?: string; searchQuery?: string; }) { + if (params.experience !== undefined) setSelectedExperience(params.experience); + if (params.region !== undefined) setSelectedRegion(params.region); + if (params.searchQuery !== undefined) setSearchQuery(params.searchQuery); + } + + return { + selectedExperience, + selectedRegion, + searchQuery, + filteredVacancies, + setFilters + }; +} \ No newline at end of file diff --git a/app/frontend/src/components/vacancy/filters/ui/VacancyFilters.tsx b/app/frontend/src/components/vacancy/filters/ui/VacancyFilters.tsx new file mode 100644 index 00000000..3ebe56d8 --- /dev/null +++ b/app/frontend/src/components/vacancy/filters/ui/VacancyFilters.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import { Group, Select, TextInput, Button, CloseButton } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { Search } from "lucide-react"; + +interface VacancyFiltersProps { + experience: string; + region: string; + searchQuery: string; + regionsOptions: string[]; + experienceOptions: string[]; + onChangeFilters: (params: { experience?: string; region?: string; searchQuery?: string; }) => void; +} + +export const VacancyFilters: React.FC = ({ experience, region, searchQuery, regionsOptions, experienceOptions, onChangeFilters }) => { + const isMobile = useMediaQuery('(max-width: 768px)'); + const [tempSearchQuery, setTempSearchQuery] = useState(searchQuery); + + const handleSearch = () => { + onChangeFilters({ searchQuery: tempSearchQuery }) + } + + const handleKeyDown = (e:React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch() + } + } + + const handleClearSearch = () => { + setTempSearchQuery(''); + onChangeFilters({ searchQuery: '' }); + } + + return ( + + {/* Селекты */} + + ({ + value: region, + label: region + }))} + onChange={(value) => onChangeFilters({region: value || ''})} + style={{ minWidth: '200px', flexGrow: isMobile? 1 : 0 }} + checkIconPosition="right" + clearable + /> + + {/* Конец селектов */} + {/* Поиск по словам */} + + setTempSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + style={{ width: isMobile ? '100%' : 'auto' }} + rightSection={ + tempSearchQuery ? ( + + ) : null + } + /> + + + {/* Конец поиска по словам */} + + ) +} \ No newline at end of file diff --git a/app/frontend/src/components/vacancy/model/types.ts b/app/frontend/src/components/vacancy/model/types.ts new file mode 100644 index 00000000..b83b7fd1 --- /dev/null +++ b/app/frontend/src/components/vacancy/model/types.ts @@ -0,0 +1,21 @@ +export interface VacancyProps { + title: string; + description: string; + company: string; + salary: string; + city: string; + id: number; + key_skills: string[]; +} + +export interface VacancyWithDynamics extends VacancyProps { + dynamics: { + count: number; + month: string; + year: number; + }[]; +} + +export interface VacancyWithExperience extends VacancyWithDynamics { + minExperience: number; +} \ No newline at end of file