copied the code from the working repo

This commit is contained in:
2024-11-30 16:00:48 +03:00
parent f22b92869b
commit 15ac0cb9b8
148 changed files with 23342 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
{
"root": true,
"extends": "next/core-web-vitals"
}

35
mtucijobsweb2/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

16
mtucijobsweb2/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:21.5.0-alpine3.19
ARG APP_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV APP_BASE_URLL=$APP_BASE_URL
WORKDIR /app
COPY package*.json ./
RUN npm i
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

36
mtucijobsweb2/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

161
mtucijobsweb2/api/api.ts Normal file
View File

@@ -0,0 +1,161 @@
import { ExtendedJobData, JobData, LoginData, ResumeData, ResumeDataWithoutSkills, SearchFilters } from "@/types/types";
import { $Api, $mtuciApi } from "./axiosInstance";
import qs from 'qs';
import { AxiosResponse } from "axios";
import { message } from "antd";
export const sendJobs = async (postData: JobData) => {
try {
const response = await $Api.post(`/jobs/`, postData, {
headers: {
'Content-Type': 'application/json',
},
});
return response;
} catch (error) {
console.error('Error post jobs:', error);
throw error;
}
};
export const login = async (values: LoginData) => {
try {
const response = await $Api.post('/login', qs.stringify(values), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
// Сохраняем токен в localStorage
const { access_token, token_type } = response.data;
localStorage.setItem('token', `${token_type} ${access_token}`);
return response.data; // Верните данные ответа
} catch (error) {
console.error('Error logging in:', error);
throw error;
}
};
export const fetchJobs = async (): Promise<ExtendedJobData[]> => {
try {
const response = await $Api.get('/jobs/');
return response.data;
} catch (error) {
console.error('Error fetching jobs:', error);
throw error;
}
};
export const fetchJobById = async (id: number): Promise<ExtendedJobData> => {
try {
const response = await $Api.get(`/jobs/${id}`);
return response.data;
} catch (error) {
console.error('Error fetching jobs:', error);
throw error;
}
};
export const updateJob = async (id: number, job: JobData) => {
try {
const response = await $Api.put(`/jobs/${id}`, job);
return response.data;
} catch (error) {
console.error('Error put jobs:', error);
throw error;
}
};
export const fetchJobsMatches = async (id: number): Promise<ResumeData[]> => {
try {
const response = await $Api.get(`/jobs/matches/${id}`);
return response.data;
} catch (error) {
console.error('Error fetching jobs:', error);
throw error;
}
};
export const deleteJob = async (id: number): Promise<void> => {
try {
await $Api.delete(`/jobs/${id}`);
} catch (error) {
console.error('Error deleting job:', error);
throw error;
}
};
export const fetchHardSkills = async (
): Promise<{ Hard_skillID: number; Title: string }[]> => {
try {
const response = await $mtuciApi.get(`/services/hardskills/`);
return response.data;
} catch (error) {
console.error('Error fetching hard skills:', error);
throw error;
}
};
export const fetchJobsHardSkills = async (id: number): Promise<string[]> => {
try {
const response: AxiosResponse<{ Hard_skillID: number; Title: string }[]> =
await $Api.get(`/jobs/hardskills/${id}`);
// Извлекаем массив названий хардскиллов из ответа
const skills = response.data.map(skill => skill.Title);
return skills;
} catch (error) {
console.error('Error fetching hardskills:', error);
throw error;
}
};
export const downloadResume = async (filename: string): Promise<void> => {
try {
const response: AxiosResponse<Blob> = await $mtuciApi.get(
`/services/resume/${filename}`,
{
responseType: 'blob', // Указываем тип ответа blob для скачивания файлов
}
);
// Проверяем, что ответ содержит данные
if (!response.data) {
message.error('У пользователя нет резюме.');
return;
}
// Создаем ссылку для скачивания файла
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename); // Устанавливаем имя файла для сохранения
document.body.appendChild(link);
link.click();
link.remove();
} catch (error) {
console.error('Error downloading resume:', error);
message.error('Не удалось скачать резюме.');
}
};
export const searchStudents = async (
queryString: string
): Promise<ResumeDataWithoutSkills[]> => {
try {
const response = await $Api.get(`/jobs/students-search/?${queryString}`);
return response.data;
} catch (error) {
console.error('Error fetching students:', error);
throw error;
}
};

View File

@@ -0,0 +1,31 @@
import axios from 'axios';
import 'dotenv/config'
// Настройка Axios без заголовка Content-Type
export const $Api = axios.create({
baseURL: `${process.env.APP_BASE_URL}`,
// Убедитесь, что переменная окружения правильно задана
});
// Добавляем интерсептор для добавления токена в заголовок запроса
$Api.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = token;
}
return config;
},
error => Promise.reject(error)
);
export const $mtuciApi = axios.create({
baseURL: `${process.env.APP_BASE_URL}`,
headers: {
Accept: '*/*',
'X-API-KEY':
'SbRHOVoK97GKCx3Lqx6hKXLbZZJEd0GTGbeglXdpK9PhSB9kpr4eWCsuIIwnD6F2mgpTDlVHFCRbeFmuSfqBVsb12lNwF3P1tmdxiktl7zH9sDS2YK7Pyj2DecCWAZ3n',
},
});

View File

@@ -0,0 +1,7 @@
import VacansyPage from "@/fsd/pages/VacansyPage";
const Create = () => {
return <><VacansyPage/></>;
};
export default Create;

View File

@@ -0,0 +1,9 @@
import EditVacancyPage from '@/fsd/pages/EditVacancyPage';
import React from 'react';
const Edit = ({ params }: { params: { id: number } }) => {
return <EditVacancyPage id={params.id} />;
};
export default Edit;

View File

@@ -0,0 +1,36 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
import '../fsd/app/styles/global.scss';
import AppHeader from '@/fsd/widgets/Header/Header';
import ClientProvider from '@/fsd/app/provider/QueryClient';
import { AuthProvider } from '@/fsd/app/provider/AuthContext';
import AuthGuard from "@/fsd/app/provider/AuthGuard"
import RedirectPage from '@/fsd/pages/RedirectPage';
export const metadata: Metadata = {
title: 'MTUCI JOBS',
description: 'Project for mtuci jobs',
icons: {
icon: '/favicon.svg'
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ClientProvider>
<html lang='en'>
<AuthProvider>
<body className={inter.className}>
<AppHeader />
<AuthGuard>{children}</AuthGuard>
</body>
</AuthProvider>
</html>
</ClientProvider>
);
}

View File

@@ -0,0 +1,7 @@
import LoginPage from "@/fsd/pages/LoginPage";
const Login = () => {
return <><LoginPage/></>;
};
export default Login;

View File

@@ -0,0 +1,12 @@
import LoginPage from "@/fsd/pages/LoginPage";
import RedirectPage from "@/fsd/pages/RedirectPage";
import VacansyPage from "@/fsd/pages/VacansyPage";
export default function Home() {
return (
<>
<RedirectPage/>
</>
)
}

View File

@@ -0,0 +1,8 @@
import ResumePage from '@/fsd/pages/ResumePage';
import React from 'react'
const ResumeList = ({ params }: { params: { id: number } }) => {
return <ResumePage id = {params.id}/>;
};
export default ResumeList;

View File

@@ -0,0 +1,11 @@
import SearchResumePage from '@/fsd/pages/SearchResumePage';
const Search = () => {
return (
<>
<SearchResumePage />
</>
);
};
export default Search;

View File

@@ -0,0 +1,11 @@
import ViewVacansyPage from '@/fsd/pages/ViewVacansyPage';
const View = () => {
return (
<>
<ViewVacansyPage />
</>
);
};
export default View;

View File

@@ -0,0 +1,56 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
loading: boolean;
login: (token: string, expiresAt: number) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
const expiresAt = localStorage.getItem('expiresAt');
if (token && expiresAt && new Date().getTime() < Number(expiresAt)) {
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
setLoading(false); // завершение проверки
}, []);
const login = (token: string, expiresInSeconds: number) => {
const expiresAt = new Date().getTime() + expiresInSeconds * 1000;
localStorage.setItem('expiresAt', expiresAt.toString());
setIsAuthenticated(true);
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('expiresAt');
setIsAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,30 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect } from 'react';
import { useAuth } from '@/fsd/app/provider/AuthContext';
const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading) {
if (!isAuthenticated && pathname !== '/login') {
router.replace('/login');
}
}
}, [loading, isAuthenticated, pathname, router]);
if (loading) {
return <div>Loading...</div>; // Показываем загрузку
}
if (!isAuthenticated && pathname !== '/login') {
return null; // Не рендерим контент до завершения редиректа
}
return <>{children}</>;
};
export default AuthGuard;

View File

@@ -0,0 +1,14 @@
'use client';
import { PropsWithChildren } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
export const queryClient = new QueryClient();
const ClientProvider = ({ children }: PropsWithChildren) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
export default ClientProvider;

View File

@@ -0,0 +1,25 @@
'use client';
import React, { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/fsd/app/provider/AuthContext';
const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isAuthenticated) {
router.replace('/login');
}
}, [isAuthenticated, router]);
if (!isAuthenticated) {
return null; // Или можно отобразить загрузочный экран
}
return <>{children}</>;
};
export default AuthGuard;

View File

@@ -0,0 +1,182 @@
body {
background-image: url('../../../public/backgorund.png');
font-family: 'Montserrat', sans-serif;
}
/* Reset and base styles */
* {
padding: 0px;
margin: 0px;
border: none;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Links */
a,
a:link,
a:visited {
text-decoration: none;
}
a:hover {
text-decoration: none;
}
/* Common */
aside,
nav,
footer,
header,
section,
main {
display: block;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
font-size: inherit;
font-weight: inherit;
}
ul,
ul li {
list-style: none;
}
img {
vertical-align: top;
}
img,
svg {
max-width: 100%;
height: auto;
}
address {
font-style: normal;
}
/* Form */
input,
textarea,
button,
select {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: transparent;
}
input::-ms-clear {
display: none;
}
button,
input[type='submit'] {
display: inline-block;
box-shadow: none;
background-color: transparent;
background: none;
cursor: pointer;
}
input:focus,
input:active,
button:focus,
button:active {
outline: none;
}
button::-moz-focus-inner {
padding: 0;
border: 0;
}
label {
cursor: pointer;
}
legend {
display: block;
}
.ant-menu-item-selected {
background-color: #372579 !important;
}
.ant-input-outlined {
&:focus {
border-color: #372579;
outline: none;
box-shadow: 0 0 0 2px rgba(55, 37, 121, 0.1);
}
&:hover {
border-color: #372579;
}
}
.ant-select .ant-select-selector {
&:hover {
border-color: #372579 !important;
}
&:focus-within {
border-color: #372579 !important;
outline: none !important;
box-shadow: 0 0 0 2px rgba(55, 37, 121, 0.1) !important;
}
}
.ant-input-number-outlined {
&:hover {
border-color: #372579 !important;
}
&:focus-within {
border-color: #372579 !important;
outline: none !important;
box-shadow: 0 0 0 2px rgba(55, 37, 121, 0.1) !important;
}
}
.ant-checkbox-inner {
background-color: #ffffff;
border: 2px solid #d9d9d9;
transition: all 0.3s;
}
.ant-checkbox-checked .ant-checkbox-inner{
background-color: #372579 !important;
border-color: #372579 !important;
outline: none !important;
box-shadow: 0 0 0 2px rgba(55, 37, 121, 0.1) !important;
&:hover {
border-color: #372579 !important;
background: #47309c !important;
}
}
:where(.css-dev-only-do-not-override-1uq9j6g).ant-checkbox-wrapper:not(.ant-checkbox-wrapper-disabled):hover .ant-checkbox-inner, :where(.css-dev-only-do-not-override-1uq9j6g).ant-checkbox:not(.ant-checkbox-disabled):hover .ant-checkbox-inner{
border-color: #372579;
}
.ant-checkbox:hover .ant-checkbox-inner,
.ant-checkbox-inner:focus {
border-color: #372579; /* цвет бордюра при ховере */
border-color: #372579 !important;
background-color: #f0f0f0; /* цвет фона при ховере */
outline: none; /* убираем outline при фокусе */
box-shadow: 0 0 0 2px rgba(55, 37, 121, 0.1);
}

View File

@@ -0,0 +1,15 @@
import Resume from '../widgets/Resume/Resume';
import EditVacancy from '../widgets/ViewVacansy/EditVacancy/EditVacancy';
type Props = {
id: number;
};
const EditVacancyPage = (props: Props) => {
return (
<>
<EditVacancy id={props.id} />
</>
);
};
export default EditVacancyPage;

View File

@@ -0,0 +1,12 @@
import LoginComponent from "../widgets/Login/Login";
type Props = {};
const LoginPage = (props: Props) => {
return (
<>
<LoginComponent />
</>
);
};
export default LoginPage;

View File

@@ -0,0 +1,12 @@
import Redirect from '../widgets/Redirect/Redirect';
type Props = {};
const RedirectPage = (props: Props) => {
return (
<>
<Redirect />
</>
);
};
export default RedirectPage;

View File

@@ -0,0 +1,15 @@
import Resume from '../widgets/Resume/Resume';
type Props = {
id: number
};
const ResumePage = (props: Props) => {
return (
<>
<Resume id = {props.id}/>
</>
);
};
export default ResumePage;

View File

@@ -0,0 +1,13 @@
import SearchResume from "../widgets/SearchResume/SearchResume";
type Props = {};
const SearchResumePage = (props: Props) => {
return (
<>
<SearchResume />
</>
);
};
export default SearchResumePage;

View File

@@ -0,0 +1,12 @@
import Vacansy from "../widgets/Vacansy/Vacansy"
type Props = {};
const VacansyPage = (props: Props) => {
return (
<>
<Vacansy />
</>
);
};
export default VacansyPage

View File

@@ -0,0 +1,14 @@
import ViewVacansy from "../widgets/ViewVacansy/ViewVacansy";
type Props = {};
const ViewVacansyPage = (props: Props) => {
return (
<>
<ViewVacansy />
</>
);
};
export default ViewVacansyPage;

View File

@@ -0,0 +1,69 @@
.header {
background: #BDB1E7;
display: flex;
align-items: center;
padding: 0 20px;
}
.logo {
color: white;
font-size: 20px;
font-weight: bold;
margin-right: 20px;
cursor: pointer;
}
.menu {
background: #BDB1E7; /* Задает фон меню такой же, как и у шапки */
flex: 1;
}
.burgerIcon {
display: none;
font-size: 24px;
color: white;
cursor: pointer;
}
.logoutButton {
background-color: #372579;
}
.logoutButton:hover {
background: #47309C !important;
}
.logoutButtonBurger {
background-color: #372579;
color:#BDB1E7;
margin-top:20px;
width:100%;
}
.logoutButtonBurger:hover {
background: #47309C !important;
}
// .menuBurger{
// color:white;
// background-color:#372579;
// }
@media screen and (max-width: 580px) {
.burgerIcon {
display: block;
position: absolute;
right:10px;
}
.menu {
display: none; /* Скрываем горизонтальное меню на мобильных */
}
}
@media screen and (max-width: 748px) {
.logo{
line-height: 20px;
text-align: center;
}
}

View File

@@ -0,0 +1,147 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Layout, Menu, Button, Drawer } from 'antd';
import { MenuOutlined } from '@ant-design/icons';
import Link from 'next/link';
import styles from './Header.module.scss';
import { useRouter, usePathname } from 'next/navigation';
import { useAuth } from '@/fsd/app/provider/AuthContext';
const { Header } = Layout;
const AppHeader: React.FC = () => {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated, logout } = useAuth();
const [currentKey, setCurrentKey] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false); // Заменяем visible на open
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (pathname === '/create') {
setCurrentKey('1');
} else if (pathname === '/view') {
setCurrentKey('2');
} else if (pathname === '/search') {
setCurrentKey('3');
} else {
setCurrentKey('');
}
}, [pathname]);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 580);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleLogout = () => {
logout();
router.push('/login');
};
const handleLogoClick = () => {
setCurrentKey('1');
router.push('/create');
};
const showDrawer = () => {
setDrawerOpen(true);
};
const closeDrawer = () => {
setDrawerOpen(false);
};
const menuItems = [
{
key: '1',
label: (
<Link href='/create' className={styles.list_item}>
Создание вакансии
</Link>
),
},
{
key: '2',
label: (
<Link href='/view' className={styles.list_item}>
Просмотр вакансий
</Link>
),
},
{
key: '3',
label: (
<Link href='/search' className={styles.list_item}>
Поиск резюме
</Link>
),
},
];
return (
<Header className={styles.header}>
<div className={styles.logo} onClick={handleLogoClick}>
MTUCI JOBS
</div>
{isAuthenticated && (
<>
{isMobile ? (
<>
<div className={styles.burgerIcon} onClick={showDrawer}>
<MenuOutlined />
</div>
<Drawer
title='Меню'
placement='right'
onClose={closeDrawer}
open={drawerOpen} // Используем "open" вместо "visible"
>
<Menu
mode='vertical'
selectedKeys={[currentKey]}
items={menuItems} // Передаем элементы меню сюда
onClick={closeDrawer}
className={styles.menuBurger}
/>
<Button
type='primary'
onClick={handleLogout}
className={styles.logoutButtonBurger}
>
Выйти
</Button>
</Drawer>
</>
) : (
<>
<Menu
theme='dark'
mode='horizontal'
selectedKeys={[currentKey]}
items={menuItems}
className={styles.menu}
/>
<Button
type='primary'
onClick={handleLogout}
className={styles.logoutButton}
>
Выйти
</Button>
</>
)}
</>
)}
</Header>
);
};
export default AppHeader;

View File

@@ -0,0 +1,6 @@
.loginButton{
background-color: #372579;
&:hover{
background: #47309C !important;
}
}

View File

@@ -0,0 +1,96 @@
'use client';
import { useState } from 'react';
import { Button, Form, Input, notification } from 'antd';
import { LoginData } from '../../../types/types';
import { login } from '@/api/api';
import { useRouter } from 'next/navigation';
import { useForm } from 'antd/es/form/Form';
import { useAuth } from '@/fsd/app/provider/AuthContext';
import style from './Login.module.scss'
const LoginComponent = () => {
const router = useRouter();
const { login: authLogin } = useAuth(); // Получаем метод login из контекста
const [form] = useForm();
const [loading, setLoading] = useState(false);
const onFinish = async (values: LoginData) => {
setLoading(true);
try {
const response = await login(values); // Отправляем запрос на логин
if (response) {
// Проверяем статус ответа
const token = response.access_token;
authLogin(token, 3600); // Устанавливаем токен в контексте
notification.success({
message: 'Вход в систему прошел успешно',
description: 'Вы успешно вошли в систему.',
});
await router.push('/create'); // Перенаправляем на страницу после успешного входа
await form.resetFields();
} else {
// Если сервер возвращает ошибку с кодом 200 (например, если в теле ответа есть ошибка)
notification.error({
message: 'Ошибка входа',
description:
'Произошла ошибка при входе в систему. Пожалуйста, попробуйте снова.',
});
}
} catch (error) {
console.error('Login error:', error);
notification.error({
message: 'Ошибка входа',
description:
'Произошла ошибка при входе в систему. Пожалуйста, попробуйте снова.',
});
} finally {
setLoading(false);
}
};
return (
<div style={{ maxWidth: '400px', margin: 'auto', padding: '20px' }}>
<h2>Вход</h2>
<Form
form={form}
name='login'
initialValues={{ remember: true }}
onFinish={onFinish}
layout='vertical'
>
<Form.Item
label='Имя пользователя'
name='username'
rules={[
{
required: true,
message: 'Пожалуйста, введите свое имя пользователя!',
},
]}
>
<Input />
</Form.Item>
<Form.Item
label='Пароль'
name='password'
rules={[
{ required: true, message: 'Пожалуйста, введите свой пароль!' },
]}
>
<Input.Password />
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit' loading={loading} className={style.loginButton}>
Войти
</Button>
</Form.Item>
</Form>
</div>
);
};
export default LoginComponent;

View File

@@ -0,0 +1,22 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
const Redirect = () => {
const router = useRouter();
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
router.push('/create');
} else {
router.replace('/login');
}
}, [router]);
return null;
}
export default Redirect

View File

@@ -0,0 +1,13 @@
.not_found {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.card_wrapper {
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
margin-top:100px;
margin-left:100px;
}

View File

@@ -0,0 +1,110 @@
'use client';
import { ResumeData } from '@/types/types';
import { useQuery } from 'react-query';
import React from 'react';
import { Card, Spin, Button } from 'antd';
import style from './Resume.module.scss';
import {
fetchJobsMatches,
fetchJobsHardSkills,
downloadResume,
} from '@/api/api';
interface ResumeProps {
id: number;
}
const Resume: React.FC<ResumeProps> = ({ id }) => {
// Получение данных о резюме
const {
data: resumeData,
error: resumeError,
isLoading: resumeLoading,
} = useQuery<ResumeData[]>(['matches', id], () => fetchJobsMatches(id), {
refetchOnWindowFocus: false,
retry: false,
});
// Получение данных о хардскиллах
const {
data: hardSkillsData,
error: hardSkillsError,
isLoading: hardSkillsLoading,
} = useQuery<string[]>(['hardSkills', id], () => fetchJobsHardSkills(id), {
refetchOnWindowFocus: false,
retry: false,
});
if (resumeLoading || hardSkillsLoading) return <Spin size='large' />;
if (resumeError || hardSkillsError)
return (
<div className={style.not_found}>
Произошла ошибка при загрузке данных
</div>
);
// Функция для обработки клика по кнопке скачивания
const handleDownload = (filename: string) => {
downloadResume(filename);
};
console.log(resumeData)
return (
<div>
{resumeData && resumeData.length > 0 ? (
<div className={style.card_wrapper}>
{resumeData.map((resume, index) => (
<Card
key={index}
title={resume.Name}
bordered={false}
className={style.card_item}
>
<p>
<strong>Тип:</strong> {resume.Type}
</p>
<p>
<strong>Группа:</strong> {resume.Group}
</p>
<p>
<strong>Занятость:</strong>{' '}
{resume.Time.length > 1
? resume.Time.join(', ')
: resume.Time[0]}
</p>
<p>
<strong>Soft Skills:</strong> {resume.Soft_skills}
</p>
<p>
<strong>Номер телефона:</strong> {resume.Phone_number}
</p>
<p>
<strong>Факультет:</strong> {resume.Faculties}
</p>
<p>
<strong>Email:</strong> {resume.Email}
</p>
{hardSkillsData && hardSkillsData.length > 0 && (
<p>
<strong>Hard Skills:</strong> {hardSkillsData.join(', ')}
</p>
)}
<Button
type='primary'
onClick={() => handleDownload(resume.Link)}
>
Скачать PDF
</Button>
</Card>
))}
</div>
) : (
<div>Подходящих резюме не найдено</div>
)}
</div>
);
};
export default Resume;

View File

@@ -0,0 +1,51 @@
.filter_section {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.form{
width:500px;
margin-top:50px;
position:relative;
left:50%;
transform: translate(-50%);
}
.card_wrapper {
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
margin: 100px;
}
.search_button{
background-color: #372579;
&:hover{
background: #47309C !important;
}
}
.spin{
position:absolute;
left:50%;
top:50%;
transform: translate(-50%, -50%);
}
.empty{
color:black;
position:absolute;
left:50%;
transform: translateX( -50%);
font-size:20px;
}
@media screen and (max-width: 580px) {
.form{
width:350px;
}
}

View File

@@ -0,0 +1,149 @@
'use client'
import React, { useState, useEffect } from 'react';
import { useQuery } from 'react-query';
import { fetchHardSkills, searchStudents } from '@/api/api';
import { Input, Button, Spin, List, Select, Card } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import style from './SearchResume.module.scss';
import { ResumeDataWithoutSkills } from '@/types/types';
const { Option } = Select;
const SearchResume: React.FC = () => {
const [year, setYear] = useState<number | undefined>();
const [time, setTime] = useState<string[]>([]);
const [hardskills, setHardskills] = useState<string[]>([]);
const [searchResults, setSearchResults] = useState<
ResumeDataWithoutSkills[] | null
>(null);
// Функция для формирования строки запроса
const buildQueryParams = () => {
const params = new URLSearchParams();
if (year !== undefined) {
params.append('year', year.toString());
}
if (time.length > 0) {
time.forEach(t => params.append('time', t));
}
if (hardskills.length > 0) {
hardskills.forEach(skill => params.append('hardskills', skill));
}
return params.toString();
};
const { data, error, isLoading, refetch } = useQuery<
ResumeDataWithoutSkills[]
>(
['searchStudents', buildQueryParams()],
() => {
const queryString = buildQueryParams();
return searchStudents(queryString);
},
{ enabled: false, refetchOnWindowFocus: false, retry: false }
);
const { data: hardSkills } = useQuery(
['hardSkills'],
() => fetchHardSkills(),
{
refetchOnWindowFocus: false,
retry: false,
}
);
// Используем useEffect для обновления searchResults, когда запрос завершен
useEffect(() => {
if (data) {
setSearchResults(data);
}
}, [data]);
const handleSearch = () => {
refetch();
};
return (
<div className={style.container}>
<div className={style.form}>
<Select
placeholder='Выберите Курс'
style={{ width: '100%', marginBottom: 20 }}
onChange={value => setYear(value)}
value={year}
>
<Option value={1}>1</Option>
<Option value={2}>2</Option>
<Option value={3}>3</Option>
<Option value={4}>4</Option>
</Select>
<Select
mode='multiple'
placeholder='Выберите занятость'
style={{ width: '100%', marginBottom: 20 }}
onChange={value => setTime(value)}
value={time}
>
<Option value='20'>20</Option>
<Option value='30'>30</Option>
<Option value='40'>40</Option>
</Select>
<Select
mode='multiple'
style={{ width: '100%', marginBottom: 20 }}
placeholder='Выбрать hardskills'
onChange={value => setHardskills(value)}
value={hardskills}
>
{hardSkills?.map(skill => (
<Option key={skill.Hard_skillID} value={skill.Title}>
{skill.Title}
</Option>
))}
</Select>
<Button type='primary' onClick={handleSearch} style={{ width: '100%' }} className={style.search_button}>
Поиск
</Button>
</div>
<div style={{ marginTop: 20, color: 'white' }}>
{isLoading && <Spin size='large' className={style.spin}/>}
{searchResults && searchResults.length > 0 ? (
<div className={style.card_wrapper}>
{searchResults.map((item: ResumeDataWithoutSkills) => (
<Card
key={item.Email}
title={item.Name}
className={style.card}
bordered={false}
style={{ marginBottom: 20 }}
>
<p className={style.item}>Тип: {item.Type}</p>
<p className={style.item}>Группа: {item.Group}</p>
<p className={style.item}>Занятость: {item.Time.join(', ')}</p>
<p className={style.item}>Soft Skills: {item.Soft_skills}</p>
<p className={style.item}>Номер телефона: {item.Phone_number}</p>
<p className={style.item}>Факультет: {item.Faculties}</p>
<p className={style.item}>
Ссылка: <a href={item.Link}>{item.Link}</a>
</p>
<p className={style.item}>Email: {item.Email}</p>
</Card>
))}
</div>
) : (
!isLoading && <div className={style.empty}>Резюме не найдены</div>
)}
</div>
</div>
);
};
export default SearchResume;

View File

@@ -0,0 +1,25 @@
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width:100%;
// background-color: #f0f2f5; /* Легкий серый фон для контраста */
}
.form {
width: 400px; /* Ширина формы */
padding: 24px; /* Внутренние отступы */
background: white; /* Белый фон формы */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* Легкая тень */
border-radius: 8px; /* Скругленные углы */
margin-top:50px;
margin-bottom: 50px;
}
.vacansy_btn{
background-color: #372579;
&:hover{
background: #47309C !important;
}
}

View File

@@ -0,0 +1,211 @@
'use client';
import React, { useState } from 'react';
import {
Form,
Input,
Button,
Checkbox,
InputNumber,
Select,
notification,
} from 'antd';
import { useForm } from 'antd/es/form/Form';
import styles from './Vacansy.module.scss';
import { JobData } from '@/types/types';
import { fetchHardSkills, sendJobs } from '@/api/api';
import { useQuery } from 'react-query';
const { TextArea } = Input;
const { Option } = Select;
const Vacansy: React.FC = () => {
const [form] = useForm();
const [isSalaryNegotiable, setIsSalaryNegotiable] = useState(false); // Состояние для чекбокса
const { data: hardSkills, isLoading } = useQuery(
['hardSkills'],
() => fetchHardSkills(),
{
refetchOnWindowFocus: false,
retry: false,
}
);
const onFinish = async (values: JobData) => {
const jobData: JobData = {
Job_name: values.Job_name,
Year: values.Year,
Qualification: values.Qualification || false,
Time: Array.isArray(values.Time) ? values.Time : [values.Time],
Company_name: values.Company_name,
Salary: isSalaryNegotiable ? 0 : values.Salary, // Установка зарплаты в 0, если выбрана опция "уточняется"
Email: values.Email,
// Website: values.Website, // Новое поле для ссылки на сайт компании
Archive: false,
Responsibilities: values.Responsibilities,
Hardskills: values.Hardskills,
};
try {
const result = await sendJobs(jobData);
console.log('Job successfully posted:', result);
notification.success({
message: 'Вакансия успешно создана',
description: 'Вы можете посмотреть её на странице вакансий',
});
await form.resetFields();
setIsSalaryNegotiable(false); // Сброс чекбокса
} catch (error) {
console.error('Failed to post job:', error);
notification.error({
message: 'Что-то пошло не так!',
description: 'Попробуйте снова',
});
}
};
return (
<div className={styles.container}>
<Form
form={form}
layout='vertical'
onFinish={onFinish}
className={styles.form}
>
<Form.Item
name='Job_name'
label='Название вакансии'
rules={[
{
required: true,
message: 'Пожалуйста, введите название вакансии!',
},
]}
>
<Input />
</Form.Item>
<Form.Item
name='Year'
label='На каком курсе должен быть кандидат на вакансию?'
rules={[{ required: true, message: 'Пожалуйста, укажите курс!' }]}
>
<Select placeholder='Выберите курс'>
<Option value='1'>1</Option>
<Option value='2'>2</Option>
<Option value='3'>3</Option>
<Option value='4'>4</Option>
<Option value='5'>5</Option>
<Option value='6'>6</Option>
</Select>
</Form.Item>
<Form.Item name='Qualification' valuePropName='checked'>
<Checkbox>Нужен ли опыт работы?</Checkbox>
</Form.Item>
<Form.Item
name='Time'
label='Занятость'
rules={[
{ required: true, message: 'Пожалуйста, укажите занятость!' },
]}
>
<Select
mode='multiple'
style={{ width: '100%' }}
placeholder='Выбрать часы'
>
<Option value='20'>20 ч/н</Option>
<Option value='30'>30 ч/н</Option>
<Option value='40'>40 ч/н</Option>
</Select>
</Form.Item>
<Form.Item name='Salary' label='Оклад руб/мес'>
<InputNumber
min={0}
style={{ width: '100%' }}
disabled={isSalaryNegotiable}
/>
</Form.Item>
<Form.Item name='isSalaryNegotiable' valuePropName='checked'>
<Checkbox onChange={e => setIsSalaryNegotiable(e.target.checked)}>
Зарплата уточняется после собеседования
</Checkbox>
</Form.Item>
<Form.Item name='Company_name' label='Название компании'>
<Input />
</Form.Item>
<Form.Item name='Website' label='Сайт компании (опционально)'>
<Input placeholder='https://company-site.com' />
</Form.Item>
<Form.Item
name='Email'
label='Электронная почта для связи и резюме.'
rules={[
{
required: true,
message: 'Пожалуйста, введите адрес электронной почты!',
},
{
type: 'email',
message:
'Пожалуйста, введите действительный адрес электронной почты!',
},
]}
>
<Input />
</Form.Item>
<Form.Item
name='Responsibilities'
label='Обязанности'
rules={[
{ required: true, message: 'Пожалуйста, укажите обязанности!' },
]}
>
<TextArea rows={4} />
</Form.Item>
<Form.Item
name='Hardskills'
label='Hardskills'
rules={[
{ required: true, message: 'Пожалуйста, укажите hardskills!' },
]}
>
<Select
mode='multiple'
style={{ width: '100%' }}
placeholder='Выбрать hardskills'
loading={isLoading}
>
{hardSkills?.map(skill => (
<Option key={skill.Hard_skillID} value={skill.Title}>
{skill.Title}
</Option>
))}
</Select>
</Form.Item>
<Form.Item>
<Button
type='primary'
htmlType='submit'
className={styles.vacansy_btn}
>
Отправить
</Button>
</Form.Item>
</Form>
</div>
);
};
export default Vacansy;

View File

@@ -0,0 +1,15 @@
.edit_btn{
background-color: #372579;
&:hover{
background: #47309C !important;
}
}
.spin{
position:absolute;
left:50%;
top:50%;
transform: translate(-50%, -50%);
}
.text{
font-size:25px;
}

View File

@@ -0,0 +1,141 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useQuery, useMutation } from 'react-query';
import { Form, Input, Button, notification, Select, Spin } from 'antd';
import { ExtendedJobData, JobData } from '@/types/types';
import { useEffect } from 'react';
import { fetchJobById, updateJob } from '@/api/api';
import { Option } from 'antd/es/mentions';
import style from './EditVacancy.module.scss'
interface EditVacancyProps {
id: number;
}
const EditVacancy: React.FC<EditVacancyProps> = ({id}) => {
const router = useRouter();
const [form] = Form.useForm();
const { data, error, isLoading } = useQuery<ExtendedJobData>(
['job', id],
() => fetchJobById(Number(id)),
{
enabled: !!id,
}
);
useEffect(() => {
if (data) {
form.setFieldsValue({
Job_name: data.Job_name || '',
Year: data.Year || '',
Qualification: data.Qualification || false,
Time: data.Time || [''],
Soft_skills: data.Soft_skills || '',
Salary: data.Salary || 0,
Email: data.Email || '',
Archive: data.Archive || false,
Responsibilities: data.Responsibilities || '',
Hardskills: ['React', 'JavaScript'],
});
}
}, [data]);
const mutation = useMutation((updatedJob: JobData) => updateJob(Number(id), updatedJob), {
onSuccess: () => {
notification.success({
message: 'Обновление прошло успешно',
description: 'Вакансия была успешно обновлена.',
});
router.push('/view'); // Перенаправление обратно на страницу списка вакансий
},
onError: () => {
notification.error({
message: 'Не удалось выполнить обновление',
description: 'Произошла ошибка при обновлении вакансии.',
});
},
});
const onFinish = (values: JobData) => {
mutation.mutate({ ...values}); // Обновление данных вакансии
};
if (isLoading) return <Spin size='large' className={style.spin} />;
if (error) return <div>Error loading job data</div>;
return (
<div style={{ maxWidth: '600px', margin: 'auto', padding: '20px' }}>
<h2 className={style.text}>Редактировать вакансию</h2>
<Form form={form} layout='vertical' onFinish={onFinish}>
<Form.Item
name='Job_name'
label='Название должности'
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item name='Year' label='Курс' rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name='Qualification'
label='Квалификация'
valuePropName='checked'
>
<Input type='checkbox' />
</Form.Item>
<Form.Item name='Time' label='Занятость' rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name='Soft_skills' label='Soft Skills'>
<Input />
</Form.Item>
<Form.Item name='Salary' label='Оклад' rules={[{ required: true }]}>
<Input type='number' />
</Form.Item>
<Form.Item name='Email' label='Email' rules={[{ required: true }]}>
<Input type='email' />
</Form.Item>
<Form.Item name='Archive' label='Archive' valuePropName='checked'>
<Input type='checkbox' />
</Form.Item>
<Form.Item name='Responsibilities' label='Обязанности'>
<Input />
</Form.Item>
<Form.Item
name='Hardskills'
label='Hardskills'
rules={[
{
required: true,
message: 'Пожалуйста, укажите свои профессиональные навыки!',
},
]}
>
<Select
mode='tags'
style={{ width: '100%' }}
placeholder='Выбрать hardskills'
>
<Option value='JavaScript'>JavaScript</Option>
<Option value='TypeScript'>TypeScript</Option>
<Option value='React'>React</Option>
</Select>
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit' loading={mutation.isLoading} className={style.edit_btn}>
Сохранить изменения
</Button>
</Form.Item>
</Form>
</div>
);
};
export default EditVacancy;

View File

@@ -0,0 +1,44 @@
.btn{
width:150px;
}
.btn_group{
display:flex;
flex-direction: column;
width:100px;
justify-content: space-around;
height:120px;
gap:10px;
margin-top:10px;
margin-bottom:30px;
position:relative;
}
.card_wrapper{
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.card{
min-height: 450px;
}
.spin{
position:absolute;
left:50%;
top:50%;
transform: translate(-50%, -50%);
}
.empty{
position:absolute;
left:50%;
top:50%;
transform: translate(-50%, -50%);
font-size:40px;
}
.text{
font-size:25px;
margin-top:40px;
margin-bottom: 20px;
font-weight: 700;
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useMutation, useQuery } from 'react-query';
import { deleteJob, fetchJobs, updateJob } from '../../../api/api';
import { Card, Spin, Alert, Button, notification } from 'antd';
import { ExtendedJobData } from '@/types/types';
import { queryClient } from '@/fsd/app/provider/QueryClient';
import { useRouter } from 'next/navigation';
import style from './ViewVacansy.module.scss';
const ViewVacansy = () => {
const router = useRouter();
const { data, error, isLoading } = useQuery<ExtendedJobData[]>(
'jobs',
fetchJobs,
{
refetchOnWindowFocus: false,
retry: false,
}
);
const mutation = useMutation(deleteJob, {
onSuccess: () => {
// Обновление списка вакансий после удаления
queryClient.invalidateQueries('jobs');
},
});
const archiveMutation = useMutation(
(job: ExtendedJobData) =>
updateJob(job.JobID, {
...job,
Archive: !job.Archive,
Hardskills: [],
}),
{
onSuccess: () => {
queryClient.invalidateQueries('jobs');
},
onError: () => {
notification.error({
message: 'Ошибка',
description: 'Не удалось снять вакансию с публикации',
});
},
}
);
const handleArchive = (job: ExtendedJobData) => {
archiveMutation.mutate(job);
};
const handleDelete = (id: number) => {
mutation.mutate(id);
};
const handleClick = (id: number) => {
router.push(`/resume/${id}`);
};
const handleEdit = (id: number) => {
router.push(`/editvacansy/${id}`);
};
if (isLoading) return <Spin size='large' className={style.spin} />;
if (error) {
return <div className={style.empty}>Нет доступных вакансий</div>;
}
// Разделение данных на активные и архивные вакансии
const activeJobs = data?.filter(job => !job.Archive);
const archivedJobs = data?.filter(job => job.Archive);
console.log(activeJobs);
return (
<div style={{ padding: '20px' }}>
<h3 className={style.text}>Активные вакансии:</h3>
<div
className={style.card_wrapper}
style={{
display: 'grid',
gap: '20px',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
}}
>
{activeJobs?.map((job, index) => (
<Card
key={index}
title={job.Job_name}
bordered={false}
className={style.card}
>
{/* Контент вакансии */}
<p>
<strong>Название компании:</strong> {job.Company_name}
</p>
<p>
<strong>Курс:</strong> {job.Year}
</p>
<p>
<strong>Квалификация:</strong>{' '}
{job.Qualification ? 'Нужна' : 'Не нужна'}
</p>
<p>
<strong>Занятость (часов в неделю):</strong>{' '}
{job.Time.length > 1 ? job.Time.join(', ') : job.Time[0]}
</p>
<p>
<strong>Оклад:</strong> {job.Salary}
</p>
<p>
<strong>Email:</strong> {job.Email}
</p>
<p>
<strong>Обязанности:</strong> {job.Responsibilities}
</p>
<div className={style.btn_group}>
<Button
onClick={() => handleEdit(job.JobID)}
className={style.btn}
>
Редактировать
</Button>
<Button
onClick={() => handleClick(job.JobID)}
className={style.btn}
>
Список резюме
</Button>
<Button onClick={() => handleArchive(job)} className={style.btn}>
Снять с публикации
</Button>
<Button
danger
onClick={() => handleDelete(job.JobID)}
className={style.btn}
>
Удалить
</Button>
</div>
</Card>
))}
</div>
<h3 className={style.text}>Архив:</h3>
<div
className={style.card_wrapper}
style={{
display: 'grid',
gap: '20px',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
}}
>
{archivedJobs?.map((job, index) => (
<Card key={index} title={job.Job_name} bordered={false}>
{/* Контент вакансии */}
<p>
<strong>Курс:</strong> {job.Year}
</p>
<p>
<strong>Квалификация:</strong> {job.Qualification ? 'Yes' : 'No'}
</p>
<p>
<strong>Занятость:</strong>{' '}
{job.Time.length > 1 ? job.Time.join(', ') : job.Time[0]}
</p>
<p>
<strong>Оклад:</strong> ${job.Salary}
</p>
<p>
<strong>Email:</strong> {job.Email}
</p>
<p>
<strong>Archive:</strong> {job.Archive ? 'Yes' : 'No'}
</p>
<p>
<strong>Обязанности:</strong> {job.Responsibilities}
</p>
<div className={style.btn_group}>
<Button
onClick={() => handleEdit(job.JobID)}
className={style.btn}
>
Редактировать
</Button>
<Button
onClick={() => handleClick(job.JobID)}
className={style.btn}
>
Список резюме
</Button>
<Button onClick={() => handleArchive(job)} className={style.btn}>
Опубликовать
</Button>
<Button danger onClick={() => handleDelete(job.JobID)}>
Удалить
</Button>
</div>
</Card>
))}
</div>
</div>
);
};
export default ViewVacansy;

View File

@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
env: {
APP_BASE_URL: process.env.APP_BASE_URL,
},
};
module.exports = nextConfig

5255
mtucijobsweb2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "mtucijobsweb2",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"antd": "^5.19.3",
"axios": "^1.7.2",
"dotenv": "^16.4.5",
"next": "13.5.6",
"qs": "^6.12.3",
"react": "^18",
"react-dom": "^18",
"react-query": "^3.39.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/qs": "^6.9.15",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "13.5.6",
"sass": "^1.77.8",
"typescript": "^5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,57 @@
<svg width="196" height="196" viewBox="0 0 196 196" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="98" cy="98" r="86.9577" fill="white"/>
<path d="M97.9814 195.985C87.6238 195.985 77.2661 195.985 66.909 195.985C63.2354 195.985 59.6227 195.31 56.1902 193.839C51.6739 191.94 47.9406 188.998 44.9295 185.138C34.7527 172.084 24.5156 159.092 14.2785 146.039C11.5688 142.607 8.8591 139.113 6.1494 135.681C1.69334 129.921 -0.414648 123.425 0.0675128 116.132C0.247833 113.375 0.910316 110.616 1.51253 107.92C5.90832 88.616 10.2443 69.3727 14.5196 50.1912C16.5668 41.06 21.6853 34.4411 29.9355 30.3966C48.6025 21.2655 67.2098 12.0726 85.8774 2.94147C93.9467 -0.980489 102.076 -0.980489 110.145 2.94147C128.813 12.0726 147.541 21.2042 166.208 30.3966C174.337 34.3798 179.456 40.9988 181.443 49.9462C186.08 70.4762 190.656 91.0062 195.233 111.536C197.159 120.299 195.473 128.327 189.994 135.375C176.987 152.106 163.919 168.775 150.792 185.383C145.252 192.369 137.906 195.862 129.114 195.923C118.756 196.046 108.399 195.985 97.9814 195.985ZM97.9814 178.151C105.569 178.151 113.216 178.151 120.804 178.151C122.429 178.151 124.055 178.029 125.681 177.722C132.667 176.435 138.327 172.942 142.723 167.304C146.457 162.524 150.19 157.805 153.863 153.025C159.523 145.794 165.244 138.562 170.905 131.269C176.384 124.222 178.312 116.255 176.384 107.491C173.133 92.4154 169.7 77.3396 166.268 62.2638C164.221 53.3169 159.162 46.7592 151.033 42.7147C137.544 36.0346 123.995 29.4161 110.447 22.7972C102.136 18.7528 93.8262 18.7528 85.5163 22.7972C72.0878 29.4161 58.5991 36.0346 45.1103 42.6535C36.9205 46.698 31.8019 53.2551 29.7547 62.2638C26.6834 75.8691 23.6125 89.4739 20.6015 103.079C19.9993 105.775 19.3368 108.41 19.0962 111.168C18.4337 118.829 20.6617 125.631 25.4192 131.637C34.6322 143.342 43.8456 155.047 52.9988 166.814C58.8999 174.413 66.5474 178.151 76.0622 178.151C83.3485 178.151 90.6348 178.151 97.9814 178.151Z" fill="url(#paint0_linear_84_894)"/>
<mask id="path-3-inside-1_84_894" fill="white">
<rect x="49.2" y="45.65" width="97.6" height="61" rx="3"/>
</mask>
<rect x="49.2" y="45.65" width="97.6" height="61" rx="3" stroke="url(#paint1_linear_84_894)" stroke-width="10" mask="url(#path-3-inside-1_84_894)"/>
<path d="M48.5421 112.415C48.9335 111.163 50.0934 110.31 51.4056 110.31H144.594C145.907 110.31 147.066 111.163 147.458 112.415L156.971 142.859C157.978 146.078 155.572 149.35 152.199 149.35H43.801C40.4278 149.35 38.0224 146.078 39.0286 142.859L48.5421 112.415Z" fill="url(#paint2_linear_84_894)"/>
<path d="M90.5812 130.72C90.6375 130.213 91.0655 129.83 91.5751 129.83H105.645C106.155 129.83 106.583 130.213 106.639 130.72L107.637 139.7C107.702 140.292 107.239 140.81 106.643 140.81H90.5773C89.9813 140.81 89.5176 140.292 89.5834 139.7L90.5812 130.72Z" fill="white"/>
<mask id="mask0_84_894" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="82" y="59" width="31" height="31">
<circle cx="97.39" cy="74.32" r="15.25" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_84_894)">
<path d="M103.795 72.795C103.795 76.3324 100.927 79.2 97.39 79.2C93.8526 79.2 90.985 76.3324 90.985 72.795C90.985 69.2576 93.8526 66.39 97.39 66.39C100.927 66.39 103.795 69.2576 103.795 72.795Z" fill="url(#paint3_linear_84_894)"/>
<path d="M86.0274 82.9346C86.6787 80.004 89.2779 77.919 92.2799 77.919H102.5C105.502 77.919 108.101 80.004 108.753 82.9346L110.2 89.448H84.58L86.0274 82.9346Z" fill="url(#paint4_linear_84_894)"/>
<rect x="96.109" y="79.2" width="2.562" height="2.562" rx="0.25" fill="white"/>
<path d="M96.7495 81.762H98.0305L98.671 89.448H96.109L96.7495 81.762Z" fill="white"/>
</g>
<path d="M106.429 90.1565C97.6762 95.2099 86.4841 92.211 81.4307 83.4583C76.3773 74.7055 79.3762 63.5134 88.129 58.46C96.8817 53.4066 108.074 56.4055 113.127 65.1583C118.181 73.911 115.182 85.1031 106.429 90.1565ZM89.9693 61.6475C82.977 65.6846 80.5812 74.6256 84.6182 81.6179C88.6553 88.6103 97.5963 91.006 104.589 86.969C111.581 82.932 113.977 73.9909 109.94 66.9986C105.903 60.0063 96.9616 57.6105 89.9693 61.6475Z" fill="url(#paint5_linear_84_894)"/>
<path d="M82.3238 80.1252L84.7638 84.3514L74.1983 90.4514L71.7583 86.2252L82.3238 80.1252Z" fill="url(#paint6_linear_84_894)"/>
<path d="M70.0917 85.7786C70.6753 85.4417 71.4214 85.6416 71.7583 86.2252L74.1983 90.4514C74.5352 91.0349 74.3353 91.781 73.7517 92.1179L60.0166 100.048C59.4331 100.385 58.6869 100.185 58.35 99.6014L55.91 95.3752C55.5731 94.7917 55.7731 94.0455 56.3566 93.7086L70.0917 85.7786Z" fill="url(#paint7_linear_84_894)"/>
<defs>
<linearGradient id="paint0_linear_84_894" x1="-1.11312e-05" y1="196" x2="375.5" y2="-177.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#372579"/>
<stop offset="1" stop-color="#6544DF"/>
</linearGradient>
<linearGradient id="paint1_linear_84_894" x1="49.2" y1="106.65" x2="146.8" y2="45.65" gradientUnits="userSpaceOnUse">
<stop stop-color="#372579"/>
<stop offset="1" stop-color="#6544DF"/>
</linearGradient>
<linearGradient id="paint2_linear_84_894" x1="98" y1="149.35" x2="98" y2="110.31" gradientUnits="userSpaceOnUse">
<stop stop-color="#372579"/>
<stop offset="0.219315" stop-color="#372579"/>
<stop offset="1" stop-color="#6544DF"/>
</linearGradient>
<linearGradient id="paint3_linear_84_894" x1="84.58" y1="89.448" x2="110.2" y2="66.39" gradientUnits="userSpaceOnUse">
<stop stop-color="#372579"/>
<stop offset="1" stop-color="#6544DF"/>
</linearGradient>
<linearGradient id="paint4_linear_84_894" x1="84.58" y1="89.448" x2="110.2" y2="66.39" gradientUnits="userSpaceOnUse">
<stop stop-color="#372579"/>
<stop offset="1" stop-color="#6544DF"/>
</linearGradient>
<linearGradient id="paint5_linear_84_894" x1="57.13" y1="97.4883" x2="113.127" y2="65.1583" gradientUnits="userSpaceOnUse">
<stop stop-color="#372579"/>
<stop offset="1" stop-color="#6544DF"/>
</linearGradient>
<linearGradient id="paint6_linear_84_894" x1="57.13" y1="97.4883" x2="113.127" y2="65.1583" gradientUnits="userSpaceOnUse">
<stop stop-color="#372579"/>
<stop offset="1" stop-color="#6544DF"/>
</linearGradient>
<linearGradient id="paint7_linear_84_894" x1="57.13" y1="97.4883" x2="113.127" y2="65.1583" gradientUnits="userSpaceOnUse">
<stop stop-color="#372579"/>
<stop offset="1" stop-color="#6544DF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,55 @@
export interface JobData {
Job_name: string;
Year: string;
Qualification: boolean;
Time: string[];
// Soft_skills: string;
Company_name: string;
Salary: number;
Email: string;
Archive: boolean;
Responsibilities: string;
Hardskills: string[];
}
export interface ResumeData {
StudentID: number;
Name: string;
Type: string;
Phone_number: string;
Faculties: string;
Group: string;
Time: string[];
Link: string;
skills: string[];
Soft_skills: string;
Email: string;
}
export interface LoginData {
grant_type?: string;
username: string;
password: string;
scope?: string;
client_id?: string;
client_secret?: string;
}
interface AdditionalJobFields {
JobID: number;
UserID: number;
Company_name: string;
}
export interface SearchFilters {
year?: number;
time?: string[];
hardskills?: string[];
}
// Новый интерфейс с объединенными полями
export type ExtendedJobData = AdditionalJobFields & Omit<JobData, 'Hardskills'>;
export type ResumeDataWithoutSkills = Omit<ResumeData, 'skills'>;