Compare commits

20 Commits

Author SHA1 Message Date
Mikan
ea02dcb0da combine all 2025-06-03 03:04:09 +03:00
Mikan
553242c8ee last front 2025-06-03 02:47:37 +03:00
Mikan
fd9fddbac9 export CVS 2025-06-03 01:46:44 +03:00
Mikan
94b9191ccf adding excel 2025-06-03 00:42:28 +03:00
Mikan
672a884ae9 Split Filters logic 2025-06-03 00:35:10 +03:00
Mikan
b4ace0d368 filters 2025-06-02 23:56:47 +03:00
Mikan
aaf425625b filters 2025-06-02 23:50:01 +03:00
Mikan
3dac31613e fix data 2025-06-02 21:27:30 +03:00
Mikan
c3244ce6f6 get pages 2025-06-02 21:07:15 +03:00
Mikan
546192b96d Doc fetch 2025-06-02 19:39:54 +03:00
Mikan
054cb63d71 DashboardLayout 2025-06-02 18:11:15 +03:00
Mikan
ab10918dc4 split layout 2025-06-02 17:34:08 +03:00
Mikan
7538faa699 Auth 2025-06-02 17:30:35 +03:00
Mikan
b5b2439bd8 UserContext 2025-06-01 21:39:44 +03:00
Mikan
35ed595edc non auth part 2025-06-01 20:55:44 +03:00
MikanD
9c6272db25 Profession page init [AI] 2025-06-01 16:48:44 +03:00
MikanD
83af205e7e survey result page 2025-06-01 16:17:09 +03:00
MikanD
4a5b0a9532 add result page 2025-06-01 00:52:20 +03:00
MikanD
48ec88df74 SurveyPage [AI] 2025-05-31 22:12:25 +03:00
MikanD
7555757ca3 pre AI 2025-05-31 18:15:52 +03:00
43 changed files with 2950 additions and 238 deletions

1138
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,11 @@
"deploy": "python ../build_front_end.py" "deploy": "python ../build_front_end.py"
}, },
"dependencies": { "dependencies": {
"@types/file-saver": "^2.0.7",
"file-saver": "^2.0.5",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.6.0" "react-router-dom": "^7.6.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,146 +1,122 @@
import {useEffect, useState} from "react"; import {apiUrl, getTokensFromCookies} from "../utils/common.ts";
import {DEBUG_MODE} from "../utils/common"; import {ApiResponse} from "../types/api.ts";
import {ApiResponse, PageResponse} from "../types/api.ts";
import {PageControl} from "../types/common.ts";
export function hostUrl(url: string){
return DEBUG_MODE? "http://localhost:8000"+url: url;
}
export function apiUrl(url: string) {
return hostUrl("/api/v1"+url);
}
export async function getFetch(path:string) { export async function getFetch(path: string): Promise<ApiResponse> {
const finalTokens = getTokensFromCookies();
try { try {
const response = await fetch( const response = await fetch(apiUrl(path), {
apiUrl(path), { method: 'GET',
method: "GET", headers: {
...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }),
},
credentials: "include" credentials: "include"
} });
);
if (!response.ok) { if (!response.ok) {
const body = await response.json(); const body = await response.json();
console.error('Error fetching:', 'Network response was not ok', body); console.error('Error fetching:', 'Network response was not ok', body);
return { body, success: false };
return {
body: body,
success: false
} as ApiResponse;
}
return {
body: await response.json(),
success: true,
} as ApiResponse
} }
catch { return { body: await response.json(), success: true };
return { } catch (error) {
success: false console.error('Error fetching:', error);
} as ApiResponse; return { success: false };
}
} }
export async function postFetch(
path: string,
body: object | FormData
): Promise<ApiResponse> {
const finalTokens = getTokensFromCookies();
const isFormData = body instanceof FormData;
try {
const headers = new Headers();
if (finalTokens?.access){
headers.append("Authorization", `Bearer ${finalTokens.access}`)
}
if (!isFormData){
headers.append('Content-Type','application/json')
}
const response = await fetch(apiUrl(path), {
method: 'POST',
headers: headers,
body: isFormData ? body as FormData : JSON.stringify(body),
credentials: 'include'
});
if (!response.ok) {
const responseBody = await response.json();
console.error('Error posting:', 'Network response was not ok', responseBody);
return { body: responseBody, success: false };
} }
return { body: await response.json(), success: true };
const createQueryString = (params: Record<string, string | number | boolean | undefined>): string => { } catch (error) {
const filteredParams = Object.entries(params) console.error('Error posting:', error);
.filter(([_, value]) => value !== undefined && value !== null) return { success: false };
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
return filteredParams.join('&');
};
export function useDataPage<T>(application:string, endpoint: string, params:{[key:string]:string|number}={}) {
const [data, setData] = useState<T[]|null>(null);
// const [previousUrl, setPreviousUrl] = useState<string|undefined>(undefined)
// const [nextUrl, setNextUrl] = useState<string|undefined>(undefined)
useEffect(
()=>{
if (data === null){
getFetch(`/${application}/${endpoint}/?${createQueryString(params)}`).then((r)=>{
if (r.success){
const pageData = r.body as PageResponse<T>;
setData(pageData.results);
} }
else {
setData([]);
}
})
} }
export async function patchFetch(
path: string,
body: object | FormData
): Promise<ApiResponse> {
const finalTokens = getTokensFromCookies();
const isFormData = body instanceof FormData;
try {
const response = await fetch(apiUrl(path), {
method: 'PATCH',
headers: {
...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }),
...(!isFormData && { 'Content-Type': 'application/json' }),
}, },
[data, application, endpoint] body: isFormData ? body as FormData : JSON.stringify(body),
) credentials: 'include',
});
// function changePage(pageUrl: string|undefined) { if (!response.ok) {
// if (pageUrl){ //TODO CHANGING PAGES const responseBody = await response.json();
// getFetch(`/${application}/${endpoint}/`).then((r)=>{ console.error('Error patching:', 'Network response was not ok', responseBody);
// if (r.success){ return { body: responseBody, success: false };
// const pageData = r.body as PageResponse<T>;
//
// setData(pageData.results);
//
//
// }
// })
// }
// }
function nextPage() {
// changePage(nextUrl)
} }
function previousPage() { return { body: await response.json(), success: true };
// changePage(previousUrl) } catch (error) {
console.error('Error patching:', error);
return { success: false };
}
} }
return { export async function deleteFetch(path: string): Promise<ApiResponse> {
items: data, const finalTokens = getTokensFromCookies();
hasPrevious: undefined,//previousUrl !== undefined,
hasNext: undefined,//nextUrl !== undefined,
nextPage,
previousPage
} as PageControl<T>
try {
} const response = await fetch(apiUrl(path), {
method: 'DELETE',
export function useData<T>(application:string, endpoint: string, id: string|number) { headers: {
...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }),
const [data, setData] = useState<T | null | undefined>(null);
useEffect(
()=>{
if (data === null){
getFetch(`/${application}/${endpoint}/${id}`).then((r)=>{
if (r.success){
const pageData = r.body as T;
setData(pageData);
}
else {
setData(null);
}
})
}
}, },
[data, application, endpoint, id] credentials: 'include',
) });
return data;
if (!response.ok) {
const responseBody = await response.json();
console.error('Error deleting:', 'Network response was not ok', responseBody);
return { body: responseBody, success: false };
} }
return { body: await response.json(), success: true };
} catch (error) {
console.error('Error deleting:', error);
return { success: false };
}
}

View File

@@ -1 +0,0 @@

229
src/API/hooks.ts Normal file
View File

@@ -0,0 +1,229 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import {FieldInfo, PageControl} from "../types/common.ts";
import {useEffect, useState} from "react";
import {ApiResponse, PageResponse} from "../types/api.ts";
import {getFetch} from "./common.ts";
import {createQueryString, hostUrl} from "../utils/common.ts";
export function useDataPage<T>(application:string, endpoint: string, params:{[key:string]:string|number}={}, needLoad:boolean=true):PageControl<T> {
const [data, setData] = useState<T[]|null>(null);
// const [previousUrl, setPreviousUrl] = useState<string|undefined>(undefined)
// const [nextUrl, setNextUrl] = useState<string|undefined>(undefined)
useEffect(
()=>{
if (!needLoad && data !== null){
setData(null)
}
else if (data === null){
getFetch(`/${application}/${endpoint}/?${createQueryString(params)}`).then((r)=>{
if (r.success){
const pageData = (r as ApiResponse).body as PageResponse<T>;
setData(pageData.results);
}
else {
setData([]);
}
})
}
},
[data, application, endpoint, needLoad, params]
)
// function changePage(pageUrl: string|undefined) {
// if (pageUrl){ //TODO CHANGING PAGES
// getFetch(`/${application}/${endpoint}/`).then((r)=>{
// if (r.success){
// const pageData = r.body as PageResponse<T>;
//
// setData(pageData.results);
//
//
// }
// })
// }
// }
function nextPage() {
// changePage(nextUrl)
}
function previousPage() {
// changePage(previousUrl)
}
return {
items: data,
hasPrevious: false,//previousUrl !== undefined,
hasNext: false,//nextUrl !== undefined,
nextPage,
previousPage
} as PageControl<T>
}
export function useData<T>(application:string, endpoint: string, id: string|number, needLoad:boolean=true): T|null|undefined {
const [data, setData] = useState<T | null | undefined>(null);
useEffect(
()=>{
if (data === null && needLoad){
getFetch(`/${application}/${endpoint}/${id}`).then((r)=>{
if (r.success){
const pageData = r.body as T;
setData(pageData);
}
else {
setData(undefined);
}
})
}
},
[data, application, endpoint, id, needLoad]
)
return data;
}
interface CachedData<T>{
get(id: string | number, needLoad?: boolean): T|undefined|null
}
export function useCachedData<T>(application: string, endpoint: string): CachedData<T> {
const [cache, setCache] = useState<Map<string | number, T | undefined>>(new Map());
useEffect(() => {
// Очистка кеша или другие побочные эффекты при необходимости
return () => {
setCache(new Map());
};
}, []);
return {
get: (id: string | number, needLoad: boolean = true): T|undefined|null => {
const cachedData = cache.has(id)?cache.get(id):null;
if (cachedData !== null) {
return cachedData;
}
if (needLoad) {
getFetch(`/${application}/${endpoint}/${id}`).then((r) => {
if (r.success) {
const data = r.body as T;
setCache((prevCache) => {
const newCache = new Map(prevCache);
newCache.set(id, data);
return newCache;
});
} else {
setCache((prevCache) => {
const newCache = new Map(prevCache);
newCache.set(id, undefined);
return newCache;
});
}
});
}
return null;
}
}
}
/**
* Хук для получения документации swagger JSON и извлечения структур.
* @param applicationName Название модуля/приложения.
* @param endpointName Название эндпоинта.
* @returns Массив структур без ID с полями: название, тип, title, format.
*/
export function useApiDocumentation(
applicationName: string,
endpointName: string
): { schema: FieldInfo[] | null; loading: boolean; error: Error | null } {
const [schema, setSchema] = useState<FieldInfo[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchDocumentation() {
try {
setLoading(true);
const response = await fetch(hostUrl('/docs/swaggerjson/'));
if (!response.ok) {
throw new Error(`Failed to fetch documentation: ${response.statusText}`);
}
const data = await response.json();
// Найти путь по applicationName и endpointName
const pathKey = `/${applicationName}/${endpointName}/`;
const pathItem = data.paths?.[pathKey];
if (!pathItem) {
throw new Error(`Path ${pathKey} not found`);
}
const getMethod = pathItem.get;
if (!getMethod || !getMethod.responses?.['200']) {
throw new Error(`GET method or response 200 not found for ${pathKey}`);
}
const schemaRef = getMethod.responses["200"].schema?.properties?.results?.items?.['$ref'];
if (!schemaRef) {
throw new Error(`$ref not found in response schema for ${pathKey}`);
}
// Получить название определения
const defNameMatch = schemaRef.match(/#\/definitions\/(\w+)/);
if (!defNameMatch || defNameMatch.length < 2) {
throw new Error(`Invalid $ref format: ${schemaRef}`);
}
const defName = defNameMatch[1];
const definitions = data.definitions;
if (!definitions || !definitions[defName]) {
throw new Error(`Definition ${defName} not found`);
}
const defProps = definitions[defName].properties;
if (!defProps) {
throw new Error(`Properties for ${defName} not found`);
}
// Собрать поля, исключая id
const result: FieldInfo[] = Object.entries(defProps)
.filter((prop) => prop[0] !== 'id')
.map(([name, prop]) => {
return {
name,
type: prop.type || '',
title: prop.title || '',
format: prop.format || undefined,
};
});
setSchema(result);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}
fetchDocumentation().then();
}, [applicationName, endpointName]);
return { schema, loading, error };
}

54
src/API/users.ts Normal file
View File

@@ -0,0 +1,54 @@
import { TokenObtainPair, TokenRefresh, TokenVerify, JwtTokenResponse } from '../types/users';
import {apiUrl} from "../utils/common.ts";
import {postFetch} from "./common.ts";
export async function login(data: TokenObtainPair): Promise<JwtTokenResponse | null> {
const response = await fetch(apiUrl('/users/auth/jwt/create/'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
return response.json();
}
return null;
}
export async function refreshToken(data: TokenRefresh): Promise<JwtTokenResponse | null> {
const response = await fetch(apiUrl('/users/auth/jwt/refresh/'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
return response.json();
}
return null;
}
export async function verifyToken(data: TokenVerify): Promise<boolean> {
const response = await fetch(apiUrl('/users/auth/jwt/verify/'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
return response.ok;
}
export async function changePassword(data: { currentPassword: string; newPassword: string }) {
const response = await postFetch("/users/auth/users/set_password/", {new_password: data.newPassword, re_new_password: data.newPassword, current_password: data.currentPassword});
if (!response.success) {
throw new Error('Не удалось изменить пароль');
}
}

View File

@@ -1,25 +1,52 @@
// App.tsx // App.tsx
import {BrowserRouter as Router, Routes, Route, Navigate, Link} from 'react-router-dom'; import {BrowserRouter as Router, Routes, Route, Navigate} from 'react-router-dom';
import React from "react";
import {MainPage} from "./pages/MainPage.tsx"; import {MainPage} from "./pages/MainPage.tsx";
import {SurveyPage} from "./pages/SurveyPage.tsx"; import {SurveyPage} from "./pages/surveys/SurveyPage.tsx";
import {DashboardPage} from "./pages/DashboardPage.tsx"; import {DashboardPage} from "./pages/dashboards/DashboardPage.tsx";
import { SurveyResultPage } from './pages/surveys/SurveyResultPage.tsx';
import { ProfessionPage } from './pages/surveys/ProfessionPage.tsx';
import React from "react";
import {LoginPage} from "./pages/LoginPage.tsx";
import {UserProvider} from "./utils/users/UserProvider.tsx";
import {DashboardLayout} from "./layouts/DashboardLayout.tsx";
import {MainLayout} from "./layouts/MainLayout.tsx";
import {DataPage} from "./pages/dashboards/DataPage.tsx";
import './css/Global.css'
import './css/Containers.css'
export default function App() { export default function App() {
return ( return (
<UserProvider>
<Router> <Router>
<div className="header">
<Link to="/dashboard" className="menu-button"></Link>
<h1 className="logo">Pro-Fi Test</h1>
</div>
<Routes> <Routes>
<Route path="/" element={<MainPage/>}/>
<Route path="/login/" element={<LoginPage />} />
<Route path="/dashboard/*" element={<DashboardLayout />}>
<Route index element={<DashboardPage />} />
<Route path=":applicationName/:endpointName/" element={<DataPage />} />
</Route>
<Route path="/" element={<MainLayout />}>
<Route index element={<MainPage />} />
<Route path="/surveys/results/:scoreVariableId/" element={<SurveyResultPage />} />
<Route path="/surveys/professions/:professionId/" element={<ProfessionPage />} />
<Route path="/surveys/:surveyId/" element={<SurveyPage />} /> <Route path="/surveys/:surveyId/" element={<SurveyPage />} />
<Route path="/dashboard/" element={<DashboardPage/>}/> </Route>
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />
</Routes> </Routes>
</Router> </Router>
</UserProvider>
); );
} }

View File

@@ -0,0 +1,58 @@
import { FieldInfo } from '../types/common'
import {VisibilityState} from "../types/filters.ts";
interface FilteredTableProps {
schema: FieldInfo[]
filteredData: object[]
visibleColumns: VisibilityState
}
/**
* Компонент динамической фильтрованной таблицы.
* Позволяет фильтровать данные по различным условиям, в зависимости от типа поля.
*/
export function FilteredTable({ schema, filteredData, visibleColumns}: FilteredTableProps) {
return (
<div className="filtered-table">
<table className="filtered-table-main" style={{ overflowX: 'auto', display: 'block' }}>
<thead>
<tr>
{schema.map(
(field) =>
visibleColumns[field.name] && (
<th key={field.name} className="column-header">
{field.title}
</th>
)
)}
</tr>
</thead>
<tbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<tr key={(item as {id: number}).id}>
{schema.map(
(field) =>
visibleColumns[field.name] && (
<td key={field.name} className="data-cell">
{item[field.name] ?? "-"}
</td>
)
)}
</tr>
))
) : (
<tr>
<td colSpan={schema.length} className="no-data">
Нет данных для отображения
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}

109
src/components/Filters.tsx Normal file
View File

@@ -0,0 +1,109 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import {FieldInfo} from "../types/common.ts";
import {FiltersState, VisibilityState} from "../types/filters.ts";
interface FiltersProps{
schema: FieldInfo[];
visibleColumns: VisibilityState;
handleColumnToggle(name: string): void;
handleFilterChange(name: string, filterPart: unknown):void;
filters: FiltersState;
isShow?: boolean;
}
export function Filters({schema, visibleColumns, handleColumnToggle, handleFilterChange, filters, isShow=false}:FiltersProps ){
if (!isShow){
return null;
}
return (
<div className="filters-block">
{schema.map(field => (
<div key={field.name} className="filter-item">
<label>
<input
type="checkbox"
checked={visibleColumns[field.name]}
onChange={() => handleColumnToggle(field.name)}
/>
{field.title}
</label>
{field.type === 'string' && (
<div className="string-filters">
<input
type="text"
placeholder="Поиск"
onChange={(e) => handleFilterChange(field.name, {searchText: e.target.value})}
/>
<label>
<input
type="checkbox"
checked={filters[field.name]?.exactMatch || false}
onChange={() => handleFilterChange(field.name, {exactMatch: !filters[field.name]?.exactMatch})}
/> Строгое совпадение
</label>
<div>
<label>
<input
type="checkbox"
checked={filters[field.name]?.notEmpty || false}
onChange={(e) =>
handleFilterChange(field.name, {
notEmpty: e.target.checked,
empty: false
})
}
/> Не пустые
</label>
<label>
<input
type="checkbox"
checked={filters[field.name]?.empty || false}
onChange={(e) =>
handleFilterChange(field.name, {
empty: e.target.checked,
notEmpty: false
})
}
/> Пустые
</label>
</div>
</div>
)}
{field.type === 'date' && (
<div className="date-filters">
<input
type="date"
onChange={(e) => handleFilterChange(field.name, {fromDate: e.target.value})}
/>
<input
type="date"
onChange={(e) => handleFilterChange(field.name, {toDate: e.target.value})}
/>
</div>
)}
{field.type === 'number' && (
<div className="number-filters">
<input
type="number"
placeholder="От"
onChange={(e) =>
handleFilterChange(field.name, {gte: e.target.value !== '' ? Number(e.target.value) : undefined})
}
/>
<input
type="number"
placeholder="До"
onChange={(e) =>
handleFilterChange(field.name, {lte: e.target.value !== '' ? Number(e.target.value) : undefined})
}
/>
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -1,13 +1,13 @@
import {ReactElement} from "react"; import {ReactElement} from "react";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
export function LoadingData({data, children}: {data: object|null|undefined, children: ReactElement}) { export function LoadingData({data, children}: {data: object|null|undefined, children: (ReactElement | null | undefined | boolean)[]}) {
if (data === null){ if (data === null){
return <div className={"loading"}>Идёт загрузка...</div> return <div className={"loading"}>Идёт загрузка...</div>
} }
if (data === undefined){ if (data === undefined){
return <Link className={"not-found"} to={"/"}>404 НИЧЕГО НЕ НАЙДЕНО</Link> return <Link className={"not-found"} to={"/"}>404 НЕ НАЙДЕНО</Link>
} }
return children; return children;

View File

@@ -1,7 +1,6 @@
import {ReactElement} from "react";
import {UniqueItem} from "../types/common.ts"; import {UniqueItem} from "../types/common.ts";
export function LoadingList({data, listElement}: {data: UniqueItem[]|null, listElement(e:UniqueItem, i:number):ReactElement}) { export function LoadingList({data, listElement}: {data: UniqueItem[]|null, listElement(e:UniqueItem, i:number):unknown}) {
if (data === null){ if (data === null){
return <div className={"loading"}>Идёт загрузка...</div> return <div className={"loading"}>Идёт загрузка...</div>
} }

12
src/css/Colors.css Normal file
View File

@@ -0,0 +1,12 @@
:root {
/* Основные цвета */
--color-background: #EEEDFF;
--color-text-primary: #000000;
--color-accent: #003CFF;
--color-secondary: #4C76FE;
--color-button-text: #FFFFFF;
/* Дополнительно можно добавить общие шрифты и т.п. */
--font-family: 'Arial', sans-serif;
--font-size-base: 16px;
}

65
src/css/Containers.css Normal file
View File

@@ -0,0 +1,65 @@
/* === Базовый контейнер === */
.container {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 0;
}
/* === Направление flex-direction === */
.flex-row { flex-direction: row; }
.flex-row-reverse { flex-direction: row-reverse; }
.flex-col { flex-direction: column; }
.flex-col-reverse { flex-direction: column-reverse; }
/* === Выравнивание элементов по главной оси (justify-content) === */
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-around { justify-content: space-around; }
.justify-evenly { justify-content: space-evenly; }
/* === Выравнивание элементов по поперечной оси (align-items) === */
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.items-center { align-items: center; }
.items-stretch { align-items: stretch; }
.items-baseline { align-items: baseline; }
/* === Выравнивание для самого контейнера на внешнем уровне (align-self) === */
.self-start { align-self: flex-start; }
.self-end { align-self: flex-end; }
.self-center { align-self: center; }
.self-stretch { align-self: stretch; }
/* === Обертка дочерних элементов (flex-wrap) === */
.flex-wrap { flex-wrap: wrap; }
.flex-wrap-reverse { flex-wrap: wrap-reverse; }
.flex-nowrap { flex-wrap: nowrap; }
/* === Отступы между дочерними элементами (gap) === */
.gap-1 { gap: 0.25rem; } /* 4px */
.gap-2 { gap: 0.5rem; } /* 8px */
.gap-3 { gap: 1rem; } /* 16px */
.gap-4 { gap: 1.5rem; } /* 24px */
.gap-5 { gap: 2rem; } /* 32px */
/* === Размеры ширины и высоты контейнера === */
.full-width { width: 100%; }
.full-height { height: 100%; }
/* === Позиционирование дочерних элементов как самостоятельные flex-элементы === */
.item {
flex: 1 1 auto;
}
.item-grow { flex-grow: 1; }
.item-shrink { flex-shrink: 1; }
.margin-center{
margin-left: auto;
margin-right: auto;
}

101
src/css/Global.css Normal file
View File

@@ -0,0 +1,101 @@
@import './Colors.css';
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
background-color: var(--color-background);
color: var(--color-text-primary);
line-height: 1.5;
}
/* Заголовки */
h1, h2, h3, h4, h5, h6 {
margin-bottom: 16px;
font-weight: 600;
}
/* Кнопки */
button {
font-family: var(--font-family);
font-size: 1rem;
padding: 12px 24px;
background-color: var(--color-accent);
color: var(--color-button-text);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
button:hover {
background-color: var(--color-secondary);
}
/* Формы и инпуты */
input,
textarea,
select {
font-family: var(--font-family);
font-size: 1rem;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(0, 60, 255, 0.2);
}
/* Адаптивы */
@media (max-width: 768px) {
body {
font-size: 14px;
}
button {
width: 100%;
padding: 14px;
}
}
@media (max-width: 480px) {
body {
font-size: 13px;
}
button {
font-size: 14px;
padding: 12px;
}
}
a,button{
text-decoration: none;
color: var(--color-button-text);
background-color: var(--color-accent);
padding: 10px 20px;
border-radius: 30px;
}
a,button .selected{
background-color: var(--color-secondary);
}
.underline{
text-decoration: underline;
text-decoration: #af4360;
}

View File

View File

@@ -0,0 +1,105 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useState, useMemo, useCallback } from 'react'
import { FieldInfo } from '../types/common'
import {FiltersState, VisibilityState} from "../types/filters.ts";
/**
* Хук для управления фильтрами и видимостью колонок таблицы.
* @param schema Схема полей таблицы.
* @param initialData
* @returns Объекты состояния фильтров и видимости, а также функции для их изменения.
*/
export function useTableFilters(schema: FieldInfo[]| null, initialData: unknown[] | null) {
const [filters, setFilters] = useState<FiltersState>({})
const [visibleColumns, setVisibleColumns] = useState<VisibilityState>({})
// Инициализация видимости колонок
useMemo(() => {
if (!schema){
return;
}
const initialVisibility: VisibilityState = {}
schema.forEach(field => {
initialVisibility[field.name] = true
})
setVisibleColumns(initialVisibility)
}, [schema])
// Обновление фильтров
const handleFilterChange = useCallback((name: string, filterPart: unknown) => {
setFilters(prev => ({
...prev,
[name]: {
...prev[name],
...filterPart
}
}))
}, [])
// Переключатель видимости колонки
const handleColumnToggle = useCallback((name: string) => {
setVisibleColumns(prev => ({
...prev,
[name]: !prev[name]
}))
}, [])
// Логика фильтрации данных, вынесенная из компонента
const filteredData = useMemo(() => {
if (!initialData || !schema){
return ;
}
return initialData.filter(item => {
return schema.every(field => {
const value = item[field.name]
const filter = filters[field.name]
if (!filter) return true
// Обработка по типу
if (field.type === 'string') {
if (filter.searchText) {
if (typeof value !== 'string') return false
if (!value.toLowerCase().includes(filter.searchText.toLowerCase())) return false
}
if (filter.exactMatch && filter.searchText) {
return value.toLowerCase() === filter.searchText.toLowerCase()
}
if (filter.empty !== undefined) {
const isEmpty = value === '' || value === null || value === undefined
if (filter.empty && !isEmpty) return false
if (filter.notEmpty && isEmpty) return false
}
} else if (field.format === 'date') {
const dateValue = new Date(value as string).getTime()
if (filter.fromDate) {
const fromTime = new Date(filter.fromDate).getTime()
if (dateValue < fromTime) return false
}
if (filter.toDate) {
const toTime = new Date(filter.toDate).getTime()
if (dateValue > toTime) return false
}
} else if (field.type === 'number') {
const numValue = Number(value)
if (filter.gte !== undefined && numValue < filter.gte) return false
if (filter.lte !== undefined && numValue > filter.lte) return false
}
return true
})
})
}, [initialData, filters, schema])
return {
filters,
setFilters,
visibleColumns,
setVisibleColumns,
handleFilterChange,
handleColumnToggle,
filteredData
}
}

4
src/img/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,47 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useUser } from "../utils/users/UseUser.ts"; // Хук для получения информации о пользователе
import { Outlet, Link, useNavigate } from "react-router-dom"; // Композиция для маршрутизации
import React, { useEffect } from "react";
import {UserContextType} from "../types/users.ts";
export function DashboardLayout() {
const { user, logout } = useUser() as UserContextType; // Получаем данные о пользователе
const navigate = useNavigate(); // Получаем функцию для навигации
// Проверяем, есть ли пользователь, если нет, редиректим на страницу логина
useEffect(() => {
if (!user) {
navigate("/login/");
}
}, [navigate, user]);
if (!user) {
return null; // Если пользователь не найден, ничего не отображаем
}
// Определяем класс для активной ссылки
const isActive = (path: string) => window.location.pathname.includes(path)? 'selected' : 'unselected';
return (
<div className="dashboard-layout">
<div className="sidebar">
<h1 className="logo">Pro-Fi</h1>
<p className="role">{user.roleTitle || "Роль не опознана"}</p> {/* Отображаем текущую роль пользователя */}
<nav className="navigation">
<Link to="/dashboard/employees/employees/" className={isActive('/dashboard/staff')}>Штат</Link>
<Link to="/dashboard/outreach/schools/" className={isActive('/dashboard/schools')}>Школы</Link>
<Link to="/dashboard/events/events/" className={isActive('/dashboard/events')}>Мероприятия</Link>
<Link to="/dashboard/education/students/" className={isActive('/dashboard/students')}>Студенты</Link>
<Link to="/dashboard/outreach/partners/" className={isActive('/dashboard/partners')}>Партнеры</Link>
<Link to="/admin" className={isActive('/admin')}>Админ-панель</Link>
<button onClick={logout} className="logout">Выйти</button>
</nav>
</div>
<div className="content">
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import {Link, Outlet} from "react-router-dom";
import React from "react";
import logoUrl from '../img/logo.svg';
import "./css/MainLayout.css"
export function MainLayout() {
return (
<>
<div className="header container flex-col">
<Link to="/dashboard" className="menu-button self-end">
<svg width="75" height="65" viewBox="0 0 75 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="75" height="15" rx="7.5" className={"rect"}/>
<rect y="25" width="75" height="15" rx="7.5" className={"rect"}/>
<rect y="50" width="75" height="15" rx="7.5" className={"rect"}/>
</svg>
</Link>
<img src={logoUrl as string} alt="Logo"/>
</div>
<div className="content">
<Outlet/>
</div>
</>
);
}

View File

@@ -0,0 +1,25 @@
.header {
display: flex;
flex-direction: column;
}
.header .menu-button{
background: none;
position: fixed;
right: 20px;
top: 20px;
}
.header>img{
width: 50%;
align-self: center;
margin-top: 50px;
}
.header .rect{
fill: var(--color-accent)
}
.header .menu-button:hover .rect{
fill: var(--color-secondary)
}

View File

@@ -1,4 +1,6 @@
import { StrictMode } from 'react' // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import React, { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'

View File

@@ -1,3 +0,0 @@
export function DashboardPage() {
return null;
}

72
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,72 @@
import {useEffect, useState} from 'react';
import {useUser} from "../utils/users/UseUser.ts";
import {useNavigate} from "react-router-dom";
export function LoginPage() {
const userContext = useUser();
const user = userContext?.user;
const login = userContext?.login;
// Локальные стейты для формы
const [loginInput, setLoginInput] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
if (user != undefined){
navigate("/dashboard/")
}
}, [user, navigate]);
// Обработчик отправки формы
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (login && loginInput && password) {
let success = false;
try {
success = await login({ login: loginInput, password });
setError(null);
if (!success){
setError('Ошибка входа');
}
} catch {
setError('Ошибка входа');
}
}
};
return (
<div className="login-page">
<form className="login-form" onSubmit={handleSubmit}>
<h1 className="login-title">Войти в систему</h1>
<div className="form-group">
<label htmlFor="login" className="form-label">Логин</label>
<input
id="login"
className="form-input"
type="text"
value={loginInput}
onChange={(e) => setLoginInput(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="password" className="form-label">Пароль</label>
<input
id="password"
className="form-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className="error-message">{error}</div>}
<button type="submit" className="submit-button">Войти</button>
</form>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import '../css/MainPage.css';
import {useDataPage} from "../API/common.ts";
import {Survey} from "../types/survey.ts"; import {Survey} from "../types/survey.ts";
import {LoadingList} from "../components/LoadingList.tsx"; import {LoadingList} from "../components/LoadingList.tsx";
import {useDataPage} from "../API/hooks.ts";
import "./css/MainPage.css"
export function MainPage() { export function MainPage() {
@@ -12,20 +12,42 @@ export function MainPage() {
return ( return (
<div className="main-page"> <div className="main-page container flex-col items-center gap-2 margin-center">
<h2 className="title">Тесты для школьников и абитуриентов</h2> <h2 className="title">Тесты для школьников и абитуриентов</h2>
<div className={"surveys-list"}> <div className={"surveys-list container flex-col items-center gap-1"}>
<LoadingList data={surveys.items} listElement={(s: Survey) => ( <LoadingList data={surveys.items} listElement={(s: Survey) => (
<Link key={s.id} to={`surveys/${s.id}/`}>{s.title}</Link> <Link key={s.id} to={`surveys/${s.id}/`}>{s.title}</Link>
)}/> )}/>
</div> </div>
<p className="description"> <p className="description">
Добро пожаловать на Pro-Fi Test ваш надежный помощник в мире профессий! Добро пожаловать на <strong>Pro-Fi Test</strong> платформу, созданную для тех, кто стоит перед важным
Мы понимаем, что выбор будущей профессии это одно из самых важных решений выбором: какую профессию выбрать, куда поступать и кем стать.
в жизни каждого школьника. Наша миссия помочь молодым людям найти свой путь, Мы знаем, что определиться с будущим бывает непросто особенно когда вариантов много, а уверенность в
раскрыть таланты и понять, какие профессии соответствуют их интересам и способностям. правильности выбора не всегда есть.
Наша цель сделать этот путь понятнее, интереснее и максимально персонализированным.
</p>
<p className="description">
Pro-Fi Test это не просто набор тестов. Это интеллектуальный гид в мире профессий, который поможет
вам:
<ul className="features-list">
<li>Узнать себя лучше: выявить свои сильные стороны, склонности и скрытые таланты;</li>
<li>Познакомиться с миром современных профессий через призму реальных навыков и интересов;</li>
<li>Понять, какие специальности подходят именно вам, исходя из ваших целей и возможностей;</li>
<li>Сэкономить время на выборе направления обучения и сосредоточиться на главном вашем развитии.
</li>
</ul>
</p>
<p className="description">
Независимо от того, стоите ли вы на пороге выпускного класса, подаёте документы в вуз или уже начали
обучение в колледже,
<strong>Pro-Fi Test</strong> будет с вами на каждом этапе. Мы верим, что у каждого есть своё уникальное
призвание и помогаем найти его без страха, сомнений и лишней путаницы.
</p>
<p className="description">
Пройдите наш тест уже сегодня и сделайте первый шаг к осознанному и уверенному выбору своей
профессиональной дороги!
</p> </p>
</div> </div>
); );

View File

@@ -1,53 +0,0 @@
import {LoadingList} from "../components/LoadingList.tsx";
import {Survey} from "../types/survey.ts";
import {Link, useParams} from "react-router-dom";
import React from "react";
import {useData, useDataPage} from "../API/common.ts";
import {LoadingData} from "../components/LoadingData.tsx";
export function SurveyPage() {
const {surveyId} = useParams();
const survey = useData<Survey>("surveys", "surveys", surveyId);
return (
<div className="survey-page">
<LoadingData data={survey}>
<h1>
{survey?.title}
</h1>
<div>
{survey?.description}
</div>
<SurveyQuestions survey={survey}/>
</LoadingData>
</div>
);
}
function SurveyQuestions({survey}: { survey: Survey | null | undefined }) {
const _survey = survey as Survey;
const questionsPairs = useDataPage("surveys", "question-pairs", {survey:_survey.id});
const questionsAgreement = useDataPage("surveys", "question-pairs", {survey:_survey.id});
//const questions =
return (
<>
</>
);
}

View File

@@ -0,0 +1,8 @@
.main-page{
width: 600px;
}
@media (min-width: 1600px) {
width: 1000px;
}

View File

@@ -0,0 +1,71 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import React, { useState } from 'react';
import {useUser} from "../../utils/users/UseUser.ts";
import {changePassword} from "../../API/users.ts";
export function DashboardPage() {
// Получаем данные о пользователе
const userContext = useUser();
const user = userContext?.user;
// Состояние для управления модальным окном
const [isModalOpen, setModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
// Обработчик смены пароля
const handleChangePassword = async () => {
try {
await changePassword({ currentPassword, newPassword });
alert('Пароль успешно изменен!');
setModalOpen(false); // Закрыть модальное окно после успешной смены пароля
} catch (error) {
alert('Ошибка при смене пароля: ' + error.message);
}
};
return (
<div className="dashboard">
<div className="welcome-message">
С возвращением, {user?.last_name} {user?.first_name} {user?.middle_name}!
</div>
<div className="user-role">
Вы вошли как: {user?.roleTitle}
</div>
{
!isModalOpen &&
<button className="change-password-button" onClick={() => setModalOpen(true)}>
Поменять пароль
</button>
}
{isModalOpen && (
<div className="change-password">
<div className="modal-content">
<label>
Текущий пароль:
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</label>
<label>
Новый пароль:
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</label>
<button onClick={handleChangePassword}>Сменить пароль</button>
<button onClick={() => setModalOpen(false)}>Закрыть</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useParams } from 'react-router-dom';
import { useApiDocumentation, useDataPage } from "../../API/hooks.ts";
import { FieldInfo } from "../../types/common.ts";
import {FilteredTable} from "../../components/FilteredTable.tsx";
import {useState} from "react";
import {LoadingData} from "../../components/LoadingData.tsx";
import {useTableFilters} from "../../hooks/useTableFilters.ts";
import {Filters} from "../../components/Filters.tsx";
import { saveAs } from 'file-saver'
import * as buffer from "buffer";
export function DataPage() {
const { applicationName, endpointName } = useParams<{ applicationName: string; endpointName: string }>();
const { schema, loading: loadingSchema } = useApiDocumentation(applicationName || "", endpointName || "");
const dataPage = useDataPage(applicationName || "", endpointName || "", {}, !loadingSchema);
const [filterShow, setFilterShow] = useState(false);
const {
filters,
handleFilterChange,
visibleColumns,
handleColumnToggle,
filteredData
} = useTableFilters(schema, dataPage.items)
// Функция для экспорта данных в Excel (пока пустая)
const handleExport = async () => {
if (!filteredData || filteredData.length === 0 || !schema) {
alert('Нет данных для экспорта')
return
}
const visibleFields = schema.filter(field => visibleColumns[field.name])
const header = visibleFields.map(field => `"${field.title.replace(/"/g, '""')}"`).join(';')
const rows = filteredData
.map(item => {
const row = visibleFields.map(field => {
const val = item[field.name]
const strVal = val !== undefined && val !== null ? String(val) : ''
return `"${strVal.replace(/"/g, '""')}"`
})
return row.join(';')
})
const csvContent = [header, ...rows].join('\n')
// Создаем ссылку для скачивания
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8'})
saveAs(blob, "Отчёт.csv")
};
// Функция для добавления новых данных (пока пустая)
const handleAdd = () => {
alert("Недостаточно прав!")
};
// Функция для фильтрации данных (пока пустая)
const handleFilters = () => {
setFilterShow(!filterShow)
};
// Если данные или схема загружаются, отображаем загрузку
if (loadingSchema) {
return <div className="loading">Загрузка схемы...</div>;
}
return (
<div className="data-page">
<div className="buttons-container">
<button className="export-csv-button" onClick={handleExport}>Экспорт в CSV</button>
<button className="add-button" onClick={handleAdd}>Добавление</button>
<button className="filters-button" onClick={handleFilters}>Фильтры</button>
</div>
<LoadingData data={dataPage.items}>
<Filters schema={schema as FieldInfo[]} visibleColumns={visibleColumns} handleColumnToggle={handleColumnToggle} handleFilterChange={handleFilterChange} filters={filters} isShow={filterShow}/>
<FilteredTable
schema={schema as FieldInfo[]}
visibleColumns={visibleColumns}
filteredData={filteredData as object[]}
/>
</LoadingData>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import {Link, useParams} from 'react-router-dom';
import { Institution, Profession, Specialty } from '../../types/survey.ts';
import ReactMarkdown from 'react-markdown';
import { LoadingData } from '../../components/LoadingData.tsx';
import { LoadingList } from '../../components/LoadingList.tsx';
import {useCachedData, useData, useDataPage} from "../../API/hooks.ts";
export function ProfessionPage() {
const { professionId } = useParams<{ professionId: string }>();
const profession = useData<Profession>('surveys', 'professions', professionId || 0);
const specialties = useDataPage<Specialty>('surveys', 'specialties', { profession: professionId || 0 }, !!profession);
const institutions = useCachedData<Institution>("surveys", "institutions");
return (
<div>
<LoadingData data={profession}>
<h1>{profession?.name}</h1>
<div className="description">
<ReactMarkdown>{profession?.description || ''}</ReactMarkdown>
</div>
<div className="specialties-block">
{specialties.items != null ? <h2>Где можно обучиться</h2>:null}
<LoadingList
data={specialties.items}
listElement={specialty => (
<SpecialtyBlock
key={specialty.id}
specialty={specialty as Specialty}
institution={institutions.get((specialty as Specialty).institution)}
/>
)}
/>
</div>
</LoadingData>
</div>
);
}
function SpecialtyBlock({
specialty,
institution,
}: {
specialty: Specialty;
institution: Institution | undefined | null;
}) {
return (
<div className="specialty-block">
<h3>Учебное заведение: {institution?.name || 'Загрузка...'}</h3>
<p className={"specialty-name"}>{specialty.name}</p>
{specialty.link?<Link to={specialty.link} className={"site"}>Открыть сайт</Link>:null}
</div>
);
}

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Survey,
Question,
QuestionPair,
AgreementQuestion,
QuestionType,
} from "../../types/survey.ts";
import { LoadingData } from "../../components/LoadingData.tsx";
import { LoadingList } from "../../components/LoadingList.tsx";
import {useData, useDataPage} from "../../API/hooks.ts";
export function SurveyPage() {
const { surveyId } = useParams<{ surveyId: string }>();
const navigate = useNavigate();
// Загрузка теста
const survey = useData<Survey>("surveys", "surveys", surveyId || 0);
// Загрузка вопросов
const [questions, setQuestions] = useState<Question[] | null>(null);
const questionsPairs = useDataPage<QuestionPair>("surveys", "question-pairs", { survey: surveyId || 0 });
const questionsAgreement = useDataPage<AgreementQuestion>("surveys", "agreement-questions", { survey: surveyId || 0 });
// Состояние для ответов
const [answers, setAnswers] = useState<Record<string, number>>({});
const [isCompleted, setIsCompleted] = useState(false);
// Объединение вопросов после загрузки
useEffect(() => {
if (
questions === null &&
questionsPairs.items !== null &&
questionsAgreement.items !== null
) {
const mergedQuestions = [
...questionsPairs.items.map((q:Question) => ({ ...q, questionType: QuestionType.QuestionPair })),
...questionsAgreement.items.map((q:Question) => ({ ...q, questionType: QuestionType.AgreementQuestion })),
];
mergedQuestions.sort(() => Math.random() - 0.5); // Перемешивание
setQuestions(mergedQuestions);
}
}, [questions, questionsPairs.items, questionsAgreement.items]);
// Проверка завершения теста
useEffect(() => {
if (questions && Object.keys(answers).length === questions.length) {
setIsCompleted(true);
} else {
setIsCompleted(false);
}
}, [questions, answers]);
// Обработка ответов
const handleAnswer = (questionKey: string, answerValue: number) => {
setAnswers((prev) => ({ ...prev, [questionKey]: answerValue }));
};
// Вычисление итогового score_variable
const calculateResults = () => {
if (!questions) return;
const scoreCounts: Record<string, number> = {};
questions.forEach((question) => {
const questionKey = `${question.questionType}_${question.id}`;
const answer = answers[questionKey];
if (answer !== undefined) {
if (question.questionType === QuestionType.QuestionPair) {
const selectedStatement = (question as QuestionPair).statements.find(
(s) => s.id === answer
);
if (selectedStatement) {
scoreCounts[selectedStatement.score_variable] =
(scoreCounts[selectedStatement.score_variable] || 0) + 1;
}
} else if (question.questionType === QuestionType.AgreementQuestion) {
const scoreVariable = (question as AgreementQuestion).score_variable;
scoreCounts[scoreVariable] = (scoreCounts[scoreVariable] || 0) + answer;
}
}
});
const maxScoreVariable = Object.keys(scoreCounts).reduce((a, b) =>
scoreCounts[a] > scoreCounts[b] ? a : b
);
navigate(`/surveys/results/${maxScoreVariable}`);
};
return (
<div className="survey-page container items-center flex-col">
<LoadingData data={survey}>
<h1>{survey?.title}</h1>
<div>{survey?.description}</div>
<LoadingList data={questions} listElement={(question: Question) => (
<QuestionBlock
key={`${question.questionType}_${question.id}`} // Используем тип вопроса и id для уникальности
question={question}
onAnswer={handleAnswer}
answered={answers[`${question.questionType}_${question.id}`]} // Учитываем тип вопроса в ключе
/>
)} />
<>
{isCompleted && (
<button onClick={calculateResults}>Завершить тест</button>
)}
</>
</LoadingData>
</div>
);
}
function QuestionBlock({
question,
onAnswer,
answered,
}: {
question: Question;
onAnswer: (questionId: string, answerValue: number) => void;
answered?: number;
}) {
switch (question.questionType) {
case QuestionType.AgreementQuestion:
{ const agreementQuestion = question as AgreementQuestion;
return (
<div className={`question agreement ${answered !== undefined ? "answered" : "unanswered"}`}>
{agreementQuestion.question}
<button
className={`answer ${answered === 1 ? "selected" : "idle"}`}
disabled={answered===1}
onClick={() => onAnswer(`${question.questionType}_${question.id}`, 1)}>Согласен</button>
<button
className={`answer ${answered === 0 ? "selected" : "idle"}`}
disabled={answered === 0}
onClick={() => onAnswer(`${question.questionType}_${question.id}`, 0)}>Не согласен</button>
</div>
); }
case QuestionType.QuestionPair:
{ const pairQuestion = question as QuestionPair;
return (
<div className={`question pair ${answered !== undefined ? "answered" : "unanswered"}`}>
{pairQuestion.statements.map((statement) => (
<button
className={`answer ${answered === statement.id ? "selected" : "idle"}`}
disabled={answered === statement.id}
key={statement.id}
onClick={() => onAnswer(`${question.questionType}_${question.id}`, statement.id)}
>
{statement.text}
</button>
))}
</div>
); }
default:
return null;
}
}

View File

@@ -0,0 +1,45 @@
import { Link, useParams } from 'react-router-dom';
import { Profession, ScoreVariable, Survey } from '../../types/survey.ts';
import ReactMarkdown from 'react-markdown';
import { LoadingData } from '../../components/LoadingData.tsx';
import { LoadingList } from '../../components/LoadingList.tsx';
import {useData, useDataPage} from "../../API/hooks.ts";
export function SurveyResultPage() {
const { scoreVariableId } = useParams<{ scoreVariableId: string }>();
const scoreVariable = useData<ScoreVariable>('surveys', 'score-variables', scoreVariableId || 0);
const survey = useData<Survey>('surveys', 'surveys', scoreVariable?.survey || 0, !!scoreVariable);
const professions = useDataPage<Profession>(
'surveys',
'professions',
{ score_variable: scoreVariableId || 0 },
!!scoreVariable
);
return (
<div>
<LoadingData data={survey && scoreVariable?survey:scoreVariable}>
<h1>{survey?.title}</h1>
<div className={"result"}>
<h2>Ваш результат</h2>
<p>{scoreVariable?.title}</p>
</div>
<div className={"description"}>
<ReactMarkdown>{scoreVariable?.description}</ReactMarkdown>
</div>
<div className={"profession-list"}>
<h3>Подходящие профессии</h3>
<LoadingList
data={professions.items}
listElement={(profession)=>(
<Link key={profession.id} to={`/surveys/professions/${profession.id}/`} className={"profession"}>
{(profession as Profession).name}
</Link>
)}
/>
</div>
</LoadingData>
</div>
);
}

View File

@@ -10,3 +10,10 @@ export interface PageControl<T>{
nextPage(): void; nextPage(): void;
previousPage(): void; previousPage(): void;
} }
export interface FieldInfo {
name: string;
type: string;
title: string;
format?: string;
}

3
src/types/filters.ts Normal file
View File

@@ -0,0 +1,3 @@
export type FiltersState = Record<string, unknown>
export type VisibilityState = Record<string, boolean>

View File

@@ -11,6 +11,47 @@ interface Statement extends UniqueItem{
score_variable: number; score_variable: number;
} }
export interface QuestionPair extends UniqueItem{ export enum QuestionType{
QuestionPair,
AgreementQuestion
}
export interface Question extends UniqueItem{
questionType: QuestionType
}
export interface QuestionPair extends Question{
statements: Statement[]
}
export interface AgreementQuestion extends Question{
question: string;
score_variable: number;
}
export interface ScoreVariable {
id: string;
name: string;
title: string;
description: string;
survey: number;
}
export interface Profession extends UniqueItem {
name: string;
description: string;
}
export interface Specialty extends UniqueItem {
name: string;
institution: number;
link?: string;
}
export interface Institution extends UniqueItem {
name: string;
} }

44
src/types/users.ts Normal file
View File

@@ -0,0 +1,44 @@
import {UniqueItem} from "./common.ts";
export type UserContextType = {
user: Account | undefined;
login: (credentials: TokenObtainPair) => Promise<boolean>;
logout: () => void;
};
export interface ContactData{
first_name: string;
last_name?: string;
middle_name?: string;
phone?: string;
email?:string;
}
export interface Account extends UniqueItem, ContactData{
login: string;
role?: number;
roleTitle?: string;
is_staff: boolean;
is_active: boolean;
}
export interface TokenObtainPair {
login: string;
password: string;
}
export interface TokenRefresh {
refresh: string;
}
export interface TokenVerify {
token: string;
}
export interface JwtTokenResponse {
access: string;
refresh: string;
}

5
src/utils/checkdebug.ts Normal file
View File

@@ -0,0 +1,5 @@
/*
`true` if debug mode enabled and `false` otherwise
*/
export const DEBUG_MODE: boolean = process.env.NODE_ENV === "development";

View File

@@ -1,4 +1,36 @@
/*
`true` if debug mode enabled and `false` otherwise
*/ import {JwtTokenResponse} from "../types/users.ts";
export const DEBUG_MODE: boolean = process.env.NODE_ENV === "development"; import {DEBUG_MODE} from "./checkdebug.ts";
export function getTokensFromCookies(): JwtTokenResponse | undefined {
const cookies = document.cookie.split('; ').reduce((acc, cookie) => {
const [key, value] = cookie.split('=');
acc[key] = decodeURIComponent(value);
return acc;
}, {} as Record<string, string>);
const access = cookies['access_token'];
const refresh = cookies['refresh_token'];
if (access && refresh) {
return { access, refresh };
}
return undefined;
}
export const createQueryString = (params: Record<string, string | number | boolean | undefined>): string => {
const filteredParams = Object.entries(params)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
return filteredParams.join('&');
};
export function hostUrl(url: string){
return DEBUG_MODE? "http://localhost:8000"+url: url;
}
export function apiUrl(url: string) {
return hostUrl("/api/v1"+url);
}

16
src/utils/cookies.ts Normal file
View File

@@ -0,0 +1,16 @@
export function setCookie(name: string, value: string, days: number) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
export function getCookie(name: string) {
return document.cookie.split('; ')
.reduce((prev: string | null, current: string) => {
const [key, value] = current.split('=');
return key === name ? decodeURIComponent(value) : prev;
}, null);
}
export function deleteCookie(name: string) {
setCookie(name, '', -1);
}

View File

@@ -0,0 +1,15 @@
import {UserContext} from "./UserContext.ts";
import {useContext} from "react";
import {UserContextType} from "../../types/users.ts";
export function useUser(): undefined| UserContextType {
const context = useContext(UserContext);
if (!context) {
console.error('useUser must be used within a UserProvider');
return undefined;
}
return context;
}

View File

@@ -0,0 +1,5 @@
import {UserContextType} from "../../types/users.ts";
import {createContext} from "react";
export const UserContext = createContext<UserContextType | undefined>(undefined);

View File

@@ -0,0 +1,104 @@
import React, {ReactNode, useEffect, useState} from 'react';
import {Account, JwtTokenResponse, TokenObtainPair} from '../../types/users.ts';
import {UserContext} from "./UserContext.ts";
import {refreshToken, verifyToken, login as apiLogin} from "../../API/users.ts";
import {getFetch} from "../../API/common.ts";
import {deleteCookie, getCookie, setCookie} from "../cookies.ts";
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<Account | undefined>(() => {
const storedUser = localStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : undefined;
});
const [tokens, setTokens] = useState<JwtTokenResponse | undefined>(() => {
const accessToken = getCookie('access_token');
const refreshToken = getCookie('refresh_token');
return accessToken && refreshToken ? { access: accessToken, refresh: refreshToken } : undefined;
});
useEffect(() => {
if (tokens?.access) {
// Устанавливаем куки для токенов
setCookie('access_token', tokens.access, 7);
setCookie('refresh_token', tokens.refresh, 7);
fetchCurrentUser().then(userData => setUser(userData));
} else {
// Удаляем куки, если токены отсутствуют
deleteCookie('access_token');
deleteCookie('refresh_token');
}
}, [tokens]);
useEffect(() => {
if (user) {
localStorage.setItem('user', JSON.stringify(user));
} else {
localStorage.removeItem('user');
}
}, [user]);
const fetchCurrentUser = async (): Promise<Account | undefined> => {
let response = await getFetch(`/users/auth/users/me/`);
if (!response.success){
return undefined;
}
response = await getFetch(`/users/auth/users/${(response.body as Account).id}/`);
if (!response.success){
return undefined;
}
const account = response.body as Account ;
if (account.role){
response = await getFetch(`/users/roles/${account.role}/`);
if (response.success){
account.roleTitle = (response.body as {name:string}).name;
}
}
return account;
};
const handleLogin = async (credentials: TokenObtainPair) => {
const tokens = await apiLogin(credentials);
if (tokens) {
setTokens(tokens);
return true;
}
return false;
};
const handleLogout = () => {
setUser(undefined);
setTokens(undefined);
};
useEffect(() => {
async function validateToken() {
if (tokens?.access) {
const isValid = await verifyToken({ token: tokens.access });
if (!isValid && tokens.refresh) {
const newTokens = await refreshToken({ refresh: tokens.refresh });
if (newTokens) {
setTokens(newTokens);
} else {
handleLogout();
}
} else if (!isValid) {
handleLogout();
}
}
}
validateToken().then();
}, [tokens]);
return (
<UserContext.Provider value={{ user, login: handleLogin, logout: handleLogout }}>
{children}
</UserContext.Provider>
);
}

View File

@@ -16,11 +16,11 @@
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": false,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": false,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": false,
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": ["src"] "include": ["src"]

View File

@@ -14,11 +14,11 @@
"noEmit": true, "noEmit": true,
/* Linting */ /* Linting */
"strict": true, "strict": false,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": false,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": false
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }