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 (
+
+ {/* Селекты */}
+
+
+ {/* Конец селектов */}
+ {/* Поиск по словам */}
+
+ setTempSearchQuery(e.target.value)}
+ onKeyDown={handleKeyDown}
+ style={{ width: isMobile ? '100%' : 'auto' }}
+ rightSection={
+ tempSearchQuery ? (
+
+ ) : null
+ }
+ />
+ }
+ color="#20B0B4"
+ style={{ width: isMobile ? '100%' : 'auto', flexShrink: 0 }}
+ onClick={handleSearch}
+ >
+ Найти
+
+
+ {/* Конец поиска по словам */}
+
+ )
+}
\ 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