diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f0fbefb..99d7b21 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -11,6 +11,7 @@ import Articles from "../views/home/articles/Articles"; import Groups from "../views/home/groups/Groups"; import Contests from "../views/home/contests/Contests"; import { PrimaryButton } from "../components/button/PrimaryButton"; +import Group from "../views/home/groups/Group"; const Home = () => { const name = useAppSelector((state) => state.auth.username); @@ -34,6 +35,7 @@ const Home = () => { } /> } /> } /> + } /> } /> } /> diff --git a/src/redux/slices/groups.ts b/src/redux/slices/groups.ts new file mode 100644 index 0000000..8af10d2 --- /dev/null +++ b/src/redux/slices/groups.ts @@ -0,0 +1,258 @@ +import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import axios from "../../axios"; + +// ─── Типы ──────────────────────────────────────────── + +export interface GroupMember { + userId: number; + username: string; + role: string; +} + +export interface Group { + id: number; + name: string; + description: string; + members: GroupMember[]; + contests: any[]; +} + +interface GroupsState { + groups: Group[]; + currentGroup: Group | null; + status: "idle" | "loading" | "successful" | "failed"; + error: string | null; +} + +const initialState: GroupsState = { + groups: [], + currentGroup: null, + status: "idle", + error: null, +}; + +// ─── Async Thunks ───────────────────────────────────── + +// POST /groups +export const createGroup = createAsyncThunk( + "groups/createGroup", + async ( + { name, description }: { name: string; description: string }, + { rejectWithValue } + ) => { + try { + const response = await axios.post("/groups", { name, description }); + return response.data as Group; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Ошибка при создании группы"); + } + } +); + +// PUT /groups/{groupId} +export const updateGroup = createAsyncThunk( + "groups/updateGroup", + async ( + { groupId, name, description }: { groupId: number; name: string; description: string }, + { rejectWithValue } + ) => { + try { + const response = await axios.put(`/groups/${groupId}`, { name, description }); + return response.data as Group; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Ошибка при обновлении группы"); + } + } +); + +// DELETE /groups/{groupId} +export const deleteGroup = createAsyncThunk( + "groups/deleteGroup", + async (groupId: number, { rejectWithValue }) => { + try { + await axios.delete(`/groups/${groupId}`); + return groupId; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Ошибка при удалении группы"); + } + } +); + +// GET /groups/my +export const fetchMyGroups = createAsyncThunk( + "groups/fetchMyGroups", + async (_, { rejectWithValue }) => { + try { + const response = await axios.get("/groups/my"); + return response.data.groups as Group[]; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Ошибка при получении групп"); + } + } +); + +// GET /groups/{groupId} +export const fetchGroupById = createAsyncThunk( + "groups/fetchGroupById", + async (groupId: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/groups/${groupId}`); + return response.data as Group; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Ошибка при получении группы"); + } + } +); + +// POST /groups/members +export const addGroupMember = createAsyncThunk( + "groups/addGroupMember", + async ({ userId, role }: { userId: number; role: string }, { rejectWithValue }) => { + try { + await axios.post("/groups/members", { userId, role }); + return { userId, role }; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Ошибка при добавлении участника"); + } + } +); + +// DELETE /groups/{groupId}/members/{memberId} +export const removeGroupMember = createAsyncThunk( + "groups/removeGroupMember", + async ( + { groupId, memberId }: { groupId: number; memberId: number }, + { rejectWithValue } + ) => { + try { + await axios.delete(`/groups/${groupId}/members/${memberId}`); + return { groupId, memberId }; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Ошибка при удалении участника"); + } + } +); + +// ─── Slice ──────────────────────────────────────────── + +const groupsSlice = createSlice({ + name: "groups", + initialState, + reducers: { + clearCurrentGroup: (state) => { + state.currentGroup = null; + }, + }, + extraReducers: (builder) => { + // ─── CREATE GROUP ─── + builder.addCase(createGroup.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(createGroup.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.groups.push(action.payload); + }); + builder.addCase(createGroup.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // ─── UPDATE GROUP ─── + builder.addCase(updateGroup.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(updateGroup.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + const index = state.groups.findIndex((g) => g.id === action.payload.id); + if (index !== -1) state.groups[index] = action.payload; + if (state.currentGroup?.id === action.payload.id) + state.currentGroup = action.payload; + }); + builder.addCase(updateGroup.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // ─── DELETE GROUP ─── + builder.addCase(deleteGroup.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(deleteGroup.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.groups = state.groups.filter((g) => g.id !== action.payload); + if (state.currentGroup?.id === action.payload) state.currentGroup = null; + }); + builder.addCase(deleteGroup.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // ─── FETCH MY GROUPS ─── + builder.addCase(fetchMyGroups.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(fetchMyGroups.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.groups = action.payload; + }); + builder.addCase(fetchMyGroups.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // ─── FETCH GROUP BY ID ─── + builder.addCase(fetchGroupById.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(fetchGroupById.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.currentGroup = action.payload; + }); + builder.addCase(fetchGroupById.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // ─── ADD MEMBER ─── + builder.addCase(addGroupMember.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(addGroupMember.fulfilled, (state) => { + state.status = "successful"; + }); + builder.addCase(addGroupMember.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // ─── REMOVE MEMBER ─── + builder.addCase(removeGroupMember.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase( + removeGroupMember.fulfilled, + (state, action: PayloadAction<{ groupId: number; memberId: number }>) => { + state.status = "successful"; + if (state.currentGroup && state.currentGroup.id === action.payload.groupId) { + state.currentGroup.members = state.currentGroup.members.filter( + (m) => m.userId !== action.payload.memberId + ); + } + } + ); + builder.addCase(removeGroupMember.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + }, +}); + +export const { clearCurrentGroup } = groupsSlice.actions; +export const groupsReducer = groupsSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 872a139..3c609e8 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -4,6 +4,7 @@ import { storeReducer } from "./slices/store"; import { missionsReducer } from "./slices/missions"; import { submitReducer } from "./slices/submit"; import { contestsReducer } from "./slices/contests"; +import { groupsReducer } from "./slices/groups"; // использование @@ -23,6 +24,7 @@ export const store = configureStore({ missions: missionsReducer, submin: submitReducer, contests: contestsReducer, + groups: groupsReducer, }, }); diff --git a/src/views/home/groups/Group.tsx b/src/views/home/groups/Group.tsx new file mode 100644 index 0000000..a567e3b --- /dev/null +++ b/src/views/home/groups/Group.tsx @@ -0,0 +1,26 @@ +import { FC } from "react"; +import { cn } from "../../../lib/cn"; +import { useParams, Navigate } from "react-router-dom"; + +interface GroupsBlockProps {} + +const Group: FC = () => { + const { groupId } = useParams<{ groupId: string }>(); + const groupIdNumber = Number(groupId); + + if (!groupId || isNaN(groupIdNumber) || !groupIdNumber) { + return ; + } + + return ( +
+ {groupIdNumber} +
+ ); +}; + +export default Group; diff --git a/src/views/home/groups/GroupItem.tsx b/src/views/home/groups/GroupItem.tsx index d4821d4..1f12097 100644 --- a/src/views/home/groups/GroupItem.tsx +++ b/src/views/home/groups/GroupItem.tsx @@ -1,5 +1,6 @@ import { cn } from "../../../lib/cn"; import { Book, UserAdd, Edit, EyeClosed, EyeOpen } from "../../../assets/icons/groups"; +import { useNavigate } from "react-router-dom"; export interface GroupItemProps { id: number; @@ -26,9 +27,13 @@ const IconComponent: React.FC = ({ const GroupItem: React.FC = ({ id, name, visible, role }) => { + const navigate = useNavigate(); + return ( -
+
navigate(`/group/${id}`)} + >
diff --git a/src/views/home/groups/Groups.tsx b/src/views/home/groups/Groups.tsx index 34559f6..7b7c52e 100644 --- a/src/views/home/groups/Groups.tsx +++ b/src/views/home/groups/Groups.tsx @@ -1,68 +1,84 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { cn } from "../../../lib/cn"; -import { useAppDispatch } from "../../../redux/hooks"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import GroupsBlock from "./GroupsBlock"; import { setMenuActivePage } from "../../../redux/slices/store"; - - -export interface Group { - id: number; - role: "menager" | "member" | "owner" | "viewer"; - visible: boolean; - name: string; -} - +import { fetchMyGroups } from "../../../redux/slices/groups"; const Groups = () => { - const dispatch = useAppDispatch(); - const groups: Group[] = [ - { id: 1, role: "owner", name: "Main Administration", visible: true }, - { id: 2, role: "menager", name: "Project Managers", visible: true }, - { id: 3, role: "member", name: "Developers", visible: true }, - { id: 4, role: "viewer", name: "QA Viewers", visible: true }, - { id: 5, role: "member", name: "Design Team", visible: true }, - { id: 6, role: "owner", name: "Executive Board", visible: true }, - { id: 7, role: "menager", name: "HR Managers", visible: true }, - { id: 8, role: "viewer", name: "Marketing Reviewers", visible: false }, - { id: 9, role: "member", name: "Content Creators", visible: false }, - { id: 10, role: "menager", name: "Support Managers", visible: true }, - { id: 11, role: "viewer", name: "External Auditors", visible: false }, - { id: 12, role: "member", name: "Frontend Developers", visible: true }, - { id: 13, role: "member", name: "Backend Developers", visible: true }, - { id: 14, role: "viewer", name: "Guest Access", visible: false }, - { id: 15, role: "menager", name: "Operations", visible: true }, - ]; + // Берём группы из стора + const groups = useAppSelector((store) => store.groups.groups); + + // Берём текущего пользователя + const currentUserName = useAppSelector((store) => store.auth.username); useEffect(() => { - dispatch(setMenuActivePage("groups")) - }, []); + dispatch(setMenuActivePage("groups")); + dispatch(fetchMyGroups()) + }, [dispatch]); + + // Разделяем группы + const { managedGroups, currentGroups, hiddenGroups } = useMemo(() => { + if (!groups || !currentUserName) { + return { managedGroups: [], currentGroups: [], hiddenGroups: [] }; + } + + const managed: typeof groups = []; + const current: typeof groups = []; + const hidden: typeof groups = []; // пока пустые, без логики + + groups.forEach((group) => { + const me = group.members.find((m) => m.username === currentUserName); + if (!me) return; + + if (me.role === "Administrator") { + managed.push(group); + } else { + current.push(group); + } + }); + + return { managedGroups: managed, currentGroups: current, hiddenGroups: hidden }; + }, [groups, currentUserName]); return ( -
+
-
-
+
Группы
{ }} + onClick={() => {}} text="Создать группу" className="absolute right-0" />
-
+
-
- - - v.visible && (v.role == "owner" || v.role == "menager"))} /> - v.visible && (v.role == "member" || v.role == "viewer"))} /> - v.visible == false)} /> + + +
); diff --git a/src/views/home/groups/GroupsBlock.tsx b/src/views/home/groups/GroupsBlock.tsx index 05e7e76..cb8df35 100644 --- a/src/views/home/groups/GroupsBlock.tsx +++ b/src/views/home/groups/GroupsBlock.tsx @@ -2,14 +2,7 @@ import { useState, FC } from "react"; import GroupItem from "./GroupItem"; import { cn } from "../../../lib/cn"; import { ChevroneDown } from "../../../assets/icons/groups"; - - -export interface Group { - id: number; - role: "menager" | "member" | "owner" | "viewer"; - visible: boolean; - name: string; -} +import { Group } from "../../../redux/slices/groups"; interface GroupsBlockProps { groups: Group[]; @@ -47,7 +40,7 @@ const GroupsBlock: FC = ({ groups, title, className }) => {
{ - groups.map((v, i) => ) + groups.map((v, i) => ) }