Added Team mamagement with backend integration

This commit is contained in:
2026-04-30 11:11:11 +03:00
parent 8449da8373
commit 45c157b910
35 changed files with 1570 additions and 4671 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.env .env
package-lock.json
# Logs # Logs
logs logs
*.log *.log

4390
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,17 +10,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^8.3.0", "@mantine/core": "^9.1.1",
"@mantine/hooks": "^8.3.0", "@mantine/hooks": "^9.1.1",
"@mantine/notifications": "^8.3.1", "@mantine/notifications": "^9.1.1",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "^3.34.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"immer": "^11.1.4", "immer": "^11.1.4",
"keycloak-js": "^26.2.3", "keycloak-js": "^26.2.3",
"miew-react": "^0.11.0", "miew-react": "^0.11.0",
"react": "^18.2.0", "react": "^19.2.5",
"react-dom": "^18.2.0", "react-dom": "^19.2.5",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-resizable-panels": "^4.5.4", "react-resizable-panels": "^4.5.4",
"react-router": "^7.9.6", "react-router": "^7.9.6",

View File

@@ -0,0 +1,117 @@
import keycloak from "Api/Keycloak/Keycloak";
import axios from "axios";
import type { Team, TeamMember } from "Types/Team/Team";
export interface TeamCreateRequest {
name: string;
description?: string | null;
}
export interface TeamUpdateRequest {
team_id: number;
name: string;
description?: string | null;
}
export interface TeamCreateResponse {
team_id: number;
}
export interface TeamListRequest {
page_num: number;
page_size: number;
}
export interface TeamDeleteRequest {
team_id: number;
}
export interface MemberAddRequest {
team_id: number;
user_id: string;
permissions: string[];
}
export interface MemberDeleteRequest {
team_id: number;
user_id: string;
}
export interface TeamsListResponse {
teams: Team[];
cur_page: number;
total_teams: number;
page_size: number;
}
const api = axios.create({
baseURL: `${import.meta.env.VITE_QUANTUM_BACKEND_URL}/team`,
headers: { "Content-Type": "application/json" },
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = keycloak.token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 1. Create team
export const createTeam = async (
data: TeamCreateRequest,
): Promise<TeamCreateResponse> => {
const response = await api.post<TeamCreateResponse>("", data);
return response.data;
};
export const updateTeamRequest = async (
data: TeamUpdateRequest,
): Promise<TeamUpdateRequest> => {
const response = await api.put<TeamUpdateRequest>("", data);
return response.data;
};
// 2. Get teams list (paginated)
export const getTeams = async (
params: Partial<TeamListRequest>,
): Promise<TeamsListResponse> => {
const response = await api.get<TeamsListResponse>("", {
headers: {},
params,
});
return response.data;
};
export const getShortTeamsList = async (): Promise<
{ team_id: number; team_name: string }[]
> => {
const response =
await api.get<{ team_id: number; team_name: string }[]>("short_list");
return response.data;
};
// 3. Get single team by ID
export const getTeam = async (teamId: number): Promise<Team> => {
const response = await api.get<Team>(`${teamId}`);
return response.data;
};
// 4. Delete team
export const deleteTeam = async (teamId: number): Promise<void> => {
await api.delete("", { data: { team_id: teamId } });
};
export const addMember = async (
data: MemberAddRequest,
): Promise<TeamMember> => {
const response = await api.put<TeamMember>("user", data);
return response.data;
};
export const deleteMember = async (
data: MemberDeleteRequest,
): Promise<void> => {
await api.delete("user", { data: data });
};

View File

@@ -28,9 +28,6 @@ export const GetCurrentUserInfo = async (): Promise<UserData | undefined> => {
export const UpdateCurrentUserInfo = async ( export const UpdateCurrentUserInfo = async (
profile_picture_path: string, profile_picture_path: string,
): Promise<UserData | undefined> => { ): Promise<UserData | undefined> => {
try {
// Trigger the UPDATE_PASSWORD required action
// This will redirect the user to Keycloak's password change page
const response = await axios.put( const response = await axios.put(
`${import.meta.env.VITE_QUANTUM_BACKEND_URL}/user`, `${import.meta.env.VITE_QUANTUM_BACKEND_URL}/user`,
{ profile_picture_path: profile_picture_path }, { profile_picture_path: profile_picture_path },
@@ -45,8 +42,20 @@ export const UpdateCurrentUserInfo = async (
if (response.status === 200) { if (response.status === 200) {
return response.data; return response.data;
} }
} catch (error) { };
console.error("Error Getting User Data", error);
throw error; export const GetUserByEmail = async (
} email: string,
): Promise<UserData | undefined> => {
const response = await axios.get(
`${import.meta.env.VITE_QUANTUM_BACKEND_URL}/user/${email}`,
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${keycloak.token}`,
},
withCredentials: true, // Important: allows credentials in CORS
},
);
return response.data;
}; };

View File

@@ -14,10 +14,9 @@ import { useEffect } from "react";
import { useMediaQuery } from "@mantine/hooks"; import { useMediaQuery } from "@mantine/hooks";
import BreadCrumbs from "Routes/Breadcrumbs/Breadcrumbs"; import BreadCrumbs from "Routes/Breadcrumbs/Breadcrumbs";
import keycloak from "Api/Keycloak/Keycloak"; import keycloak from "Api/Keycloak/Keycloak";
import { AuthenticationStore } from "Stores/AuthenticationStore";
function App() { function App() {
const { is_navbar_open, set_navbar_open } = useLayoutStore(); const { is_navbar_open, set_navbar_open } = useLayoutStore();
const { set_token } = AuthenticationStore();
const theme = useMantineTheme(); const theme = useMantineTheme();
const location = useLocation(); const location = useLocation();
@@ -32,14 +31,9 @@ function App() {
useEffect(() => { useEffect(() => {
const refreshToken = () => { const refreshToken = () => {
keycloak if (keycloak.authenticated)
.updateToken(60) keycloak.updateToken(60).catch((error) => {
.then((refreshed) => { console.log(error);
if (refreshed) {
set_token(keycloak.token);
}
})
.catch(() => {
keycloak.logout(); keycloak.logout();
}); });
}; };

View File

@@ -31,7 +31,6 @@ function CustomButton({
onClick = () => {}, onClick = () => {},
disabled = false, disabled = false,
}: CustomButtonProps) { }: CustomButtonProps) {
console.log(color);
return ( return (
<UnstyledButton <UnstyledButton
className={ className={

View File

@@ -23,7 +23,7 @@ import { Link, useLocation } from "react-router";
import { useState, type ForwardRefExoticComponent } from "react"; import { useState, type ForwardRefExoticComponent } from "react";
import { routes } from "Routes/Routes"; import { routes } from "Routes/Routes";
import keycloak from "Api/Keycloak/Keycloak"; import keycloak from "Api/Keycloak/Keycloak";
import { AuthenticationStore } from "Stores/AuthenticationStore"; import { useAuthenticationStore } from "Stores/AuthenticationStore";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
interface SubtleLinkButtonProps { interface SubtleLinkButtonProps {
@@ -49,7 +49,7 @@ function SubtleLinkButton(props: SubtleLinkButtonProps) {
} }
function Sidebar() { function Sidebar() {
const { profile } = AuthenticationStore(); const { profile, profile_picture_path } = useAuthenticationStore();
const [open, set_open] = useState(false); const [open, set_open] = useState(false);
const theme = useMantineTheme(); const theme = useMantineTheme();
const location = useLocation(); // get current URL const location = useLocation(); // get current URL
@@ -120,7 +120,7 @@ function Sidebar() {
{keycloak.authenticated && ( {keycloak.authenticated && (
<> <>
<div className="sidebar_bottom_top"> <div className="sidebar_bottom_top">
<Avatar radius="xl" /> <Avatar radius="xl" src={profile_picture_path} />
<Stack className="userInfo"> <Stack className="userInfo">
<Text size="md">{profile?.username}</Text> <Text size="md">{profile?.username}</Text>
<Text size="sm" c="dimmed" truncate="end"> <Text size="sm" c="dimmed" truncate="end">

View File

@@ -19,7 +19,11 @@
color: var(--mantine-color-accent-filled); color: var(--mantine-color-accent-filled);
* { * {
width: 100%; width: 100%;
text-align: center;
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
} }
} }

View File

@@ -6,42 +6,38 @@ import { IconTrash } from "@tabler/icons-react";
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import { useExperimentStore } from "Stores/ExperimentStore"; import { useExperimentStore } from "Stores/ExperimentStore";
function ExperimentsListCard(props: Experiment) { function ExperimentsListCard(props: {
experiment: Experiment;
team: { team_id: number; team_name: string } | undefined;
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { selectExperiment, removeExperiment, tasks, teams } = const { removeExperiment, tasks } = useExperimentStore();
useExperimentStore();
const team = teams.find((team) => { const experiment_tasks = tasks.filter(
return team.id == props.team_id; (a) => a.id in props.experiment.tasks_ids,
}); );
const experiment_tasks = tasks.filter((a) => a.id in props.tasks_ids);
const handleDelete = () => { const handleDelete = () => {
removeExperiment(props.id); removeExperiment(props.experiment.id);
}; };
return ( return (
<Card <Card
className="ExperimentsListCard" className="ExperimentsListCard"
onClick={() => { onClick={() => {
console.log(props.id); navigate(props.experiment.id.toString());
selectExperiment(props.id);
navigate(props.id.toString());
}} }}
> >
<SimpleGrid cols={3}> <SimpleGrid cols={3}>
<div className="ExperimentSectionWithLine"> <div className="ExperimentSectionWithLine">
<Text mb="sm" size="md" style={{ textDecorationLine: "underline" }}> <Text mb="sm" size="md" style={{ textDecorationLine: "underline" }}>
{props.name} Эксперимент: {props.experiment.name}
</Text> </Text>
<Text mb="sm" size="md"> <Text mb="sm" size="md">
Team: {team ? team.name : "ERROR"} Команда: {props.team?.team_name}
</Text> </Text>
<Pill className="ExperimentPill"> <Pill className="ExperimentPill">
<Text mb="sm" size="md"> <Text size="md">Статус: {props.experiment.experiment_status}</Text>
Статус: {props.experiment_status}
</Text>
</Pill> </Pill>
</div> </div>
<div className="ExperimentSectionWithLine"> <div className="ExperimentSectionWithLine">
@@ -62,15 +58,7 @@ function ExperimentsListCard(props: Experiment) {
); );
})} })}
{experiment_tasks.length == 0 ? ( {experiment_tasks.length == 0 ? (
<Pill <Pill size="md" className="ExperimentPill2">
size="md"
className="ExperimentPill2"
style={{
textWrap: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
Нет Задач Нет Задач
</Pill> </Pill>
) : ( ) : (
@@ -81,8 +69,12 @@ function ExperimentsListCard(props: Experiment) {
<div className="RightExperimentSection"> <div className="RightExperimentSection">
<div className="TopRightContainer"> <div className="TopRightContainer">
<div className="dateContainer"> <div className="dateContainer">
<Text size="sm">{props.date_created.toLocaleDateString()} </Text> <Text size="sm">
<Text size="sm">{props.date_created.toLocaleTimeString()}</Text> {props.experiment.date_created.toLocaleDateString("ru")}{" "}
</Text>
<Text size="sm">
{props.experiment.date_created.toLocaleTimeString("ru")}
</Text>
</div> </div>
<UnstyledButton <UnstyledButton
onClick={(e: MouseEvent) => { onClick={(e: MouseEvent) => {
@@ -100,16 +92,16 @@ function ExperimentsListCard(props: Experiment) {
display: "flex", display: "flex",
justifyContent: "right", justifyContent: "right",
alignItems: "center", alignItems: "center",
gap: "10px",
}} }}
> >
<Text> <Text> Тип эксперимента: </Text>
{" "} <Pill className="ExperimentPill">
Тип эксперимента:{" "} {props.experiment.experiment_type}
<Pill className="ExperimentPill2">{props.experiment_type}</Pill> </Pill>
</Text>
</div> </div>
<Text color="secondary" size="sm"> <Text c="dimmed" size="sm">
#{props.id} #{props.experiment.id}
</Text> </Text>
</div> </div>
</SimpleGrid> </SimpleGrid>

View File

@@ -0,0 +1,6 @@
.TeamMembers {
margin-top: 10px;
gap: 10px;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,242 @@
import {
Avatar,
Card,
MultiSelect,
Pill,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import {
IconCheck,
IconCrown,
IconForbid,
IconPencil,
IconTrash,
} from "@tabler/icons-react";
import CustomButton from "Components/CustomButton/CustomButton";
import type { Team, TeamMember } from "Types/Team/Team";
import "./TeamMemberCard.css";
import { useState } from "react";
import { addMember, deleteMember } from "Api/QuantumBackend/TeamManagement";
import { useTeamStore } from "Stores/TeamsStore";
import { notifications } from "@mantine/notifications";
function TeamMemberCard(props: { cur_team: Team; member: TeamMember }) {
const { updateTeam } = useTeamStore();
const [is_editing, set_is_editing] = useState<boolean>(false);
const [selected_permissions, set_selected_permissions] = useState<string[]>(
props.member.permissions,
);
return (
<Card padding="sm" withBorder orientation="horizontal">
<Card.Section
inheritPadding
px="xs"
style={{
display: "flex",
alignItems: "center",
flexDirection: "column",
justifyContent:
props.member.user.keycloak_id !=
props.cur_team.creator.user.keycloak_id
? "center"
: "",
}}
>
{props.member.user.keycloak_id ==
props.cur_team.creator.user.keycloak_id && <IconCrown />}
<Avatar radius="xl" src={props.member.user.profile_picture_path} />
</Card.Section>
<Card.Section
withBorder
inheritPadding
px="xs"
style={{ width: "250px" }}
>
<Stack>
<Text size="xl">{props.member.user.username}</Text>
<Text size="md">{props.member.user.email}</Text>
</Stack>
</Card.Section>
<Card.Section
inheritPadding
px="md"
withBorder
style={{
display: "flex",
flexGrow: 1,
alignItems: "center",
}}
>
<Stack>
<div style={{ display: "flex", gap: "20px" }}>
<Text size="md">Разрешения: </Text>
{props.member.user.keycloak_id !=
props.cur_team.creator.user.keycloak_id &&
(is_editing ? (
<div style={{ display: "flex", gap: "10px" }}>
<CustomButton
style="outline"
text="Сохранить"
textSize="sm"
icon={<IconPencil size={15} />}
onClick={() => {
addMember({
team_id: props.cur_team.id,
user_id: props.member.user.keycloak_id,
permissions: selected_permissions,
})
.then((member) => {
if (member) {
updateTeam(props.cur_team.id, {
members: [
...props.cur_team.members.filter(
(member) =>
member.user.keycloak_id !=
props.member.user.keycloak_id,
),
member,
],
});
set_is_editing(false);
notifications.show({
radius: "md",
title: "Права изменены успешно",
message: "",
icon: <IconCheck />,
style: { paddingLeft: "5px" },
});
}
})
.catch(() => {
notifications.show({
radius: "md",
title: "Ошибка изменения прав",
message: "",
color: "red",
icon: <IconForbid />,
style: { paddingLeft: "5px" },
});
});
}}
/>
<CustomButton
style="outline"
text="Отменить"
color="red"
textSize="sm"
icon={<IconPencil size={15} />}
onClick={() => {
set_is_editing(false);
set_selected_permissions(props.member.permissions);
}}
/>
</div>
) : (
<div>
<CustomButton
style="outline"
text="Изменить"
textSize="sm"
icon={<IconPencil size={15} />}
onClick={() => {
set_is_editing(true);
}}
/>
</div>
))}
</div>
<div
style={{
gap: "10px",
display: "flex",
flexDirection: "row",
}}
>
{!is_editing ? (
props.member.permissions.map((perm) => (
<Pill className="TeamPill">{perm}</Pill>
))
) : (
<MultiSelect
value={selected_permissions}
label=""
placeholder="Выберите разрешения"
searchable
data={props.cur_team.creator.permissions}
classNames={{ input: "MantineInput" }}
onChange={(value) => {
set_selected_permissions(value);
}}
/>
)}
</div>
</Stack>
</Card.Section>
<Card.Section
inheritPadding
px="md"
style={{
display: "flex",
flexDirection: "column",
}}
>
<UnstyledButton
onClick={(e) => {
e.stopPropagation();
deleteMember({
team_id: props.cur_team.id,
user_id: props.member.user.keycloak_id,
}).then(() => {
notifications.show({
radius: "md",
title: "Пользователь удален успешно",
message: "",
icon: <IconCheck />,
style: { paddingLeft: "5px" },
});
updateTeam(props.cur_team.id, {
members: [
...props.cur_team.members.filter(
(member) =>
member.user.keycloak_id != props.member.user.keycloak_id,
),
],
});
});
}}
style={{
cursor: "pointer",
width: "20px",
alignSelf: "flex-end",
}}
>
<IconTrash size={20} />
</UnstyledButton>
<Stack gap={"5px"}>
<Text style={{ alignSelf: "flex-end" }}>Добавлен:</Text>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "5px",
}}
>
<Text size="sm" style={{ textAlign: "right" }}>
{new Date(props.member.joined_at).toLocaleDateString("ru")}
</Text>
<Text size="sm" style={{ textAlign: "right" }}>
{new Date(props.member.joined_at).toLocaleTimeString("ru")}
</Text>
</div>
</Stack>
</Card.Section>
</Card>
);
}
export default TeamMemberCard;

View File

@@ -0,0 +1,56 @@
.TeamsListCard {
height: 120px;
padding: 12px;
}
.TeamSection {
border-right: 1px solid var(--mantine-color-contrast-filled);
padding-right: 10px;
}
.TeamPill {
background-color: var(--mantine-color-contrast-filled);
color: var(--mantine-color-primary-filled);
}
.TeamPill2 {
border: 2px solid var(--mantine-color-accent-filled);
background-color: transparent;
color: var(--mantine-color-accent-filled);
* {
width: 100%;
text-align: center;
}
}
.membersPills {
display: flex;
flex-direction: row;
gap: 10px;
}
.dateContainer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: right;
gap: 5px;
.p {
text-wrap: nowrap;
}
}
.TopRightContainer {
justify-content: right;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 15px;
justify-items: center;
}
.RightTeamSection {
text-align: right;
padding-right: 10px;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,211 @@
import { Card, Pill, Text, UnstyledButton } from "@mantine/core";
import "./TeamsListCard.css";
import { useNavigate } from "react-router";
import type { Team, TeamMember } from "Types/Team/Team";
import {
IconCancel,
IconCheck,
IconDoorExit,
IconTrash,
} from "@tabler/icons-react";
import type { MouseEvent } from "react";
import { useTeamStore } from "Stores/TeamsStore";
import { useAuthenticationStore } from "Stores/AuthenticationStore";
import { deleteMember, deleteTeam } from "Api/QuantumBackend/TeamManagement";
import { notifications } from "@mantine/notifications";
function TeamsListCard(props: Team) {
const navigate = useNavigate();
const { removeTeam, setTotalTeams, totalTeams } = useTeamStore();
const { profile } = useAuthenticationStore();
const handleDelete = () => {
// TODO: fix delete
deleteTeam(props.id)
.then(() => {
removeTeam(props.id);
setTotalTeams(totalTeams - 1);
notifications.show({
radius: "md",
title: "Команда удалена успешно",
message: "",
icon: <IconCheck />,
style: { paddingLeft: "5px" },
});
})
.catch(() => {
notifications.show({
radius: "md",
color: "red",
title: "Не удалось удалить команду",
message: "",
icon: <IconCancel />,
style: { paddingLeft: "5px" },
});
});
};
const handleLeave = () => {
// TODO: fix delete
if (profile && profile.id) {
deleteMember({ team_id: props.id, user_id: profile.id })
.then(() => {
removeTeam(props.id);
setTotalTeams(totalTeams - 1);
notifications.show({
radius: "md",
title: "Успешно вышел из команды",
message: "",
icon: <IconCheck />,
style: { paddingLeft: "5px" },
});
})
.catch(() => {
notifications.show({
radius: "md",
color: "red",
title: "Не удалось выйти из команды",
message: "",
icon: <IconCancel />,
style: { paddingLeft: "5px" },
});
});
}
};
return (
<Card
className="TeamsListCard"
onClick={() => {
navigate(props.id.toString());
}}
orientation="horizontal"
>
<Card.Section
withBorder
inheritPadding
px="xs"
style={{ width: "430px" }}
>
<Text mb="sm" size="md" style={{ textDecorationLine: "underline" }}>
Команда: {props.name}
</Text>
<Text mb="sm" size="md">
Владелец:
<Pill className="TeamPill" style={{ marginLeft: "10px" }}>
<Text size="md">{props.creator.user.username}</Text>
</Pill>
</Text>
<div className="membersPills">
<Text>Члены:</Text>
{props.members.map((team: TeamMember) => {
return (
<Pill className="TeamPill" style={{ marginLeft: "10px" }}>
<Text
size="md"
style={{
textWrap: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
{team.user.username}
</Text>
</Pill>
);
})}
</div>
</Card.Section>
<Card.Section
inheritPadding
px="xs"
withBorder
style={{
flexGrow: "1",
gap: "15px",
display: "flex",
flexDirection: "column",
}}
>
<Text>Разрешения:</Text>
<div
className="membersPills"
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
}}
>
{props.members
.find((member) => {
return profile && member.user.keycloak_id == profile?.id;
})
?.permissions.map((perm) => {
return (
<Pill className="TeamPill" style={{ marginLeft: "10px" }}>
<Text
size="md"
style={{
textWrap: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
{perm}
</Text>
</Pill>
);
})}
</div>
</Card.Section>
<Card.Section
inheritPadding
px="md"
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<div className="TopRightContainer">
<div className="dateContainer">
<Text size="sm">
{new Date(props.created_at).toLocaleDateString()}{" "}
</Text>
<Text size="sm">
{new Date(props.created_at).toLocaleTimeString()}
</Text>
</div>
{props.creator.user.keycloak_id == profile?.id && (
<UnstyledButton
onClick={(e: MouseEvent) => {
e.stopPropagation();
handleDelete();
}}
style={{ cursor: "pointer" }}
>
<IconTrash size={20} />
</UnstyledButton>
)}
{props.creator.user.keycloak_id != profile?.id && (
<UnstyledButton
onClick={(e: MouseEvent) => {
e.stopPropagation();
handleLeave();
}}
style={{ cursor: "pointer" }}
>
<IconDoorExit size={20} />
</UnstyledButton>
)}
</div>
<Text c="dimmed" size="sm" style={{ alignSelf: "flex-end" }}>
#{props.id}
</Text>
</Card.Section>
</Card>
);
}
export default TeamsListCard;

View File

@@ -1,17 +1,31 @@
import { Pagination } from "@mantine/core"; import { LoadingOverlay, Pagination } from "@mantine/core";
import "./PaginationContainer.css"; import "./PaginationContainer.css";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
interface PaginationContainerProps extends PropsWithChildren { interface PaginationContainerProps extends PropsWithChildren {
numberOfPages: number; numberOfPages: number;
isLoading: boolean;
activePage: number;
setPage: (page_num: number) => void;
} }
export function PaginationContainer(a: PaginationContainerProps) { export function PaginationContainer(a: PaginationContainerProps) {
return ( return (
<div className="PaginationContainer"> <div className="PaginationContainer">
<LoadingOverlay
visible={a.isLoading}
zIndex={1000}
overlayProps={{ radius: "sm", blur: 2 }}
loaderProps={{ size: 50, type: "dots" }}
/>
<div className="PaginationContents">{a.children}</div> <div className="PaginationContents">{a.children}</div>
{a.numberOfPages > 1 && ( {a.numberOfPages > 1 && (
<Pagination total={a.numberOfPages} color="accent" /> <Pagination
total={a.numberOfPages}
value={a.activePage}
onChange={a.setPage}
color="accent"
/>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,136 @@
import {
Center,
Modal,
MultiSelect,
Space,
TextInput,
Title,
} from "@mantine/core";
import { useEffect, useState } from "react";
import CustomButton from "Components/CustomButton/CustomButton";
import { addMember } from "Api/QuantumBackend/TeamManagement";
import { GetUserByEmail } from "Api/QuantumBackend/UserManagement";
import { notifications } from "@mantine/notifications";
import { IconForbid } from "@tabler/icons-react";
import { useTeamStore } from "Stores/TeamsStore";
import type { Team } from "Types/Team/Team";
interface NewExperimentModalProps {
isOpened: boolean;
setIsOpened: (opened: boolean) => void;
permissions: string[] | undefined;
team: Team | undefined;
}
export function AddMember(props: NewExperimentModalProps) {
const [email, setEmail] = useState("");
const [selectedPerms, setSelectedPerms] = useState<string[] | undefined>();
const { updateTeam } = useTeamStore();
//reset on open dialog
useEffect(() => {
if (props.isOpened) {
setEmail("");
setSelectedPerms(undefined);
}
}, [props.isOpened]);
const handleClose = () => {
props.setIsOpened(false);
};
const handleAddMember = () => {
if (props.team)
//TODO: add logic for backend server
GetUserByEmail(email)
.then((member) => {
if (member && props.team) {
addMember({
team_id: props.team.id,
user_id: member.keycloak_id,
permissions: selectedPerms ? selectedPerms : [],
})
.then((member) => {
if (member && props.team) {
updateTeam(props.team.id, {
members: [...props.team.members, member],
});
}
props.setIsOpened(false);
})
.catch(() => {
notifications.show({
radius: "md",
title: "Ошибка добавления пользователя в команду",
message: "",
color: "red",
icon: <IconForbid />,
style: { paddingLeft: "5px" },
});
});
}
})
.catch((error) => {
console.log(error);
notifications.show({
radius: "md",
color: "red",
title: "Пользователь с почтой не найден",
message: "",
icon: <IconForbid />,
style: { paddingLeft: "5px" },
});
});
};
return (
<Modal
opened={props.isOpened}
onClose={handleClose}
title=<Title size="xl">Добавить члена команды</Title>
centered
size="75%"
styles={{
content: { paddingLeft: "10px" },
title: { width: "100%" },
}}
>
<TextInput
value={email}
label="Почта члена команды"
placeholder="Введите почту"
required
classNames={{ input: "MantineInput" }}
onChange={(event) => {
setEmail(event.currentTarget.value);
}}
></TextInput>
<MultiSelect
value={selectedPerms}
label="Разрешения члена"
placeholder="Выберите разрешения"
searchable
data={props.permissions}
classNames={{ input: "MantineInput" }}
onChange={(value) => setSelectedPerms(value)}
/>
<Space h="md" />
<Center>
<CustomButton
disabled={email != "" ? false : true}
color="contrast"
onClick={handleAddMember}
text="Добавить члена"
></CustomButton>
<Space w="md" />
<CustomButton
color="contrast"
style="outline"
onClick={() => {
props.setIsOpened(false);
}}
text="Отменить"
></CustomButton>
</Center>
</Modal>
);
}

View File

@@ -15,17 +15,23 @@ import CustomButton from "Components/CustomButton/CustomButton";
interface NewExperimentModalProps { interface NewExperimentModalProps {
isOpened: boolean; isOpened: boolean;
setIsOpened: (opened: boolean) => void; setIsOpened: (opened: boolean) => void;
teams: { team_id: number; team_name: string }[] | undefined;
} }
export function NewExperimentModal(props: NewExperimentModalProps) { export function NewExperimentModal(props: NewExperimentModalProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const { addExperiment } = useExperimentStore(); const { addExperiment } = useExperimentStore();
const [selectedTeam, setSelectedTeam] = useState<{
label: string;
value: string;
}>();
//reset on open dialog //reset on open dialog
useEffect(() => { useEffect(() => {
if (props.isOpened) { if (props.isOpened) {
setName(""); setName("");
setDescription(""); setDescription("");
setSelectedTeam(undefined);
} }
}, [props.isOpened]); }, [props.isOpened]);
@@ -35,18 +41,19 @@ export function NewExperimentModal(props: NewExperimentModalProps) {
const handleCreateExperiment = () => { const handleCreateExperiment = () => {
//TODO: add logic for backend server //TODO: add logic for backend server
if (selectedTeam) {
addExperiment({ addExperiment({
id: 1, id: 1,
name: name, name: name,
description: description, description: description,
team_id: 1, team_id: Number(selectedTeam?.value),
tasks_ids: [], tasks_ids: [],
date_created: new Date(), date_created: new Date(),
experiment_status: "DRAFT", experiment_status: "DRAFT",
experiment_type: "a", experiment_type: "a",
}); });
props.setIsOpened(false); props.setIsOpened(false);
}
}; };
return ( return (
@@ -86,18 +93,28 @@ export function NewExperimentModal(props: NewExperimentModalProps) {
></Textarea> ></Textarea>
<Space h="md" /> <Space h="md" />
<Select <Select
value="" value={selectedTeam?.value}
label="Команда эксперимента" label="Команда эксперимента"
placeholder="Выберите команду эксперимента" placeholder="Выберите команду эксперимента"
searchable searchable
data={[]} required
data={
props.teams
? props.teams.map((team) => {
return {
value: team.team_id.toString(),
label: `${team.team_name} (#${team.team_id})`,
};
})
: []
}
classNames={{ input: "MantineInput" }} classNames={{ input: "MantineInput" }}
onChange={() => {}} onChange={(_value, option) => setSelectedTeam(option)}
/> />
<Space h="md" /> <Space h="md" />
<Center> <Center>
<CustomButton <CustomButton
disabled={name != "" ? false : true} disabled={name != "" && selectedTeam ? false : true}
color="contrast" color="contrast"
onClick={handleCreateExperiment} onClick={handleCreateExperiment}
text="Создать эксперимент" text="Создать эксперимент"

View File

View File

@@ -0,0 +1,116 @@
import {
Center,
Modal,
Space,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { useEffect, useState } from "react";
import "./NewTeam.css";
import CustomButton from "Components/CustomButton/CustomButton";
import { useTeamStore } from "Stores/TeamsStore";
import { createTeam, getTeam } from "Api/QuantumBackend/TeamManagement";
import { IconCheck } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
interface NewTeamModalProps {
isOpened: boolean;
setIsOpened: (opened: boolean) => void;
}
export function NewTeamModal(props: NewTeamModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const { addTeam, setTotalTeams, totalTeams } = useTeamStore();
//reset on open dialog
useEffect(() => {
if (props.isOpened) {
setName("");
setDescription("");
}
}, [props.isOpened]);
const handleClose = () => {
props.setIsOpened(false);
};
const handleCreateTeam = () => {
//TODO: add logic for backend server
//
createTeam({ name: name, description: description }).then((team) => {
if (team) {
getTeam(team.team_id).then((team) => {
if (team) {
setTotalTeams(totalTeams + 1);
addTeam(team);
notifications.show({
radius: "md",
title: "Команда создана успешно",
message: "",
icon: <IconCheck />,
style: { paddingLeft: "5px" },
});
}
});
props.setIsOpened(false);
}
});
};
return (
<Modal
opened={props.isOpened}
onClose={handleClose}
title=<Title size="xl">Новая Команда</Title>
centered
size="75%"
styles={{
content: { paddingLeft: "10px" },
title: { width: "100%" },
}}
>
<TextInput
value={name}
label="Имя команлы"
placeholder="Введите имя команды"
required
classNames={{ input: "MantineInput" }}
onChange={(event) => {
setName(event.currentTarget.value);
}}
></TextInput>
<Space h="md" />
<Textarea
value={description}
label="Описание команды"
placeholder="Введите описание команды"
minRows={4}
maxRows={10}
autosize
classNames={{ input: "MantineInput" }}
onChange={(event) => {
setDescription(event.currentTarget.value);
}}
></Textarea>
<Space h="md" />
<Center>
<CustomButton
disabled={name != "" ? false : true}
color="contrast"
onClick={handleCreateTeam}
text="Создать Команду"
></CustomButton>
<Space w="md" />
<CustomButton
color="contrast"
style="outline"
onClick={() => {
props.setIsOpened(false);
}}
text="Отменить"
></CustomButton>
</Center>
</Modal>
);
}

View File

@@ -15,7 +15,12 @@ function DevicesPage() {
/> />
</Helmet> </Helmet>
<div className="DevicesPage"> <div className="DevicesPage">
<PaginationContainer numberOfPages={1}> <PaginationContainer
numberOfPages={1}
isLoading={false}
activePage={1}
setPage={() => {}}
>
<Alert title="Подключенные устройства не найдены" color="blue"> <Alert title="Подключенные устройства не найдены" color="blue">
Для того, чтобы добавить устройство, простмотрите{" "} Для того, чтобы добавить устройство, простмотрите{" "}
<Link to={"documentaion"} className="invisible_link"> <Link to={"documentaion"} className="invisible_link">

View File

@@ -6,25 +6,17 @@ import { useState } from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { IconPlus, IconSettings } from "@tabler/icons-react"; import { IconPlus, IconSettings } from "@tabler/icons-react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import {
useExperimentStore,
useSelectedExperiment,
} from "Stores/ExperimentStore";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
import { useExperimentStore } from "Stores/ExperimentStore";
function ExperimentPage() { function ExperimentPage() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { experiment_id } = useParams(); const { experiment_id } = useParams();
const { selectedExperimentId, selectExperiment } = useExperimentStore(); const { experiments } = useExperimentStore();
if ( const experiment = experiments.find((exp) => {
!selectedExperimentId || return exp.id == Number(experiment_id);
(Number(experiment_id) != selectedExperimentId && experiment_id) });
) {
console.log(Number(experiment_id));
selectExperiment(Number(experiment_id));
}
const experiment = useSelectedExperiment();
return ( return (
<> <>
@@ -74,7 +66,12 @@ function ExperimentPage() {
</div> </div>
</div> </div>
{experiment.tasks_ids.length > 0 && ( {experiment.tasks_ids.length > 0 && (
<PaginationContainer numberOfPages={1}> <PaginationContainer
numberOfPages={1}
isLoading={false}
activePage={1}
setPage={() => {}}
>
{experiment.tasks_ids.map((task: number) => { {experiment.tasks_ids.map((task: number) => {
return <div>{task}</div>; return <div>{task}</div>;
})} })}

View File

@@ -1,7 +1,7 @@
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import "./ExperimentsPage.css"; import "./ExperimentsPage.css";
import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer"; import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer";
import { useState } from "react"; import { useEffect, useState } from "react";
import { IconMicroscope } from "@tabler/icons-react"; import { IconMicroscope } from "@tabler/icons-react";
import { NewExperimentModal } from "Modals/NewExperiment/NewExperiment"; import { NewExperimentModal } from "Modals/NewExperiment/NewExperiment";
import ExperimentsListCard from "Components/ListCard/ExperimentsListCard"; import ExperimentsListCard from "Components/ListCard/ExperimentsListCard";
@@ -9,10 +9,29 @@ import { useExperimentStore } from "Stores/ExperimentStore";
import type { Experiment } from "Types/Experiment/Experiment"; import type { Experiment } from "Types/Experiment/Experiment";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
import { Alert } from "@mantine/core"; import { Alert } from "@mantine/core";
import { getShortTeamsList } from "Api/QuantumBackend/TeamManagement";
import { useAuthenticationStore } from "Stores/AuthenticationStore";
function ExperimentsPage() { function ExperimentsPage() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { experiments, teams } = useExperimentStore(); const { experiments } = useExperimentStore();
const [teams, setTeams] =
useState<{ team_id: number; team_name: string }[]>();
const { profile, is_loading } = useAuthenticationStore();
useEffect(() => {
if (profile || !is_loading)
getShortTeamsList()
.then((teams) => {
if (teams) {
setTeams(teams);
}
})
.catch(() => {
setTeams([]);
});
}, [profile, is_loading]);
return ( return (
<> <>
@@ -23,7 +42,11 @@ function ExperimentsPage() {
content="See the list of all of users experiments" content="See the list of all of users experiments"
/> />
</Helmet> </Helmet>
<NewExperimentModal isOpened={isOpen} setIsOpened={setIsOpen} /> <NewExperimentModal
isOpened={isOpen}
setIsOpened={setIsOpen}
teams={teams}
/>
<div className="ExperimentsPage"> <div className="ExperimentsPage">
<div <div
style={{ style={{
@@ -33,7 +56,7 @@ function ExperimentsPage() {
marginBottom: "20px", marginBottom: "20px",
}} }}
> >
{teams.length > 0 && ( {teams && teams.length > 0 && (
<div className="experimentsButtons"> <div className="experimentsButtons">
<CustomButton <CustomButton
color="contrast" color="contrast"
@@ -46,27 +69,40 @@ function ExperimentsPage() {
</div> </div>
)} )}
</div> </div>
<PaginationContainer numberOfPages={1}> <PaginationContainer
numberOfPages={1}
isLoading={teams == undefined}
activePage={1}
setPage={() => {}}
>
{experiments.map((exp: Experiment) => { {experiments.map((exp: Experiment) => {
return ( return (
<ExperimentsListCard <ExperimentsListCard
id={exp.id} experiment={{
name={exp.name} id: exp.id,
description={exp.description} name: exp.name,
team_id={exp.team_id} description: exp.description,
tasks_ids={exp.tasks_ids} team_id: exp.team_id,
date_created={exp.date_created} tasks_ids: exp.tasks_ids,
experiment_status={exp.experiment_status} date_created: exp.date_created,
experiment_type="A" experiment_status: exp.experiment_status,
experiment_type: "A",
}}
team={teams?.find((team) => team.team_id == exp.team_id)}
/> />
); );
})} })}
{teams.length == 0 && ( {teams && teams.length == 0 && (
<Alert title="Команды не найдены" color="red"> <Alert title="Команды не найдены" color="red">
Создайте или войдите в команду чтобы начать работу с Создайте или войдите в команду чтобы начать работу с
экспериментами экспериментами
</Alert> </Alert>
)} )}
{teams && teams.length != 0 && experiments.length == 0 && (
<Alert title="Экспериментов нету" color="blue">
Вы еще не создали не один эксперимент
</Alert>
)}
</PaginationContainer> </PaginationContainer>
</div> </div>
</> </>

View File

@@ -0,0 +1,11 @@
.TeamPage {
flex-grow: 1;
display: flex;
flex-direction: column;
position: relative;
padding: 10px;
}
.TeamData {
margin-bottom: 15px;
}

View File

@@ -0,0 +1,228 @@
import {
Alert,
Avatar,
Card,
Center,
Grid,
LoadingOverlay,
Text,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import "./TeamPage.css";
import { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { IconCheck, IconPlus } from "@tabler/icons-react";
import { useParams } from "react-router";
import CustomButton from "Components/CustomButton/CustomButton";
import { useTeamStore } from "Stores/TeamsStore";
import { getTeam, updateTeamRequest } from "Api/QuantumBackend/TeamManagement";
import type { Team, TeamMember } from "Types/Team/Team";
import { useAuthenticationStore } from "Stores/AuthenticationStore";
import TeamMemberCard from "Components/ListCard/TeamListCard/TeamMemberCard/TeamMemberCard";
import { AddMember } from "Modals/AddMember/AddMember";
import { notifications } from "@mantine/notifications";
function TeamPage() {
const [isOpen, setIsOpen] = useState(false);
const { team_id } = useParams();
const { addTeam, teams, updateTeam } = useTeamStore();
const { profile } = useAuthenticationStore();
const [is_loading, set_is_loading] = useState(true);
const [cur_team, set_cur_team] = useState<Team>();
const [team_name, set_team_name] = useState(cur_team?.name);
const [team_descr, set_team_descr] = useState(cur_team?.description);
useEffect(() => {
if (!cur_team && profile) {
getTeam(Number(team_id))
.then((team) => {
if (team) {
addTeam(team);
set_cur_team(team);
set_team_name(team.name);
set_team_descr(team.description);
set_is_loading(false);
}
})
.catch(() => {
set_is_loading(false);
});
} else {
if (profile) {
set_is_loading(false);
}
}
if (teams.find((e) => e.id === Number(team_id)) != cur_team) {
set_cur_team(teams.find((e) => e.id === Number(team_id)));
}
}, [cur_team, profile, teams]);
return (
<>
<Helmet>
<title>
{cur_team ? "Team " + cur_team.id + " | QMolSim" : "Error | QmolSim"}
</title>
<meta
name="description"
content="See the information on the currently selected team"
/>
</Helmet>
<AddMember
isOpened={isOpen}
setIsOpened={setIsOpen}
permissions={cur_team?.creator.permissions}
team={cur_team}
/>
<div className="TeamPage">
<LoadingOverlay
visible={is_loading}
zIndex={1000}
overlayProps={{ radius: "sm", blur: 2 }}
loaderProps={{ size: 50, type: "dots" }}
/>
<Card className="TeamData">
<Grid>
<Grid.Col span={3}>
<Title size={"xl"}>Имя команды:</Title>
</Grid.Col>
<Grid.Col span={9}>
<TextInput
size="md"
value={team_name}
onChange={(val) => {
set_team_name(val.target.value);
}}
/>
</Grid.Col>
<Grid.Col span={3}>
<Title size={"xl"}>Описание:</Title>
</Grid.Col>
<Grid.Col span={9}>
<Textarea
size="md"
value={team_descr ? team_descr : ""}
onChange={(val) => {
set_team_descr(val.target.value);
}}
minRows={2}
maxRows={4}
/>
</Grid.Col>
<Grid.Col span={3}>
<Title size={"xl"}>Создатель:</Title>
</Grid.Col>
<Grid.Col
span={9}
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: "10px",
}}
>
<Avatar
radius="xl"
src={cur_team?.creator.user.profile_picture_path}
/>
<Text size="md" style={{ textDecoration: "underline" }}>
{cur_team?.creator.user.username} (
{cur_team?.creator.user.email})
</Text>
</Grid.Col>
<Grid.Col span={6}>
<CustomButton
color="accent"
text="Сохранить"
disabled={
cur_team?.name == team_name &&
cur_team?.description == team_descr
}
onClick={() => {
if (cur_team && team_name)
updateTeamRequest({
team_id: cur_team.id,
name: team_name,
description: team_descr,
}).then((team) => {
updateTeam(team.team_id, {
name: team.name,
description: team.description,
});
notifications.show({
radius: "md",
title: "Информация команды обновлена успешно",
message: "",
icon: <IconCheck />,
style: { paddingLeft: "5px" },
});
});
}}
/>
</Grid.Col>
<Grid.Col span={6}>
<CustomButton
color="error"
text="Отменить"
disabled={
cur_team?.name == team_name &&
cur_team?.description == team_descr
}
onClick={() => {
set_team_name(cur_team?.name);
set_team_descr(cur_team?.description);
}}
/>
</Grid.Col>
</Grid>
</Card>
<div
style={{
display: "flex",
flexWrap: "nowrap",
gap: "20px",
alignItems: "center",
}}
>
<Title order={2} style={{ textWrap: "nowrap" }}>
Члены команды:{" "}
</Title>
<div>
<CustomButton
color="contrast"
onClick={() => {
setIsOpen(true);
}}
icon={<IconPlus />}
text="Добавить члена"
/>
</div>
</div>
{cur_team && cur_team.members.length > 0 && (
<div className="TeamMembers">
{cur_team.members.map((member: TeamMember) => {
return <TeamMemberCard cur_team={cur_team} member={member} />;
})}
</div>
)}
{!cur_team && !is_loading && (
<Alert color="red">
<Center>
{" "}
<Text c="contrast" size={"xl"}>
Ошибка. Команда не найдена
</Text>{" "}
</Center>
</Alert>
)}
</div>
</>
);
}
export default TeamPage;

View File

@@ -3,14 +3,79 @@ import "./TeamsPage.css";
import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer"; import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
import { IconUsersGroup } from "@tabler/icons-react"; import { IconUsersGroup } from "@tabler/icons-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Alert } from "@mantine/core"; import { Alert } from "@mantine/core";
import { NewTeamModal } from "Modals/NewTeam/NewTeam";
import { useTeamStore } from "Stores/TeamsStore";
import { getTeams } from "Api/QuantumBackend/TeamManagement";
import { useAuthenticationStore as useAuthenticationStore } from "Stores/AuthenticationStore";
import TeamsListCard from "Components/ListCard/TeamListCard/TeamsListCard";
function TeamsPage() { function TeamsPage() {
const [, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { teams, setTeams, setTotalTeams, totalTeams, removeTeam } =
useTeamStore();
const [isLoading, setIsLoading] = useState(true);
const { profile } = useAuthenticationStore();
const [cur_page, set_cur_page] = useState<number>(1);
const [page_size, set_page_size] = useState<number>(256);
useEffect(() => {
setIsLoading(true);
if (profile) {
getTeams({ page_num: cur_page })
.then((teams) => {
setTeams(teams.teams);
set_cur_page(teams.cur_page);
setTotalTeams(teams.total_teams);
set_page_size(teams.page_size);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}
}, [profile]);
useEffect(() => {
if (profile) {
if (teams && teams.length > page_size) {
removeTeam(teams[teams.length - 1].id);
}
if (teams && cur_page > Math.ceil(totalTeams / page_size)) {
setIsLoading(true);
getTeams({ page_num: Math.max(1, cur_page - 1) })
.then((teams) => {
setTeams(teams.teams);
set_cur_page(teams.cur_page);
setTotalTeams(teams.total_teams);
set_page_size(teams.page_size);
setIsLoading(false);
scroll({ top: 0 });
})
.catch(() => setIsLoading(false));
set_cur_page(Math.max(1, cur_page - 1));
}
if (
teams &&
teams.length < Math.min(page_size, totalTeams) &&
cur_page != Math.ceil(totalTeams / page_size)
) {
getTeams({ page_num: cur_page })
.then((teams) => {
setTeams(teams.teams);
set_cur_page(teams.cur_page);
setTotalTeams(teams.total_teams);
set_page_size(teams.page_size);
setIsLoading(false);
scroll({ top: 0 });
})
.catch(() => setIsLoading(false));
}
}
}, [profile, teams]);
return ( return (
<> <>
<NewTeamModal isOpened={isOpen} setIsOpened={setIsOpen} />
<Helmet> <Helmet>
<title>Teams Page | QMolSim</title> <title>Teams Page | QMolSim</title>
<meta <meta
@@ -26,13 +91,35 @@ function TeamsPage() {
setIsOpen(true); setIsOpen(true);
}} }}
icon={<IconUsersGroup />} icon={<IconUsersGroup />}
text="Создать эксперимент" text="Создать Команду"
/> />
</div> </div>
<PaginationContainer numberOfPages={1}> <PaginationContainer
numberOfPages={Math.ceil(totalTeams / page_size)}
isLoading={isLoading}
activePage={cur_page}
setPage={(page) => {
getTeams({ page_num: page })
.then((teams) => {
setTeams(teams.teams);
set_cur_page(teams.cur_page);
setTotalTeams(teams.total_teams);
set_page_size(teams.page_size);
setIsLoading(false);
scroll({ top: 0 });
})
.catch(() => setIsLoading(false));
}}
>
{teams.length == 0 && !isLoading && (
<Alert title="Команды не найдены" color="blue"> <Alert title="Команды не найдены" color="blue">
Создайте или войдите в команду чтобы начать работу с экспериментами Создайте или войдите в команду чтобы начать работу с
экспериментами
</Alert> </Alert>
)}
{teams.map((team) => {
return <TeamsListCard {...team} />;
})}
</PaginationContainer> </PaginationContainer>
</div> </div>
</> </>

View File

@@ -23,7 +23,7 @@ import keycloak, {
} from "Api/Keycloak/Keycloak"; } from "Api/Keycloak/Keycloak";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { AuthenticationStore } from "Stores/AuthenticationStore"; import { useAuthenticationStore } from "Stores/AuthenticationStore";
import "./UserPage.css"; import "./UserPage.css";
import { useUserPreferencesStore } from "Stores/PreferencesStore"; import { useUserPreferencesStore } from "Stores/PreferencesStore";
import CustomButton from "Components/CustomButton/CustomButton"; import CustomButton from "Components/CustomButton/CustomButton";
@@ -35,7 +35,8 @@ import {
} from "Api/QuantumBackend/UserManagement"; } from "Api/QuantumBackend/UserManagement";
import type { UserData } from "Types/User/User"; import type { UserData } from "Types/User/User";
function UserPage() { function UserPage() {
const { profile, is_loading, profile_picture_path } = AuthenticationStore(); const { profile, is_loading, profile_picture_path } =
useAuthenticationStore();
const { theme, set_theme } = useUserPreferencesStore(); const { theme, set_theme } = useUserPreferencesStore();
const [username, set_username] = useState<string>(""); const [username, set_username] = useState<string>("");
const [email, set_email] = useState<string>(""); const [email, set_email] = useState<string>("");
@@ -49,15 +50,13 @@ function UserPage() {
((profile.username && profile.username != username) || ((profile.username && profile.username != username) ||
(profile.email && profile.email != email)) (profile.email && profile.email != email))
) { ) {
console.log("A");
const prof_1 = profile; const prof_1 = profile;
prof_1.email = email; prof_1.email = email;
prof_1.username = username; prof_1.username = username;
AuthenticationStore.setState({ useAuthenticationStore.setState({
is_loading: true, is_loading: true,
}); });
updateUserData(prof_1).then((data) => { updateUserData(prof_1).then((data) => {
console.log(data);
if (data) { if (data) {
notifications.show({ notifications.show({
radius: "md", radius: "md",
@@ -67,7 +66,7 @@ function UserPage() {
style: { paddingLeft: "5px" }, style: { paddingLeft: "5px" },
}); });
keycloak.loadUserProfile().then((profile) => { keycloak.loadUserProfile().then((profile) => {
AuthenticationStore.setState({ useAuthenticationStore.setState({
is_loading: false, is_loading: false,
profile: profile, profile: profile,
}); });
@@ -81,7 +80,7 @@ function UserPage() {
UpdateCurrentUserInfo(pfp_path).then(() => { UpdateCurrentUserInfo(pfp_path).then(() => {
GetCurrentUserInfo().then((info: UserData | undefined) => { GetCurrentUserInfo().then((info: UserData | undefined) => {
if (info && info.profile_picture_path) if (info && info.profile_picture_path)
AuthenticationStore.setState({ useAuthenticationStore.setState({
profile_picture_path: info.profile_picture_path, profile_picture_path: info.profile_picture_path,
}); });
}); });
@@ -184,6 +183,12 @@ function UserPage() {
<CustomButton <CustomButton
color="error" color="error"
text="Отменить" text="Отменить"
onClick={() => {
if (profile && profile.email && profile.username) {
set_username(profile.username);
set_email(profile.email);
}
}}
disabled={ disabled={
!( !(
profile != null && profile != null &&

View File

@@ -48,7 +48,6 @@ function testEqual(path: string, pattern: string) {
function BreadCrumbs() { function BreadCrumbs() {
const unique_matches: string[] = getSubPaths(useLocation().pathname); const unique_matches: string[] = getSubPaths(useLocation().pathname);
console.log(unique_matches);
//find the breadcrumbs for the matched pathes //find the breadcrumbs for the matched pathes
const elements: ReactElement[] = []; const elements: ReactElement[] = [];
for (const prop in routes) { for (const prop in routes) {

View File

@@ -1,21 +1,33 @@
import keycloak from "Api/Keycloak/Keycloak"; import keycloak from "Api/Keycloak/Keycloak";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { useAuthenticationStore } from "Stores/AuthenticationStore";
export default function AuthGuard() { export default function AuthGuard() {
const loginStarted = useRef(false); const loginStarted = useRef(false);
const { is_loading } = useAuthenticationStore();
useEffect(() => { useEffect(() => {
if (!keycloak.authenticated && !loginStarted.current) { if (
keycloak &&
!keycloak.authenticated &&
!loginStarted.current &&
!is_loading
) {
loginStarted.current = true; loginStarted.current = true;
keycloak.login({ keycloak.login({
redirectUri: window.location.origin, redirectUri: window.location.origin,
}); });
} }
}, []); }, [is_loading, loginStarted]);
if (!keycloak.authenticated) { if (
keycloak &&
!keycloak.authenticated &&
!loginStarted.current &&
!is_loading
) {
return <div>Redirecting to login...</div>; return <div>Redirecting to login...</div>;
} }

View File

@@ -12,6 +12,7 @@ import ExperimentPage from "Pages/ExperimentsPage/ExperimentPage";
import ExperimentsPage from "Pages/ExperimentsPage/ExperimentsPage"; import ExperimentsPage from "Pages/ExperimentsPage/ExperimentsPage";
import AuthGuard from "./RouterAuhGuard"; import AuthGuard from "./RouterAuhGuard";
import DevicesPage from "Pages/DevicesPage/DevicesPage"; import DevicesPage from "Pages/DevicesPage/DevicesPage";
import TeamPage from "Pages/TeamsPage/TeamPage/TeamPage";
export const routes: { export const routes: {
[id: string]: { path: string; breadcrumbs: (path: string) => ReactElement[] }; [id: string]: { path: string; breadcrumbs: (path: string) => ReactElement[] };
@@ -42,6 +43,12 @@ export const routes: {
breadcrumbs: () => [<>Вычислительные системы</>], breadcrumbs: () => [<>Вычислительные системы</>],
}, },
TeamsPage: { path: "/teams", breadcrumbs: () => [<>Команды</>] }, TeamsPage: { path: "/teams", breadcrumbs: () => [<>Команды</>] },
TeamPage: {
path: "/teams/:team_id",
breadcrumbs: (path: string) => [
<>Команда #{path.split("/")[path.split("/").length - 1]}</>,
],
},
ErrorPage: { path: "/*", breadcrumbs: () => [<>Ошибка</>] }, ErrorPage: { path: "/*", breadcrumbs: () => [<>Ошибка</>] },
SettingsPage: { path: "/settings", breadcrumbs: () => [<>Настройки</>] }, SettingsPage: { path: "/settings", breadcrumbs: () => [<>Настройки</>] },
}; };
@@ -84,6 +91,10 @@ const router = createBrowserRouter(
path: routes.TeamsPage.path, path: routes.TeamsPage.path,
Component: TeamsPage, Component: TeamsPage,
}, },
{
path: routes.TeamPage.path,
Component: TeamPage,
},
{ {
path: routes.MachinesPage.path, path: routes.MachinesPage.path,
Component: DevicesPage, Component: DevicesPage,

View File

@@ -10,7 +10,7 @@ interface AuthernticationStoreState {
set_is_loading: (is_loading: boolean) => void; set_is_loading: (is_loading: boolean) => void;
} }
export const AuthenticationStore = create<AuthernticationStoreState>()( export const useAuthenticationStore = create<AuthernticationStoreState>()(
(set) => ({ (set) => ({
is_loading: true, is_loading: true,
profile: null, profile: null,

View File

@@ -2,19 +2,10 @@ import { create } from "zustand";
import { immer } from "zustand/middleware/immer"; import { immer } from "zustand/middleware/immer";
import type { Experiment, TaskData } from "Types/Experiment/Experiment"; import type { Experiment, TaskData } from "Types/Experiment/Experiment";
import type { Team } from "Types/User/User";
interface ExperimentStoreState { interface ExperimentStoreState {
experiments: Experiment[]; experiments: Experiment[];
tasks: TaskData[]; tasks: TaskData[];
teams: Team[];
selectedExperimentId?: number;
selectedTaskId?: number;
selectedTeamId?: number;
selectExperiment: (id: number | undefined) => void;
selectTask: (id: number | undefined) => void;
addExperiment: (experiment: Experiment) => void; addExperiment: (experiment: Experiment) => void;
updateExperiment: (id: number, data: Partial<Experiment>) => void; updateExperiment: (id: number, data: Partial<Experiment>) => void;
@@ -23,10 +14,6 @@ interface ExperimentStoreState {
addTask: (experimentId: number, task: TaskData) => void; addTask: (experimentId: number, task: TaskData) => void;
updateTask: (taskId: number, data: Partial<TaskData>) => void; updateTask: (taskId: number, data: Partial<TaskData>) => void;
removeTask: (experimentId: number, taskId: number) => void; removeTask: (experimentId: number, taskId: number) => void;
addTeam: (team: Team) => void;
updateTeam: (teamId: number, data: Partial<TaskData>) => void;
removeTeam: (teamId: number) => void;
} }
export const useExperimentStore = create<ExperimentStoreState>()( export const useExperimentStore = create<ExperimentStoreState>()(
@@ -35,20 +22,6 @@ export const useExperimentStore = create<ExperimentStoreState>()(
tasks: [], tasks: [],
teams: [], teams: [],
selectedExperimentId: undefined,
selectedTaskId: undefined,
selectedTeamId: undefined,
selectExperiment: (id) =>
set((state) => {
state.selectedExperimentId = id;
}),
selectTask: (id) =>
set((state) => {
state.selectedTaskId = id;
}),
addExperiment: (experiment) => addExperiment: (experiment) =>
set((state) => { set((state) => {
state.experiments.push(experiment); state.experiments.push(experiment);
@@ -65,11 +38,6 @@ export const useExperimentStore = create<ExperimentStoreState>()(
removeExperiment: (id) => removeExperiment: (id) =>
set((state) => { set((state) => {
state.experiments = state.experiments.filter((e) => e.id !== id); state.experiments = state.experiments.filter((e) => e.id !== id);
if (state.selectedExperimentId === id) {
state.selectedExperimentId = undefined;
state.selectedTaskId = undefined;
}
}), }),
addTask: (experimentId, task) => addTask: (experimentId, task) =>
@@ -96,43 +64,6 @@ export const useExperimentStore = create<ExperimentStoreState>()(
state.tasks = state.tasks.filter((t) => t.data.id !== taskId); state.tasks = state.tasks.filter((t) => t.data.id !== taskId);
exp.tasks_ids = exp.tasks_ids.filter((t) => t !== taskId); exp.tasks_ids = exp.tasks_ids.filter((t) => t !== taskId);
if (state.selectedTaskId === taskId) {
state.selectedTaskId = undefined;
}
}),
addTeam: (team) =>
set((state) => {
state.teams.push(team);
}),
updateTeam: (teamID, data) =>
set((state) => {
const team = state.teams.find((t) => t.id === teamID);
if (!team) return;
Object.assign(team, data);
}),
removeTeam: (teamID) =>
set((state) => {
state.teams = state.teams.filter((t) => t.id !== teamID);
state.experiments = state.experiments.filter(
(t) => t.team_id !== teamID,
);
if (state.selectedTeamId === teamID) {
state.selectedTeamId = undefined;
}
}), }),
})), })),
); );
export const useSelectedExperiment = () =>
useExperimentStore((s) =>
s.experiments.find((e) => e.id === s.selectedExperimentId),
);
export const useSelectedTask = () =>
useExperimentStore((s) => {
return s.tasks.find((t) => t.id === s.selectedTaskId);
});

46
src/Stores/TeamsStore.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import type { Team } from "Types/Team/Team";
interface TeamStoreState {
teams: Team[];
setTeams: (teams: Team[]) => void;
totalTeams: number;
setTotalTeams: (num: number) => void;
addTeam: (team: Team) => void;
updateTeam: (teamId: number, data: Partial<Team>) => void;
removeTeam: (teamId: number) => void;
}
export const useTeamStore = create<TeamStoreState>()(
immer((set) => ({
teams: [],
totalTeams: 0,
setTotalTeams: (num) =>
set((state) => {
state.totalTeams = num;
}),
addTeam: (team) =>
set((state) => {
state.teams = [team, ...state.teams];
}),
updateTeam: (teamID, data) =>
set((state) => {
const team = state.teams.find((t) => t.id === teamID);
if (!team) return;
Object.assign(team, data);
}),
setTeams: (teams) =>
set((state) => {
state.teams = teams;
}),
removeTeam: (teamID) =>
set((state) => {
state.teams = state.teams.filter((t) => t.id !== teamID);
}),
})),
);

16
src/Types/Team/Team.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { UserData } from "Types/User/User";
export interface TeamMember {
user: UserData;
joined_at: string; // ISO datetime string
permissions: string[];
}
export interface Team {
id: number;
name: string;
description: string | null;
created_at: Date; // ISO datetime string
creator: TeamMember;
members: TeamMember[];
}

View File

@@ -1,24 +1,3 @@
export interface Team {
id: number;
name: string;
description: string;
creation_date: Date;
creator: TeamMember;
team_members: TeamMember[];
}
export interface TeamMember {
username: string;
email: string;
permissions: Permission[];
date_accepted: Date;
}
export interface Permission {
id: number;
name: string;
}
export interface UserData { export interface UserData {
keycloak_id: string; keycloak_id: string;
email: string; email: string;

View File

@@ -11,11 +11,13 @@ import {
virtualColor, virtualColor,
} from "@mantine/core"; } from "@mantine/core";
import keycloak from "Api/Keycloak/Keycloak"; import keycloak from "Api/Keycloak/Keycloak";
import { AuthenticationStore } from "Stores/AuthenticationStore"; import { useAuthenticationStore } from "Stores/AuthenticationStore";
import { useUserPreferencesStore } from "Stores/PreferencesStore"; import { useUserPreferencesStore } from "Stores/PreferencesStore";
import type { KeycloakProfile } from "keycloak-js"; import type { KeycloakProfile } from "keycloak-js";
import { GetCurrentUserInfo } from "Api/QuantumBackend/UserManagement"; import { GetCurrentUserInfo } from "Api/QuantumBackend/UserManagement";
import type { UserData } from "Types/User/User"; import type { UserData } from "Types/User/User";
import { notifications } from "@mantine/notifications";
import { IconForbid } from "@tabler/icons-react";
const colorSchemeManager = localStorageColorSchemeManager({ const colorSchemeManager = localStorageColorSchemeManager({
key: "my-app-color-scheme", key: "my-app-color-scheme",
@@ -63,7 +65,7 @@ const theme = createTheme({
}); });
async function bootstrap() { async function bootstrap() {
AuthenticationStore.setState({ is_loading: true }); useAuthenticationStore.setState({ is_loading: true });
keycloak keycloak
.init({ .init({
onLoad: "check-sso", onLoad: "check-sso",
@@ -78,20 +80,31 @@ async function bootstrap() {
.then((authenticated: boolean) => { .then((authenticated: boolean) => {
if (authenticated) { if (authenticated) {
keycloak.loadUserProfile().then((profile: KeycloakProfile) => { keycloak.loadUserProfile().then((profile: KeycloakProfile) => {
AuthenticationStore.setState({ useAuthenticationStore.setState({
is_loading: false, is_loading: false,
profile: profile, profile: profile,
}); });
GetCurrentUserInfo().then((info: UserData | undefined) => { GetCurrentUserInfo()
.then((info: UserData | undefined) => {
if (info && info.profile_picture_path) if (info && info.profile_picture_path)
AuthenticationStore.setState({ useAuthenticationStore.setState({
profile_picture_path: info.profile_picture_path, profile_picture_path: info.profile_picture_path,
}); });
})
.catch(() => {
notifications.show({
radius: "md",
title: "Ошибка получения данных пользователя",
message: "",
color: "red",
icon: <IconForbid />,
style: { paddingLeft: "5px" },
});
}); });
}); });
} else { } else {
console.log("Keycloak authentication failed."); console.log("Keycloak authentication failed.");
AuthenticationStore.setState({ useAuthenticationStore.setState({
is_loading: false, is_loading: false,
}); });
} }