copied the code from the working repo
This commit is contained in:
56
mtucijobsweb2/fsd/app/provider/AuthContext.tsx
Normal file
56
mtucijobsweb2/fsd/app/provider/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
30
mtucijobsweb2/fsd/app/provider/AuthGuard.tsx
Normal file
30
mtucijobsweb2/fsd/app/provider/AuthGuard.tsx
Normal 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;
|
||||
14
mtucijobsweb2/fsd/app/provider/QueryClient.tsx
Normal file
14
mtucijobsweb2/fsd/app/provider/QueryClient.tsx
Normal 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;
|
||||
25
mtucijobsweb2/fsd/app/provider/withAuth.tsx
Normal file
25
mtucijobsweb2/fsd/app/provider/withAuth.tsx
Normal 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;
|
||||
182
mtucijobsweb2/fsd/app/styles/global.scss
Normal file
182
mtucijobsweb2/fsd/app/styles/global.scss
Normal 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);
|
||||
}
|
||||
|
||||
15
mtucijobsweb2/fsd/pages/EditVacancyPage.tsx
Normal file
15
mtucijobsweb2/fsd/pages/EditVacancyPage.tsx
Normal 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;
|
||||
12
mtucijobsweb2/fsd/pages/LoginPage.tsx
Normal file
12
mtucijobsweb2/fsd/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import LoginComponent from "../widgets/Login/Login";
|
||||
|
||||
type Props = {};
|
||||
const LoginPage = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<LoginComponent />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
12
mtucijobsweb2/fsd/pages/RedirectPage.tsx
Normal file
12
mtucijobsweb2/fsd/pages/RedirectPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Redirect from '../widgets/Redirect/Redirect';
|
||||
|
||||
type Props = {};
|
||||
const RedirectPage = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Redirect />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedirectPage;
|
||||
15
mtucijobsweb2/fsd/pages/ResumePage.tsx
Normal file
15
mtucijobsweb2/fsd/pages/ResumePage.tsx
Normal 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;
|
||||
13
mtucijobsweb2/fsd/pages/SearchResumePage.tsx
Normal file
13
mtucijobsweb2/fsd/pages/SearchResumePage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import SearchResume from "../widgets/SearchResume/SearchResume";
|
||||
|
||||
|
||||
type Props = {};
|
||||
const SearchResumePage = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<SearchResume />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResumePage;
|
||||
12
mtucijobsweb2/fsd/pages/VacansyPage.tsx
Normal file
12
mtucijobsweb2/fsd/pages/VacansyPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Vacansy from "../widgets/Vacansy/Vacansy"
|
||||
|
||||
type Props = {};
|
||||
const VacansyPage = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Vacansy />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VacansyPage
|
||||
14
mtucijobsweb2/fsd/pages/ViewVacansyPage.tsx
Normal file
14
mtucijobsweb2/fsd/pages/ViewVacansyPage.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import ViewVacansy from "../widgets/ViewVacansy/ViewVacansy";
|
||||
|
||||
|
||||
|
||||
type Props = {};
|
||||
const ViewVacansyPage = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<ViewVacansy />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewVacansyPage;
|
||||
69
mtucijobsweb2/fsd/widgets/Header/Header.module.scss
Normal file
69
mtucijobsweb2/fsd/widgets/Header/Header.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
147
mtucijobsweb2/fsd/widgets/Header/Header.tsx
Normal file
147
mtucijobsweb2/fsd/widgets/Header/Header.tsx
Normal 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;
|
||||
6
mtucijobsweb2/fsd/widgets/Login/Login.module.scss
Normal file
6
mtucijobsweb2/fsd/widgets/Login/Login.module.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.loginButton{
|
||||
background-color: #372579;
|
||||
&:hover{
|
||||
background: #47309C !important;
|
||||
}
|
||||
}
|
||||
96
mtucijobsweb2/fsd/widgets/Login/Login.tsx
Normal file
96
mtucijobsweb2/fsd/widgets/Login/Login.tsx
Normal 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;
|
||||
22
mtucijobsweb2/fsd/widgets/Redirect/Redirect.tsx
Normal file
22
mtucijobsweb2/fsd/widgets/Redirect/Redirect.tsx
Normal 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
|
||||
13
mtucijobsweb2/fsd/widgets/Resume/Resume.module.scss
Normal file
13
mtucijobsweb2/fsd/widgets/Resume/Resume.module.scss
Normal 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;
|
||||
}
|
||||
110
mtucijobsweb2/fsd/widgets/Resume/Resume.tsx
Normal file
110
mtucijobsweb2/fsd/widgets/Resume/Resume.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
149
mtucijobsweb2/fsd/widgets/SearchResume/SearchResume.tsx
Normal file
149
mtucijobsweb2/fsd/widgets/SearchResume/SearchResume.tsx
Normal 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;
|
||||
25
mtucijobsweb2/fsd/widgets/Vacansy/Vacansy.module.scss
Normal file
25
mtucijobsweb2/fsd/widgets/Vacansy/Vacansy.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
211
mtucijobsweb2/fsd/widgets/Vacansy/Vacansy.tsx
Normal file
211
mtucijobsweb2/fsd/widgets/Vacansy/Vacansy.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
206
mtucijobsweb2/fsd/widgets/ViewVacansy/ViewVacansy.tsx
Normal file
206
mtucijobsweb2/fsd/widgets/ViewVacansy/ViewVacansy.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user