logs categories and hitcounts

This commit is contained in:
David Katrinka 2025-12-30 19:27:36 +01:00
parent 9121f49a9b
commit 4ee508f01b
23 changed files with 559 additions and 6 deletions

View File

@ -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() {

View File

@ -9,3 +9,24 @@ export const getCategories = async () => {
const res = await axiosInstance.get<CategoriesResponse>("/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;
};

24
src/api/logsApi.ts Normal file
View File

@ -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<LogsResponse>("/api/logs", {
params: { page },
});
return res.data;
};

39
src/api/metricsService.ts Normal file
View File

@ -0,0 +1,39 @@
import axiosInstance from "./axiosInstance";
export const hitApi = async (url: string): Promise<void> => {
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<HitCountsResponse>("/api/hitcounts", {
params: { page },
});
return res.data;
};

View File

@ -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: <AssignmentIndIcon />,
to: "/dashboard/userTests",
to: "/dashboard/user-tests",
},
{
label: "Categories",
icon: <CategoryIcon />,
to: "/dashboard/categories",
},
{
label: "Hitcounts",
icon: <FmdBadIcon />,
to: "/dashboard/hitcounts",
},
{
label: "Logs",

View File

@ -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;

View File

@ -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";

View File

@ -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);
},
});
};

View File

@ -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);
},
});
};

View File

@ -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,
});
};

View File

@ -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);
},
});
};

View File

@ -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),
});
};

View File

@ -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),
});
};

View File

@ -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 = () => {
<Header />
<StyledDistance />
<AdminSidebar />
<Cholecounter/>
<Outlet />
</LayoutWrapper>
);

View File

@ -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 = () => {
<LayoutWrapper>
<Header />
<StyledDistance />
<Cholecounter/>
<Outlet />
</LayoutWrapper>
</>

View File

@ -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<number | null>(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 <CircularProgress />;
if (isError)
return <Typography color="error">Error loading categories.</Typography>;
return (
<Container>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Typography variant="h4">Categories</Typography>
<Button
variant="contained"
color="primary"
onClick={handleCreateCategory}
>
Create Category
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Questions</TableCell>
<TableCell>User Tests</TableCell>
<TableCell>Created At</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.map((category) => (
<TableRow key={category.id}>
<TableCell>{category.id}</TableCell>
<TableCell>{category.name}</TableCell>
<TableCell>{category.questions_count ?? "-"}</TableCell>
<TableCell>{category.user_tests_count ?? "-"}</TableCell>
<TableCell>
{category.created_at
? new Date(category.created_at).toLocaleDateString()
: "-"}
</TableCell>
<TableCell>
<Button
variant="outlined"
size="small"
sx={{ mr: 1 }}
onClick={() => handleUpdateCategory(category.id)}
>
Update
</Button>
<Button
variant="outlined"
color="error"
size="small"
onClick={() => setDeleteCategoryId(category.id)}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog
open={deleteCategoryId !== null}
onClose={() => setDeleteCategoryId(null)}
>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete this category?
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteCategoryId(null)}>Cancel</Button>
<Button
color="error"
onClick={handleDeleteCategory}
disabled={deleteMutation.isPending}
>
Delete
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default CategoriesPage;

View File

@ -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 <CircularProgress />;
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 (
<Box sx={{ maxWidth: 500, mx: "auto", mt: 5 }}>
<Typography variant="h5" mb={3}>
{isUpdate ? "Update Category" : "Create Category"}
</Typography>
<form onSubmit={handleSubmit}>
<TextField
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
required
sx={{ mb: 3 }}
/>
<Button variant="contained" color="primary" type="submit">
{isUpdate ? "Update" : "Create"}
</Button>
</form>
</Box>
);
};
export default CategoryForm;

View File

@ -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 <CircularProgress />;
if (isError)
return <Typography color="error">Error loading hitcounts.</Typography>;
return (
<Container>
<Box sx={{ mb: 3 }}>
<Typography variant="h4">Hit Counts</Typography>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>IP</TableCell>
<TableCell>Device</TableCell>
<TableCell>User Agent</TableCell>
<TableCell>Country</TableCell>
<TableCell>URL</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.data.map((hit) => (
<TableRow key={hit.id}>
<TableCell>{hit.id}</TableCell>
<TableCell>{hit.ip}</TableCell>
<TableCell>{hit.device_type}</TableCell>
<TableCell>{hit.user_agent}</TableCell>
<TableCell>{hit.country || "-"}</TableCell>
<TableCell>{hit.url}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Pagination
color="primary"
shape="rounded"
count={data?.meta.last_page || 1}
page={currentPage}
onChange={(_, value) => setCurrentPage(value)}
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
/>
</Container>
);
};
export default HitCountsPage;

View File

@ -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);

View File

@ -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 <CircularProgress />;
if (isError)
return <Typography color="error">Error loading logs.</Typography>;
return (
<Container>
<Typography variant="h4" mb={3}>
Activity Logs
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Description</TableCell>
<TableCell>Created At</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.data.map((log) => (
<TableRow key={log.id}>
<TableCell>{log.id}</TableCell>
<TableCell>{log.description}</TableCell>
<TableCell>
{new Date(log.created_at).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Pagination
color="primary"
shape="rounded"
count={data?.meta.last_page}
page={currentPage}
onChange={(_, value) => setCurrentPage(value)}
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
/>
</Container>
);
};
export default LogsPage;

View File

@ -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);

View File

@ -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: <Container>questions</Container> },
{ path: "tests", element: <Container>tests</Container> },
{ path: "user-tests", element: <Container>User Tests</Container> },
{ path: "categories", element: <Container>categories</Container> },
{ path: "logs", element: <Container>logs</Container> },
{
path: "categories",
children: [
{ index: true, element: <CategoriesPage /> },
{ path: "create", element: <CategoryForm /> },
{ path: ":id/update", element: <CategoryForm /> },
],
},
{ path: "logs", element: <LogsPage /> },
{path: "hitcounts", element: <HitCountsPage/>}
],
},
]);