From 9121f49a9b328cc5f538eff69cce48c28f4f7603 Mon Sep 17 00:00:00 2001 From: David Katrinka Date: Sun, 28 Dec 2025 19:48:20 +0100 Subject: [PATCH] user crud --- src/api/usersApi.ts | 53 +++++++ src/components/AdminSidebar/AdminSidebar.tsx | 88 ++++++++++++ src/components/Header/Header.tsx | 12 +- src/hooks/users/useCreateUser.ts | 17 +++ src/hooks/users/useDeleteUser.ts | 17 +++ src/hooks/users/useUpdateUser.ts | 18 +++ src/hooks/users/useUserById.ts | 10 ++ src/hooks/users/useUsers.ts | 10 ++ src/layouts/AdminLayout/AdminLayout.tsx | 18 +++ src/pages/UserForm/UserForm.tsx | 115 +++++++++++++++ src/pages/UsersPage/UsersPage.tsx | 141 +++++++++++++++++++ src/router/router.tsx | 34 ++++- 12 files changed, 524 insertions(+), 9 deletions(-) create mode 100644 src/api/usersApi.ts create mode 100644 src/components/AdminSidebar/AdminSidebar.tsx create mode 100644 src/hooks/users/useCreateUser.ts create mode 100644 src/hooks/users/useDeleteUser.ts create mode 100644 src/hooks/users/useUpdateUser.ts create mode 100644 src/hooks/users/useUserById.ts create mode 100644 src/hooks/users/useUsers.ts create mode 100644 src/layouts/AdminLayout/AdminLayout.tsx create mode 100644 src/pages/UserForm/UserForm.tsx create mode 100644 src/pages/UsersPage/UsersPage.tsx diff --git a/src/api/usersApi.ts b/src/api/usersApi.ts new file mode 100644 index 0000000..ffe4208 --- /dev/null +++ b/src/api/usersApi.ts @@ -0,0 +1,53 @@ +import type { User } from "../components/shared/types/AuthTypes"; +import axiosInstance from "./axiosInstance"; + +export type UsersResponse = { + data: User[]; + meta: { + current_page: number; + from: number; + last_page: number; + per_page: number; + to: number; + total: number; + }; +}; + +export interface UserPayload { + username: string; + email: string; + password?: string; + type: string; +} + +export const getUsers = async (page = 1) => { + const res = await axiosInstance.get("/api/users", { + params: { page }, + }); + return res.data; +}; + +export const getUserById = async (id: number) => { + const res = await axiosInstance.get(`/api/users/${id}`); + return res.data.data; +}; + +export const deleteUser = async (id: number) => { + const res = await axiosInstance.delete(`/api/users/${id}`); + return res.data; +}; + +export const createUser = async (payload: UserPayload) => { + const res = await axiosInstance.post("/api/users", payload); + return res.data; +}; + +export const updateUser = async (id: number, payload: UserPayload) => { + const res = await axiosInstance.put(`/api/users/${id}`, { + username: payload.username, + email: payload.email, + password: payload.password, + type: payload.type, + }); + return res.data; +}; diff --git a/src/components/AdminSidebar/AdminSidebar.tsx b/src/components/AdminSidebar/AdminSidebar.tsx new file mode 100644 index 0000000..6893cc1 --- /dev/null +++ b/src/components/AdminSidebar/AdminSidebar.tsx @@ -0,0 +1,88 @@ +import { + Drawer, + List, + ListItemButton, + ListItemIcon, + ListItemText, +} from "@mui/material"; +import { NavLink } from "react-router-dom"; + +import PeopleIcon from "@mui/icons-material/People"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; +import QuizIcon from "@mui/icons-material/Quiz"; +import CategoryIcon from "@mui/icons-material/Category"; +import ArticleIcon from "@mui/icons-material/Article"; +import AssignmentIndIcon from '@mui/icons-material/AssignmentInd'; + +const drawerWidth = 240; + +const menuItems = [ + { + label: "Users", + icon: , + to: "/dashboard/users", + }, + { + label: "Questions", + icon: , + to: "/dashboard/questions", + }, + { + label: "Tests", + icon: , + to: "/dashboard/tests", + }, + { + label: "User Tests", + icon: , + to: "/dashboard/userTests", + }, + { + label: "Categories", + icon: , + to: "/dashboard/categories", + }, + { + label: "Logs", + icon: , + to: "/dashboard/logs", + }, +]; + +export const AdminSidebar = () => { + return ( + + + {menuItems.map((item) => ( + + {item.icon} + + + ))} + + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 2267d2c..e428b95 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -29,7 +29,7 @@ function Header() { setAnchorElNav(null); }; - const goToAdmin = () => navigate("/admin"); + const goToDashboard = () => navigate("/dashboard"); return ( @@ -99,11 +99,11 @@ function Header() { { handleCloseNavMenu(); - goToAdmin(); + goToDashboard(); }} > - Admin Dashboard + Dashboard )} @@ -161,10 +161,10 @@ function Header() { {user.type === "admin" && ( )} @@ -176,7 +176,7 @@ function Header() { navigate("/tests"); }} > - Tests + Tests diff --git a/src/hooks/users/useCreateUser.ts b/src/hooks/users/useCreateUser.ts new file mode 100644 index 0000000..4245ba7 --- /dev/null +++ b/src/hooks/users/useCreateUser.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createUser, type UserPayload } from "../../api/usersApi"; +import { toast } from "react-toastify"; + +export const useCreateUser = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: UserPayload) => createUser(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + toast.success("User Created"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/users/useDeleteUser.ts b/src/hooks/users/useDeleteUser.ts new file mode 100644 index 0000000..8228a20 --- /dev/null +++ b/src/hooks/users/useDeleteUser.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteUser } from "../../api/usersApi"; +import { toast } from "react-toastify"; + +export const useDeleteUser = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => deleteUser(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + toast.success("User Deleted!"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/users/useUpdateUser.ts b/src/hooks/users/useUpdateUser.ts new file mode 100644 index 0000000..36bb0a7 --- /dev/null +++ b/src/hooks/users/useUpdateUser.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateUser, type UserPayload } from "../../api/usersApi"; +import { toast } from "react-toastify"; + +export const useUpdateUser = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: number; payload: UserPayload }) => + updateUser(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + toast.success("User Updated"); + }, + onError: (error) =>{ + toast.error(error.message); + } + }); +}; diff --git a/src/hooks/users/useUserById.ts b/src/hooks/users/useUserById.ts new file mode 100644 index 0000000..e617819 --- /dev/null +++ b/src/hooks/users/useUserById.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { getUserById } from "../../api/usersApi"; + +export const useUserById = (id?: number) => { + return useQuery({ + queryKey: ["user", id], + queryFn: () => getUserById(id!), + enabled: !!id, + }); +}; diff --git a/src/hooks/users/useUsers.ts b/src/hooks/users/useUsers.ts new file mode 100644 index 0000000..630d734 --- /dev/null +++ b/src/hooks/users/useUsers.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { getUsers } from "../../api/usersApi"; + +export const useUsers = (page = 1) => { + return useQuery({ + queryKey: ["users", page], + queryFn: () => getUsers(page), + staleTime: 1000 * 60, + }); +}; diff --git a/src/layouts/AdminLayout/AdminLayout.tsx b/src/layouts/AdminLayout/AdminLayout.tsx new file mode 100644 index 0000000..d58bfa0 --- /dev/null +++ b/src/layouts/AdminLayout/AdminLayout.tsx @@ -0,0 +1,18 @@ +import { Outlet } from "react-router-dom"; +import { AdminSidebar } from "../../components/AdminSidebar/AdminSidebar"; +import { LayoutWrapper } from "../MainLayout/Layout.styles"; +import Header from "../../components/Header/Header"; +import { StyledDistance } from "../../components/shared/StyledDistance"; + +const AdminLayout = () => { + return ( + +
+ + + + + ); +}; + +export default AdminLayout; diff --git a/src/pages/UserForm/UserForm.tsx b/src/pages/UserForm/UserForm.tsx new file mode 100644 index 0000000..0e44f73 --- /dev/null +++ b/src/pages/UserForm/UserForm.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Button, + CircularProgress, + MenuItem, + TextField, + Typography, +} from "@mui/material"; +import { useUserById } from "../../hooks/users/useUserById"; +import { useCreateUser } from "../../hooks/users/useCreateUser"; +import { useUpdateUser } from "../../hooks/users/useUpdateUser"; + + +const UserForm = () => { + const { id } = useParams<{ id: string }>(); + const isUpdate = Boolean(id); + const navigate = useNavigate(); + + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [type, setType] = useState("user"); + + const { data: user, isLoading: isLoadingUser } = useUserById( + id ? Number(id) : undefined + ); + + const createUserMutation = useCreateUser(); + const updateUserMutation = useUpdateUser(); + + useEffect(() => { + if (user) { + setUsername(user.username); + setEmail(user.email); + setType(user.type); + } + }, [user]); + + if (isUpdate && isLoadingUser) return ; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const payload = { username, email, type, ...(password && { password }) }; + + if (isUpdate && id) { + console.log(payload); + console.log(id); + updateUserMutation.mutate( + { id: Number(id), payload }, + { onSuccess: () => navigate("/dashboard/users") } + ); + } else { + createUserMutation.mutate(payload, { + onSuccess: () => navigate("/dashboard/users"), + }); + } + }; + + return ( + + + {isUpdate ? "Update User" : "Create User"} + +
+ setUsername(e.target.value)} + fullWidth + required + sx={{ mb: 2 }} + /> + setEmail(e.target.value)} + fullWidth + required + sx={{ mb: 2 }} + /> + setPassword(e.target.value)} + fullWidth + sx={{ mb: 2 }} + placeholder={isUpdate ? "Leave blank to keep current password" : ""} + required={!isUpdate} + /> + setType(e.target.value)} + fullWidth + sx={{ mb: 3 }} + > + User + Admin + Creator + + + +
+ ); +}; + +export default UserForm; diff --git a/src/pages/UsersPage/UsersPage.tsx b/src/pages/UsersPage/UsersPage.tsx new file mode 100644 index 0000000..ea79de8 --- /dev/null +++ b/src/pages/UsersPage/UsersPage.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { + Box, + Button, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Pagination, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { useUsers } from "../../hooks/users/useUsers"; +import { useDeleteUser } from "../../hooks/users/useDeleteUser"; +import Container from "../../components/shared/Container"; +import { useNavigate } from "react-router-dom"; + +const UsersPage = () => { + const [currentPage, setCurrentPage] = useState(1); + const [deleteUserId, setDeleteUserId] = useState(null); + const { data, isLoading, isError } = useUsers(currentPage); + const deleteMutation = useDeleteUser(); + const navigate = useNavigate(); + + const handleCreateUser = () => { + navigate(`/dashboard/users/create`); + }; + + const handleUpdateUser = (id: number) => { + navigate(`/dashboard/users/${id}/update`); + }; + + const handleDeleteUser = () => { + if (deleteUserId !== null) { + deleteMutation.mutate(deleteUserId, { + onSuccess: () => setDeleteUserId(null), + }); + } + }; + + if (isLoading) return ; + if (isError) + return Error loading users.; + + return ( + + + Users + + + + + + + + ID + Username + Email + Type + Created At + Actions + + + + + {data?.data.map((user) => ( + + {user.id} + {user.username} + {user.email} + {user.type} + + {new Date(user.created_at).toLocaleDateString()} + + + + + + + ))} + +
+
+ + setCurrentPage(value)} + sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }} + /> + + setDeleteUserId(null)} + > + Confirm Delete + + Are you sure you want to delete this user? + + + + + + +
+ ); +}; + +export default UsersPage; diff --git a/src/router/router.tsx b/src/router/router.tsx index b78f072..947fa02 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -12,6 +12,10 @@ import ResetPasswordPage from "../pages/ResetPasswordPage/ResetPasswordPage"; import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage"; import { TestPage } from "../pages/TestPage/TestPage"; import TestsPage from "../pages/TestsPage/TestsPage"; +import AdminLayout from "../layouts/AdminLayout/AdminLayout"; +import Container from "../components/shared/Container"; +import UsersPage from "../pages/UsersPage/UsersPage"; +import UserForm from "../pages/UserForm/UserForm"; const router = createBrowserRouter([ { @@ -52,15 +56,19 @@ const router = createBrowserRouter([ }, { path: "/questions/:id", - element: + element: , }, { path: "/tests/:id", - element: + element: ( + + + + ), }, { path: "/tests", - element: + element: , }, { path: "*", @@ -68,6 +76,26 @@ const router = createBrowserRouter([ }, ], }, + { + path: "/dashboard", + element: , + children: [ + { index: true, element: }, + { + path: "users", + children: [ + { index: true, element: }, + { path: "create", element: }, + { path: ":id/update", element: }, + ], + }, + { path: "questions", element: questions }, + { path: "tests", element: tests }, + { path: "user-tests", element: User Tests }, + { path: "categories", element: categories }, + { path: "logs", element: logs }, + ], + }, ]); export default router;