Compare commits
13 Commits
b5b2439bd8
...
incollege
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea02dcb0da | ||
|
|
553242c8ee | ||
|
|
fd9fddbac9 | ||
|
|
94b9191ccf | ||
|
|
672a884ae9 | ||
|
|
b4ace0d368 | ||
|
|
aaf425625b | ||
|
|
3dac31613e | ||
|
|
c3244ce6f6 | ||
|
|
546192b96d | ||
|
|
054cb63d71 | ||
|
|
ab10918dc4 | ||
|
|
7538faa699 |
31
package-lock.json
generated
31
package-lock.json
generated
@@ -8,6 +8,8 @@
|
||||
"name": "profi-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -1312,6 +1314,11 @@
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/file-saver": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
|
||||
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A=="
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -1339,6 +1346,17 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz",
|
||||
"integrity": "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
|
||||
@@ -2255,6 +2273,11 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -3930,6 +3953,14 @@
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"deploy": "python ../build_front_end.py"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,187 +1,122 @@
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {DEBUG_MODE} from "../utils/common";
|
||||
import {ApiResponse, PageResponse} from "../types/api.ts";
|
||||
import {PageControl} from "../types/common.ts";
|
||||
import {Institution} from "../types/survey.ts";
|
||||
|
||||
export function hostUrl(url: string){
|
||||
return DEBUG_MODE? "http://localhost:8000"+url: url;
|
||||
}
|
||||
|
||||
export function apiUrl(url: string) {
|
||||
return hostUrl("/api/v1"+url);
|
||||
}
|
||||
import {apiUrl, getTokensFromCookies} from "../utils/common.ts";
|
||||
import {ApiResponse} from "../types/api.ts";
|
||||
|
||||
|
||||
export async function getFetch(path:string) {
|
||||
export async function getFetch(path: string): Promise<ApiResponse> {
|
||||
const finalTokens = getTokensFromCookies();
|
||||
|
||||
try{
|
||||
const response = await fetch(
|
||||
apiUrl(path), {
|
||||
method: "GET",
|
||||
try {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }),
|
||||
},
|
||||
credentials: "include"
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.ok){
|
||||
if (!response.ok) {
|
||||
const body = await response.json();
|
||||
|
||||
console.error('Error fetching:', 'Network response was not ok', body);
|
||||
|
||||
return {
|
||||
body: body,
|
||||
success: false
|
||||
} as ApiResponse;
|
||||
|
||||
}
|
||||
return {
|
||||
body: await response.json(),
|
||||
success: true,
|
||||
} as ApiResponse
|
||||
return { body, success: false };
|
||||
}
|
||||
|
||||
catch {
|
||||
return {
|
||||
success: false
|
||||
} as ApiResponse;
|
||||
return { body: await response.json(), success: true };
|
||||
} catch (error) {
|
||||
console.error('Error fetching:', error);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export async function postFetch(
|
||||
path: string,
|
||||
body: object | FormData
|
||||
): Promise<ApiResponse> {
|
||||
const finalTokens = getTokensFromCookies();
|
||||
const isFormData = body instanceof FormData;
|
||||
|
||||
const createQueryString = (params: Record<string, string | number | boolean | undefined>): string => {
|
||||
const filteredParams = Object.entries(params)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
return filteredParams.join('&');
|
||||
};
|
||||
|
||||
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 (data === null && needLoad){
|
||||
getFetch(`/${application}/${endpoint}/?${createQueryString(params)}`).then((r)=>{
|
||||
if (r.success){
|
||||
const pageData = r.body as PageResponse<T>;
|
||||
|
||||
setData(pageData.results);
|
||||
try {
|
||||
|
||||
const headers = new Headers();
|
||||
|
||||
if (finalTokens?.access){
|
||||
headers.append("Authorization", `Bearer ${finalTokens.access}`)
|
||||
}
|
||||
else {
|
||||
setData([]);
|
||||
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 };
|
||||
} catch (error) {
|
||||
console.error('Error posting:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
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, needLoad]
|
||||
)
|
||||
body: isFormData ? body as FormData : JSON.stringify(body),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// 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)
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.json();
|
||||
console.error('Error patching:', 'Network response was not ok', responseBody);
|
||||
return { body: responseBody, success: false };
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
// changePage(previousUrl)
|
||||
return { body: await response.json(), success: true };
|
||||
} catch (error) {
|
||||
console.error('Error patching:', error);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
export async function deleteFetch(path: string): Promise<ApiResponse> {
|
||||
const finalTokens = getTokensFromCookies();
|
||||
|
||||
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);
|
||||
}
|
||||
})
|
||||
}
|
||||
try {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }),
|
||||
},
|
||||
[data, application, endpoint, id, needLoad]
|
||||
)
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
export function useCachedData<T>(application: string, endpoint: string) {
|
||||
const [cache, setCache] = useState<Map<string | number, T | undefined>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
// Очистка кеша или другие побочные эффекты при необходимости
|
||||
return () => {
|
||||
setCache(new Map());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
229
src/API/hooks.ts
Normal file
229
src/API/hooks.ts
Normal 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
54
src/API/users.ts
Normal 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('Не удалось изменить пароль');
|
||||
}
|
||||
}
|
||||
58
src/App.tsx
58
src/App.tsx
@@ -1,14 +1,18 @@
|
||||
// App.tsx
|
||||
import {BrowserRouter as Router, Routes, Route, Link, Navigate, Outlet, useNavigate} from 'react-router-dom';
|
||||
import {BrowserRouter as Router, Routes, Route, Navigate} from 'react-router-dom';
|
||||
import {MainPage} from "./pages/MainPage.tsx";
|
||||
import {SurveyPage} from "./pages/SurveyPage.tsx";
|
||||
import {DashboardPage} from "./pages/DashboardPage.tsx";
|
||||
import { SurveyResultPage } from './pages/SurveyResultPage.tsx';
|
||||
import { ProfessionPage } from './pages/ProfessionPage.tsx';
|
||||
import React, {useEffect} from "react";
|
||||
import {SurveyPage} from "./pages/surveys/SurveyPage.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 {useUser} from "./utils/users/UseUser.ts";
|
||||
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() {
|
||||
@@ -22,6 +26,7 @@ export default function App() {
|
||||
|
||||
<Route path="/dashboard/*" element={<DashboardLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path=":applicationName/:endpointName/" element={<DataPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -40,45 +45,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
|
||||
export function DashboardLayout() {
|
||||
const { user } = useUser();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate("/login/")
|
||||
}
|
||||
}, [navigate, user]);
|
||||
|
||||
if (!user){
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="header">
|
||||
<h1 className="logo">Pro-Fi</h1>
|
||||
</div>
|
||||
<div className="content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function MainLayout() {
|
||||
return (
|
||||
<>
|
||||
<div className="header">
|
||||
<Link to="/dashboard" className="menu-button">☰</Link>
|
||||
<h1 className="logo">Pro-Fi Test</h1>
|
||||
</div>
|
||||
<div className="content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
58
src/components/FilteredTable.tsx
Normal file
58
src/components/FilteredTable.tsx
Normal 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
109
src/components/Filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function LoadingData({data, children}: {data: object|null|undefined, chil
|
||||
return <div className={"loading"}>Идёт загрузка...</div>
|
||||
}
|
||||
if (data === undefined){
|
||||
return <Link className={"not-found"} to={"/"}>404 НИЧЕГО НЕ НАЙДЕНО</Link>
|
||||
return <Link className={"not-found"} to={"/"}>404 НЕ НАЙДЕНО</Link>
|
||||
}
|
||||
return children;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {UniqueItem} from "../types/common.ts";
|
||||
import {JSX} from "react";
|
||||
|
||||
export function LoadingList({data, listElement}: {data: UniqueItem[]|null, listElement(e:UniqueItem, i:number):unknown}) {
|
||||
if (data === null){
|
||||
|
||||
12
src/css/Colors.css
Normal file
12
src/css/Colors.css
Normal 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
65
src/css/Containers.css
Normal 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
101
src/css/Global.css
Normal 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;
|
||||
}
|
||||
105
src/hooks/useTableFilters.ts
Normal file
105
src/hooks/useTableFilters.ts
Normal 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
4
src/img/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 33 KiB |
47
src/layouts/DashboardLayout.tsx
Normal file
47
src/layouts/DashboardLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/layouts/MainLayout.tsx
Normal file
26
src/layouts/MainLayout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/layouts/css/MainLayout.css
Normal file
25
src/layouts/css/MainLayout.css
Normal 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)
|
||||
}
|
||||
@@ -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 App from './App.tsx'
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function DashboardPage() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +1,72 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useUser} from "../utils/users/UseUser.ts";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
export function LoginPage() {
|
||||
return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import '../css/MainPage.css';
|
||||
import {useDataPage} from "../API/common.ts";
|
||||
import {Survey} from "../types/survey.ts";
|
||||
import {LoadingList} from "../components/LoadingList.tsx";
|
||||
import {useDataPage} from "../API/hooks.ts";
|
||||
import "./css/MainPage.css"
|
||||
|
||||
export function MainPage() {
|
||||
|
||||
@@ -12,20 +12,42 @@ export function MainPage() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="main-page">
|
||||
<div className="main-page container flex-col items-center gap-2 margin-center">
|
||||
<h2 className="title">Тесты для школьников и абитуриентов</h2>
|
||||
|
||||
<div className={"surveys-list"}>
|
||||
<LoadingList data={surveys.items} listElement={(s: Survey)=>(
|
||||
<div className={"surveys-list container flex-col items-center gap-1"}>
|
||||
<LoadingList data={surveys.items} listElement={(s: Survey) => (
|
||||
<Link key={s.id} to={`surveys/${s.id}/`}>{s.title}</Link>
|
||||
)}/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
8
src/pages/css/MainPage.css
Normal file
8
src/pages/css/MainPage.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.main-page{
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
width: 1000px;
|
||||
}
|
||||
71
src/pages/dashboards/DashboardPage.tsx
Normal file
71
src/pages/dashboards/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/pages/dashboards/DataPage.tsx
Normal file
91
src/pages/dashboards/DataPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {useCachedData, useData, useDataPage} from '../API/common';
|
||||
import { Institution, Profession, Specialty } from '../types/survey';
|
||||
import { Institution, Profession, Specialty } from '../../types/survey.ts';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { LoadingData } from '../components/LoadingData';
|
||||
import { LoadingList } from '../components/LoadingList';
|
||||
import { LoadingData } from '../../components/LoadingData.tsx';
|
||||
import { LoadingList } from '../../components/LoadingList.tsx';
|
||||
import {useCachedData, useData, useDataPage} from "../../API/hooks.ts";
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ProfessionPage() {
|
||||
<SpecialtyBlock
|
||||
key={specialty.id}
|
||||
specialty={specialty as Specialty}
|
||||
institution={institutions((specialty as Specialty).institution)}
|
||||
institution={institutions.get((specialty as Specialty).institution)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Survey,
|
||||
Question,
|
||||
QuestionPair,
|
||||
AgreementQuestion,
|
||||
QuestionType,
|
||||
ScoreVariable,
|
||||
} from "../types/survey";
|
||||
import { useData, useDataPage } from "../API/common";
|
||||
import { LoadingData } from "../components/LoadingData";
|
||||
import { LoadingList } from "../components/LoadingList";
|
||||
|
||||
} 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 }>();
|
||||
@@ -92,7 +92,7 @@ export function SurveyPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="survey-page">
|
||||
<div className="survey-page container items-center flex-col">
|
||||
<LoadingData data={survey}>
|
||||
<h1>{survey?.title}</h1>
|
||||
<div>{survey?.description}</div>
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useData, useDataPage } from '../API/common';
|
||||
import { Profession, ScoreVariable, Survey } from '../types/survey';
|
||||
import { Profession, ScoreVariable, Survey } from '../../types/survey.ts';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { LoadingData } from '../components/LoadingData';
|
||||
import { LoadingList } from '../components/LoadingList';
|
||||
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 }>();
|
||||
@@ -11,6 +11,9 @@ export interface PageControl<T>{
|
||||
previousPage(): void;
|
||||
}
|
||||
|
||||
export interface CachedData<T>{
|
||||
|
||||
export interface FieldInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
title: string;
|
||||
format?: string;
|
||||
}
|
||||
3
src/types/filters.ts
Normal file
3
src/types/filters.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type FiltersState = Record<string, unknown>
|
||||
export type VisibilityState = Record<string, boolean>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {UniqueItem} from "./common.ts";
|
||||
|
||||
export type UserContextType = {
|
||||
user: Account | undefined;
|
||||
login: (credentials: TokenObtainPair) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export interface ContactData{
|
||||
first_name: string;
|
||||
@@ -9,14 +14,31 @@ export interface ContactData{
|
||||
email?:string;
|
||||
}
|
||||
|
||||
interface Role extends UniqueItem{
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Account extends UniqueItem, ContactData{
|
||||
login: string;
|
||||
role?: Role;
|
||||
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
5
src/utils/checkdebug.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
`true` if debug mode enabled and `false` otherwise
|
||||
*/
|
||||
|
||||
export const DEBUG_MODE: boolean = process.env.NODE_ENV === "development";
|
||||
@@ -1,4 +1,36 @@
|
||||
/*
|
||||
`true` if debug mode enabled and `false` otherwise
|
||||
*/
|
||||
export const DEBUG_MODE: boolean = process.env.NODE_ENV === "development";
|
||||
|
||||
|
||||
import {JwtTokenResponse} from "../types/users.ts";
|
||||
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
16
src/utils/cookies.ts
Normal 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);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
|
||||
import {UserContext} from "./UserContext.ts";
|
||||
import {useContext} from "react";
|
||||
import {UserContextType} from "../../types/users.ts";
|
||||
|
||||
|
||||
|
||||
export function useUser() {
|
||||
export function useUser(): undefined| UserContextType {
|
||||
const context = useContext(UserContext);
|
||||
if (!context) {
|
||||
throw new Error('useUser must be used within a UserProvider');
|
||||
console.error('useUser must be used within a UserProvider');
|
||||
return undefined;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
import {Account} from "../../types/users.ts";
|
||||
import {UserContextType} from "../../types/users.ts";
|
||||
import {createContext} from "react";
|
||||
|
||||
export type UserContextType = {
|
||||
user: Account | undefined;
|
||||
login: (userData: Account) => void;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, {ReactNode, useEffect, useState} from 'react';
|
||||
import {Account} from '../../types/users.ts';
|
||||
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 }) {
|
||||
@@ -8,27 +11,93 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
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));
|
||||
document.cookie = `user=${encodeURIComponent(JSON.stringify(user))}; path=/;`;
|
||||
} else {
|
||||
localStorage.removeItem('user');
|
||||
document.cookie = 'user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;';
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const login = (userData: Account) => {
|
||||
setUser(userData);
|
||||
|
||||
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 logout = () => {
|
||||
setUser(undefined);
|
||||
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, logout }}>
|
||||
<UserContext.Provider value={{ user, login: handleLogin, logout: handleLogout }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedSideEffectImports": false,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedSideEffectImports": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user