diff --git a/src/App.tsx b/src/App.tsx index e3614bd..01c8cfd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { ToastContainer } from "react-toastify"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AuthProvider } from "./context/AuthContext"; + const queryClient = new QueryClient(); function App() { diff --git a/src/api/categoryApi.ts b/src/api/categoryApi.ts index 6fa22ab..ca7b5b1 100644 --- a/src/api/categoryApi.ts +++ b/src/api/categoryApi.ts @@ -9,3 +9,24 @@ export const getCategories = async () => { const res = await axiosInstance.get("/api/categories"); return res.data.data; }; + +export const getCategoryById = async (id: number) => { + const res = await axiosInstance.get<{data:CategoryType}>(`/api/categories/${id}`); + return res.data.data; +}; + +export const createCategory = async (name: string) => { + const res = await axiosInstance.post(`/api/categories`, { name }); + return res.data; +}; + +export const updateCategory = async (id: number, name: string) => { + const res = await axiosInstance.put(`/api/categories/${id}`, { name }); + return res.data; +}; + +export const deleteCategory = async (id: number) => { + const res = await axiosInstance.delete(`/api/categories/${id}`); + return res.data; +}; + diff --git a/src/api/logsApi.ts b/src/api/logsApi.ts new file mode 100644 index 0000000..ca2c14f --- /dev/null +++ b/src/api/logsApi.ts @@ -0,0 +1,24 @@ +import axiosInstance from "./axiosInstance"; + +export type LogType = { + id: number; + description: string; + created_at: string; +}; + +interface LogsResponse { + data: LogType[]; + meta: { + current_page: number; + last_page: number; + per_page: number; + total: number; + }; +} + +export const getLogs = async (page = 1) => { + const res = await axiosInstance.get("/api/logs", { + params: { page }, + }); + return res.data; +}; diff --git a/src/api/metricsService.ts b/src/api/metricsService.ts new file mode 100644 index 0000000..7084892 --- /dev/null +++ b/src/api/metricsService.ts @@ -0,0 +1,39 @@ +import axiosInstance from "./axiosInstance"; + +export const hitApi = async (url: string): Promise => { + await axiosInstance.post("/api/hit", { url }); +}; + +export interface HitCountType { + id: number; + ip: string; + device_type: string; + user_agent: string; + country: string | null; + url: string; + created_at?: string; +} + +export interface HitCountsResponse { + data: HitCountType[]; + links: { + first: string; + last: string; + prev: string | null; + next: string | null; + }; + meta: { + current_page: number; + from: number; + last_page: number; + per_page: number; + total: number; + }; +} + +export const getHitCounts = async (page = 1) => { + const res = await axiosInstance.get("/api/hitcounts", { + params: { page }, + }); + return res.data; +}; diff --git a/src/components/AdminSidebar/AdminSidebar.tsx b/src/components/AdminSidebar/AdminSidebar.tsx index 6893cc1..96e4466 100644 --- a/src/components/AdminSidebar/AdminSidebar.tsx +++ b/src/components/AdminSidebar/AdminSidebar.tsx @@ -13,6 +13,7 @@ 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'; +import FmdBadIcon from '@mui/icons-material/FmdBad'; const drawerWidth = 240; @@ -35,12 +36,17 @@ const menuItems = [ { label: "User Tests", icon: , - to: "/dashboard/userTests", + to: "/dashboard/user-tests", }, { label: "Categories", icon: , to: "/dashboard/categories", + }, + { + label: "Hitcounts", + icon: , + to: "/dashboard/hitcounts", }, { label: "Logs", diff --git a/src/components/Cholecounter/Cholecounter.tsx b/src/components/Cholecounter/Cholecounter.tsx new file mode 100644 index 0000000..43c132a --- /dev/null +++ b/src/components/Cholecounter/Cholecounter.tsx @@ -0,0 +1,15 @@ +import { useEffect } from "react"; +import { hitApi } from "../../api/metricsService"; +import { useLocation } from "react-router-dom"; + +const Cholecounter = () => { + const location = useLocation(); + + useEffect(() => { + hitApi(window.location.href); + }, [location.pathname]); + + return null; +}; + +export default Cholecounter; diff --git a/src/components/StartTestForm/StartTestForm.tsx b/src/components/StartTestForm/StartTestForm.tsx index 40c8126..6feb232 100644 --- a/src/components/StartTestForm/StartTestForm.tsx +++ b/src/components/StartTestForm/StartTestForm.tsx @@ -9,7 +9,7 @@ import { } from "@mui/material"; import { StartTestWrapper } from "./StartTestForm.styles"; import { useState } from "react"; -import { useCategories } from "../../hooks/Question/useCategories"; +import { useCategories } from "../../hooks/categories/useCategories"; import type { Category } from "../shared/types/QuestionTypes"; import { useStartTest } from "../../hooks/Tests/useStartTest"; import { toast } from "react-toastify"; diff --git a/src/hooks/Question/useCategories.ts b/src/hooks/categories/useCategories.ts similarity index 100% rename from src/hooks/Question/useCategories.ts rename to src/hooks/categories/useCategories.ts diff --git a/src/hooks/categories/useCreateCategory.ts b/src/hooks/categories/useCreateCategory.ts new file mode 100644 index 0000000..2e59e34 --- /dev/null +++ b/src/hooks/categories/useCreateCategory.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createCategory } from "../../api/categoryApi"; +import { toast } from "react-toastify"; + +export const useCreateCategory = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => createCategory(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["categories"] }); + toast.success("Category Created"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/categories/useDeleteCategory.ts b/src/hooks/categories/useDeleteCategory.ts new file mode 100644 index 0000000..9e67cc1 --- /dev/null +++ b/src/hooks/categories/useDeleteCategory.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteCategory } from "../../api/categoryApi"; +import { toast } from "react-toastify"; + +export const useDeleteCategory = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => deleteCategory(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["categories"] }); + toast.success("Category Deleted"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/categories/useGetCategoryById.ts b/src/hooks/categories/useGetCategoryById.ts new file mode 100644 index 0000000..63408aa --- /dev/null +++ b/src/hooks/categories/useGetCategoryById.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { getCategoryById } from "../../api/categoryApi"; + +export const useCategoryById = (id?: number) => { + return useQuery({ + queryKey: ["categories", id], + queryFn: () => getCategoryById(id!), + enabled: !!id, + }); +}; \ No newline at end of file diff --git a/src/hooks/categories/useUpdateCategory.ts b/src/hooks/categories/useUpdateCategory.ts new file mode 100644 index 0000000..81e2e2c --- /dev/null +++ b/src/hooks/categories/useUpdateCategory.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateCategory } from "../../api/categoryApi"; +import { toast } from "react-toastify"; + +export const useUpdateCategory = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, name }: { id: number; name: string }) => + updateCategory(id, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["categories"] }); + toast.success("Category Updated"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/src/hooks/hitcounts/useHitcounts.ts b/src/hooks/hitcounts/useHitcounts.ts new file mode 100644 index 0000000..079581a --- /dev/null +++ b/src/hooks/hitcounts/useHitcounts.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getHitCounts } from "../../api/metricsService"; + +export const useHitcounts = (page: number) => { + return useQuery({ + queryKey: ["hitcounts", page], + queryFn: () => getHitCounts(page), + }); +}; \ No newline at end of file diff --git a/src/hooks/logsHook/useLogs.ts b/src/hooks/logsHook/useLogs.ts new file mode 100644 index 0000000..9fac894 --- /dev/null +++ b/src/hooks/logsHook/useLogs.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getLogs } from "../../api/logsApi"; + +export const useLogs = (page: number) => { + return useQuery({ + queryKey: ["logs", page], + queryFn: () => getLogs(page), + }); +}; diff --git a/src/layouts/AdminLayout/AdminLayout.tsx b/src/layouts/AdminLayout/AdminLayout.tsx index d58bfa0..8409730 100644 --- a/src/layouts/AdminLayout/AdminLayout.tsx +++ b/src/layouts/AdminLayout/AdminLayout.tsx @@ -3,6 +3,7 @@ import { AdminSidebar } from "../../components/AdminSidebar/AdminSidebar"; import { LayoutWrapper } from "../MainLayout/Layout.styles"; import Header from "../../components/Header/Header"; import { StyledDistance } from "../../components/shared/StyledDistance"; +import Cholecounter from "../../components/Cholecounter/Cholecounter"; const AdminLayout = () => { return ( @@ -10,6 +11,7 @@ const AdminLayout = () => {
+ ); diff --git a/src/layouts/MainLayout/MainLayout.tsx b/src/layouts/MainLayout/MainLayout.tsx index bc19eae..d62b930 100644 --- a/src/layouts/MainLayout/MainLayout.tsx +++ b/src/layouts/MainLayout/MainLayout.tsx @@ -2,6 +2,7 @@ import { Outlet } from "react-router-dom"; import Header from "../../components/Header/Header"; import { LayoutWrapper } from "./Layout.styles"; import { StyledDistance } from "../../components/shared/StyledDistance"; +import Cholecounter from "../../components/Cholecounter/Cholecounter"; const MainLayout = () => { return ( @@ -9,6 +10,7 @@ const MainLayout = () => {
+ diff --git a/src/pages/CategoriesPage/CategoriesPage.tsx b/src/pages/CategoriesPage/CategoriesPage.tsx new file mode 100644 index 0000000..42e1c74 --- /dev/null +++ b/src/pages/CategoriesPage/CategoriesPage.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { + Box, + Button, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import Container from "../../components/shared/Container"; +import { useCategories } from "../../hooks/categories/useCategories"; +import { useDeleteCategory } from "../../hooks/categories/useDeleteCategory"; + +const CategoriesPage = () => { + const [deleteCategoryId, setDeleteCategoryId] = useState(null); + + const { data, isLoading, isError } = useCategories(); + const deleteMutation = useDeleteCategory(); + const navigate = useNavigate(); + + const handleCreateCategory = () => { + navigate("/dashboard/categories/create"); + }; + + const handleUpdateCategory = (id: number) => { + navigate(`/dashboard/categories/${id}/update`); + }; + + const handleDeleteCategory = () => { + if (deleteCategoryId !== null) { + deleteMutation.mutate(deleteCategoryId, { + onSuccess: () => setDeleteCategoryId(null), + }); + } + }; + + if (isLoading) return ; + if (isError) + return Error loading categories.; + + return ( + + + Categories + + + + + + + + ID + Name + Questions + User Tests + Created At + Actions + + + + + {data?.map((category) => ( + + {category.id} + {category.name} + {category.questions_count ?? "-"} + {category.user_tests_count ?? "-"} + + {category.created_at + ? new Date(category.created_at).toLocaleDateString() + : "-"} + + + + + + + ))} + +
+
+ + setDeleteCategoryId(null)} + > + Confirm Delete + + Are you sure you want to delete this category? + + + + + + +
+ ); +}; + +export default CategoriesPage; diff --git a/src/pages/CategoryForm/CategoryForm.tsx b/src/pages/CategoryForm/CategoryForm.tsx new file mode 100644 index 0000000..60bd986 --- /dev/null +++ b/src/pages/CategoryForm/CategoryForm.tsx @@ -0,0 +1,77 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Button, + CircularProgress, + TextField, + Typography, +} from "@mui/material"; + +import { useCreateCategory } from "../../hooks/categories/useCreateCategory"; +import { useUpdateCategory } from "../../hooks/categories/useUpdateCategory"; +import { useCategoryById } from "../../hooks/categories/useGetCategoryById"; + +const CategoryForm = () => { + const { id } = useParams<{ id: string }>(); + const isUpdate = Boolean(id); + const navigate = useNavigate(); + + const [name, setName] = useState(""); + + const { data: category, isLoading } = useCategoryById( + id ? Number(id) : undefined + ); + + const createCategoryMutation = useCreateCategory(); + const updateCategoryMutation = useUpdateCategory(); + + useEffect(() => { + if (category) { + setName(category.name); + } + }, [category]); + + if (isUpdate && isLoading) return ; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (isUpdate && id) { + updateCategoryMutation.mutate( + { id: Number(id), name }, + { onSuccess: () => navigate("/dashboard/categories") } + ); + } else { + createCategoryMutation.mutate(name, { + onSuccess: () => navigate("/dashboard/categories"), + }); + } + }; + + return ( + + + {isUpdate ? "Update Category" : "Create Category"} + + +
+ setName(e.target.value)} + fullWidth + required + sx={{ mb: 3 }} + /> + + + +
+ ); +}; + +export default CategoryForm; + diff --git a/src/pages/HitcountsPage/HitcountsPage.tsx b/src/pages/HitcountsPage/HitcountsPage.tsx new file mode 100644 index 0000000..55cfdb9 --- /dev/null +++ b/src/pages/HitcountsPage/HitcountsPage.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { + Box, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Pagination, +} from "@mui/material"; +import Container from "../../components/shared/Container"; +import { useHitcounts } from "../../hooks/hitcounts/useHitcounts"; + +const HitCountsPage = () => { + const [currentPage, setCurrentPage] = useState(1); + const { data, isLoading, isError } = useHitcounts(currentPage); + + if (isLoading) return ; + if (isError) + return Error loading hitcounts.; + + return ( + + + Hit Counts + + + + + + + ID + IP + Device + User Agent + Country + URL + + + + {data?.data.map((hit) => ( + + {hit.id} + {hit.ip} + {hit.device_type} + {hit.user_agent} + {hit.country || "-"} + {hit.url} + + ))} + +
+
+ + setCurrentPage(value)} + sx={{ mt: 3, display: "flex", justifyContent: "center" }} + /> +
+ ); +}; + +export default HitCountsPage; diff --git a/src/pages/IndexPage/IndexPage.tsx b/src/pages/IndexPage/IndexPage.tsx index ad8228d..a5463ee 100644 --- a/src/pages/IndexPage/IndexPage.tsx +++ b/src/pages/IndexPage/IndexPage.tsx @@ -15,7 +15,7 @@ import Question from "../../components/Question/Question"; import { useQuestions } from "../../hooks/Question/useQuestions"; import type { QuestionType } from "../../components/shared/types/QuestionTypes"; import { useAuth } from "../../context/AuthContext"; -import { useCategories } from "../../hooks/Question/useCategories"; +import { useCategories } from "../../hooks/categories/useCategories"; const IndexPage = () => { const [page, setPage] = useState(1); diff --git a/src/pages/LogsPage/LogsPage.tsx b/src/pages/LogsPage/LogsPage.tsx new file mode 100644 index 0000000..492c76e --- /dev/null +++ b/src/pages/LogsPage/LogsPage.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Pagination, +} from "@mui/material"; +import Container from "../../components/shared/Container"; +import { useLogs } from "../../hooks/logsHook/useLogs"; + +const LogsPage = () => { + const [currentPage, setCurrentPage] = useState(1); + const { data, isLoading, isError } = useLogs(currentPage); + + if (isLoading) return ; + if (isError) + return Error loading logs.; + + return ( + + + Activity Logs + + + + + + + ID + Description + Created At + + + + + {data?.data.map((log) => ( + + {log.id} + {log.description} + + {new Date(log.created_at).toLocaleString()} + + + ))} + +
+
+ + setCurrentPage(value)} + sx={{ mt: 3, display: "flex", justifyContent: "center" }} + /> +
+ ); +}; + +export default LogsPage; diff --git a/src/pages/TestsPage/TestsPage.tsx b/src/pages/TestsPage/TestsPage.tsx index 17b1a25..1d405d8 100644 --- a/src/pages/TestsPage/TestsPage.tsx +++ b/src/pages/TestsPage/TestsPage.tsx @@ -14,7 +14,7 @@ import { Typography, type SelectChangeEvent, } from "@mui/material"; -import { useCategories } from "../../hooks/Question/useCategories"; +import { useCategories } from "../../hooks/categories/useCategories"; const TestsPage = () => { const [currentPage, setCurrentPage] = useState(1); diff --git a/src/router/router.tsx b/src/router/router.tsx index 947fa02..7e4d2ca 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -16,6 +16,10 @@ import AdminLayout from "../layouts/AdminLayout/AdminLayout"; import Container from "../components/shared/Container"; import UsersPage from "../pages/UsersPage/UsersPage"; import UserForm from "../pages/UserForm/UserForm"; +import CategoriesPage from "../pages/CategoriesPage/CategoriesPage"; +import CategoryForm from "../pages/CategoryForm/CategoryForm"; +import LogsPage from "../pages/LogsPage/LogsPage"; +import HitCountsPage from "../pages/HitcountsPage/HitcountsPage"; const router = createBrowserRouter([ { @@ -92,8 +96,16 @@ const router = createBrowserRouter([ { path: "questions", element: questions }, { path: "tests", element: tests }, { path: "user-tests", element: User Tests }, - { path: "categories", element: categories }, - { path: "logs", element: logs }, + { + path: "categories", + children: [ + { index: true, element: }, + { path: "create", element: }, + { path: ":id/update", element: }, + ], + }, + { path: "logs", element: }, + {path: "hitcounts", element: } ], }, ]);