diff --git a/CMS_UX_ROADMAP.md b/CMS_UX_ROADMAP.md new file mode 100644 index 0000000..0e7034c --- /dev/null +++ b/CMS_UX_ROADMAP.md @@ -0,0 +1,53 @@ +# CMS Experience & Capabilities Blueprint + +This master document outlines the strategic UX/UI and structural enhancements targeted for the AxeCode CMS. The goal is to upgrade the current modular grid interface into a highly robust, enterprise-grade Content Management System. + +--- + +## ๐Ÿ—๏ธ 1. Scalability & Performance Engine (Server-Side Overhaul) +Currently, data grids fetch entire datasets and compute pagination or search filtering on the client side (in browser). This architecture fails at scale. + +### Future Implementation: +* **Server-Side Pagination:** Refactor API repositories to integrate natively with Strapi's standard pagination parameters (`?pagination[page]=1&pagination[pageSize]=10`). +* **Query Builder Mapping:** Transform frontend filter criteria into Strapi Query strings within the Domain layer before executing API calls. +* **Component Shift:** `CMSResourceTable.jsx` must shift from slicing a complete array (e.g., `items.slice()`) to passing `onPageChange` commands up to the useCase hooks, which then fetch subsequent chunks. +* **Debounced Instant Search:** Ensure that typing in the search bar uses lodash `debounce` to fetch data from the server without exhausting the API rate limit. + +## ๐Ÿ“Š 2. "Bird's-Eye" Analytics Dashboard +Currently, logging into the CMS throws the Admin directly into a table. A modern CMS requires a primary overview context. + +### Future Implementation: +* **Entry Dashboard Component:** Introduce `CMSDashboard.jsx` as the default route (`/cms`). +* **Real-time Metrics:** + * **Quick Counters:** Display aggregated metrics for total published Courses, active Events, pending Reports, and active accounts. + * **Submission Trend Graphs:** Use lightweight chart libraries (e.g., `recharts`) to plot system engagement over the past 30 days. + * **Pending Actions:** Highlighting critical items requiring immediate administrative action (e.g. Unresolved User Reports). + +## ๐Ÿš€ 3. Batch Productivity & Bulk Workflows +Management involves repetitive and bulky tasks. The multi-select implementation currently only supports batch deletion. + +### Future Implementation: +* **Bulk Status Mutators:** Allow selecting 10 entries and publishing or rolling them back to "Draft" via a single click in the action bar. +* **Bulk Categorization:** Add the ability to assign massive arrays of Courses to specific Tag groups or Course Types all at once. +* **Smart Header Sorting:** Headers (Date, Engagement Score, Status) must become clickable. Clicking issues a server-side sort request (`?sort=publishedAt:desc`) returning the appropriately sorted list. +* **Pre-Set Fast Filters:** Buttons acting as pre-baked queries above the table (e.g., "Active Modules", "Legacy Content", "Hidden"). + +## ๐ŸŽจ 4. Media Library Visual Recognition (Visual UX) +Rows representing images with a "Storage" metric are difficult to interact with. Media manipulation requires graphical indexing. + +### Future Implementation: +* **Grid Layout Paradigm:** Introduce a dual-mode component (`Table Mode` / `Grid Mode`), keeping `Grid Mode` default for the Media page. +* **Lazy-loaded Thumbnail UI:** Directly fetch image thumbnails generated by Strapi (Small/Thumbnail formats) from the bucket to ensure snappy rendering of visuals. +* **Asset Preview Context:** Introduce a side-panel upon clicking a media item that displays file resolution, alt tags, and a unified preview window. + +## ๐Ÿ”’ 5. Role-Based Access Control (RBAC) & Governance UI +The CMS must not present unauthorized actions to non-privileged users. + +### Future Implementation: +* **Dynamic Sidebar Extraction:** The sidebar (`CMSSidebar.jsx`) needs to read the incoming `userSession.role`. If the user is a `Support Agent`, they should only see "Help Centers" and "Reports". +* **Safe-State UI Overrides:** If a user navigates to an endpoint they don't have access to, the dashboard should lock the screen and provide an elegant 403 "Restricted Clearance" manuscript error screen. +* **Action Logs/Audit Trails:** A logging table specifically for Super Admins showing which User Account modified what article and at what time. + +--- + +**Strategic Priority for Next Execution Phase:** Start systematically addressing `CMSResourceTable` by implementing Server-Side scaling, which protects the application from catastrophic memory overflows, then advance sequentially to the Master Dashboard. diff --git a/scratch/verify_mapper.test.js b/scratch/verify_mapper.test.js deleted file mode 100644 index 750cab9..0000000 --- a/scratch/verify_mapper.test.js +++ /dev/null @@ -1,47 +0,0 @@ - -import { EntityMapper } from '../../src/domain/mapper/EntityMapper'; -import { CourseDTO } from '../../src/infrastructure/DTO/CourseDTO'; - -describe('EntityMapper Mock Removal Verification', () => { - const mockCourseData = { - documentId: 'course_123', - title: 'Test Course', - lessonCount: 15, - duration: 120, // 2 hours - interactions: { - rating: { - average: 4.2, - count: 88 - } - }, - instructor: { - documentId: 'user_456', - username: 'test_instructor', - displayName: 'Real Instructor' - } - }; - - it('should map real duration and reviewsCount correctly without mock fallbacks', () => { - const dto = new CourseDTO(mockCourseData); - const mapper = new EntityMapper(); - const entity = mapper.toCourse(dto); - - expect(entity.duration).toBe(120); - expect(entity.reviewsCount).toBe(88); - expect(entity.rating).toBe(4.2); - expect(entity.instructor.displayName).toBe('Real Instructor'); - }); - - it('should handle missing data gracefully (fallbacks to 0, not mock values)', () => { - const emptyData = { - documentId: 'course_789' - }; - const dto = new CourseDTO(emptyData); - const mapper = new EntityMapper(); - const entity = mapper.toCourse(dto); - - expect(entity.duration).toBe(0); - expect(entity.reviewsCount).toBe(0); - expect(entity.rating).toBe(0); - }); -}); diff --git a/src/domain/entity/UserEntity.js b/src/domain/entity/UserEntity.js index fb16dfb..0002fc3 100644 --- a/src/domain/entity/UserEntity.js +++ b/src/domain/entity/UserEntity.js @@ -16,6 +16,10 @@ export class UserEntity extends BaseEntity { * @param {MediaEntity|null} props.avatar * @param {number} props.submissionCount * @param {number} props.passedSubmissionsCount + * @param {boolean} props.confirmed + * @param {boolean} props.blocked + * @param {object} props.role + * @param {string} props.createdAt */ constructor(props = {}) { super(props); @@ -28,6 +32,12 @@ export class UserEntity extends BaseEntity { this.bio = props.bio; this.avatar = props.avatar; + // Admin fields + this.confirmed = props.confirmed; + this.blocked = props.blocked; + this.role = props.role; + this.createdAt = props.createdAt; + // Direct statistics (fetched from table, not populated) this.submissionCount = props.submissionCount || 0; this.passedSubmissionsCount = props.passedSubmissionsCount || 0; diff --git a/src/domain/mapper/EntityMapper.js b/src/domain/mapper/EntityMapper.js index 8a14dc0..0a74b92 100644 --- a/src/domain/mapper/EntityMapper.js +++ b/src/domain/mapper/EntityMapper.js @@ -74,6 +74,10 @@ export class EntityMapper { university: data.university, bio: data.bio, avatar: this.toMedia(data.avatar), + confirmed: data.confirmed, + blocked: data.blocked, + role: data.role, + createdAt: data.createdAt, submissionCount: stats.total || 0, passedSubmissionsCount: stats.passed || 0 }); diff --git a/src/domain/useCase/useDeleteArticle.js b/src/domain/useCase/useDeleteArticle.js new file mode 100644 index 0000000..32c1fcf --- /dev/null +++ b/src/domain/useCase/useDeleteArticle.js @@ -0,0 +1,21 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { ArticleRepository } from '../../infrastructure/repository/ArticleRepository'; +import { useMemo } from 'react'; + +/** + * UseCase for deleting an article. + */ +export const useDeleteArticle = () => { + const repository = useMemo(() => new ArticleRepository(), []); + + const { execute, inProgress, error, returnedData } = useAsyncUseCase( + (id) => repository.delete(id) + ); + + return { + deleteArticle: execute, + inProgress, + error, + success: !!returnedData + }; +}; diff --git a/src/domain/useCase/useDeleteBlog.js b/src/domain/useCase/useDeleteBlog.js new file mode 100644 index 0000000..ecf8d2d --- /dev/null +++ b/src/domain/useCase/useDeleteBlog.js @@ -0,0 +1,21 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { BlogRepository } from '../../infrastructure/repository/BlogRepository'; +import { useMemo } from 'react'; + +/** + * UseCase for deleting a blog post. + */ +export const useDeleteBlog = () => { + const repository = useMemo(() => new BlogRepository(), []); + + const { execute, inProgress, error, returnedData } = useAsyncUseCase( + (id) => repository.delete(id) + ); + + return { + deleteBlog: execute, + inProgress, + error, + success: !!returnedData + }; +}; diff --git a/src/domain/useCase/useDeleteCourse.js b/src/domain/useCase/useDeleteCourse.js new file mode 100644 index 0000000..512ebbd --- /dev/null +++ b/src/domain/useCase/useDeleteCourse.js @@ -0,0 +1,21 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { CourseRepository } from '../../infrastructure/repository/CourseRepository'; +import { useMemo } from 'react'; + +/** + * UseCase for deleting a course. + */ +export const useDeleteCourse = () => { + const repository = useMemo(() => new CourseRepository(), []); + + const { execute, inProgress, error, returnedData } = useAsyncUseCase( + (id) => repository.delete(id) + ); + + return { + deleteCourse: execute, + inProgress, + error, + success: !!returnedData + }; +}; diff --git a/src/domain/useCase/useFetchAdminCategorizations.js b/src/domain/useCase/useFetchAdminCategorizations.js new file mode 100644 index 0000000..327f924 --- /dev/null +++ b/src/domain/useCase/useFetchAdminCategorizations.js @@ -0,0 +1,78 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { CategorizationRepository } from '../../infrastructure/repository/CategorizationRepository'; +import { useMemo, useEffect, useCallback, useState } from 'react'; + +/** + * Hook for managing Course Types and Problem Types in Admin CMS. + * Each type maintains its own independent pagination state. + */ +export const useFetchAdminCategorizations = () => { + const repository = useMemo(() => new CategorizationRepository(), []); + const pageSize = 10; + + // Course Types pagination state + const [courseTypePage, setCourseTypePage] = useState(1); + const [courseTypeSearch, setCourseTypeSearch] = useState(''); + + // Problem Types pagination state + const [problemTypePage, setProblemTypePage] = useState(1); + const [problemTypeSearch, setProblemTypeSearch] = useState(''); + + // Course Types + const fetchCourseTypes = useCallback(async () => { + return await repository.getCourseTypes(courseTypePage, pageSize, courseTypeSearch); + }, [repository, courseTypePage, courseTypeSearch]); + + const { execute: loadCourseTypes, returnedData: courseTypesData, inProgress: isLoadingCourses, error: courseError } = useAsyncUseCase(fetchCourseTypes); + + // Problem Types + const fetchProblemTypes = useCallback(async () => { + return await repository.getProblemTypes(problemTypePage, pageSize, problemTypeSearch); + }, [repository, problemTypePage, problemTypeSearch]); + + const { execute: loadProblemTypes, returnedData: problemTypesData, inProgress: isLoadingProblems, error: problemError } = useAsyncUseCase(fetchProblemTypes); + + useEffect(() => { + loadCourseTypes(); + }, [loadCourseTypes]); + + useEffect(() => { + loadProblemTypes(); + }, [loadProblemTypes]); + + const deleteCourseType = useCallback(async (id) => { + await repository.deleteCourseType(id); + await loadCourseTypes(); + }, [repository, loadCourseTypes]); + + const deleteProblemType = useCallback(async (id) => { + await repository.deleteProblemType(id); + await loadProblemTypes(); + }, [repository, loadProblemTypes]); + + return { + // Course Types + courseTypes: courseTypesData?.items || [], + courseTypesTotalItems: courseTypesData?.meta?.pagination?.total || 0, + courseTypesTotalPages: Math.max(1, Math.ceil((courseTypesData?.meta?.pagination?.total || 0) / pageSize)), + courseTypesPage: courseTypePage, + setCourseTypePage, + setCourseTypeSearch, + reloadCourseTypes: loadCourseTypes, + deleteCourseType, + + // Problem Types + problemTypes: problemTypesData?.items || [], + problemTypesTotalItems: problemTypesData?.meta?.pagination?.total || 0, + problemTypesTotalPages: Math.max(1, Math.ceil((problemTypesData?.meta?.pagination?.total || 0) / pageSize)), + problemTypesPage: problemTypePage, + setProblemTypePage, + setProblemTypeSearch, + reloadProblemTypes: loadProblemTypes, + deleteProblemType, + + // Shared + isLoading: isLoadingCourses || isLoadingProblems, + error: courseError || problemError + }; +}; diff --git a/src/domain/useCase/useFetchAdminCourses.js b/src/domain/useCase/useFetchAdminCourses.js index 61c8c37..cca7eeb 100644 --- a/src/domain/useCase/useFetchAdminCourses.js +++ b/src/domain/useCase/useFetchAdminCourses.js @@ -1,6 +1,6 @@ import { useAsyncUseCase } from './useAsyncUseCase'; import { CourseRepository } from '../../infrastructure/repository/CourseRepository'; -import { useMemo, useEffect } from 'react'; +import { useMemo, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; /** @@ -10,20 +10,29 @@ export const useFetchAdminCourses = () => { const userId = useSelector((state) => state.auth?.user?.id); const repository = useMemo(() => new CourseRepository(), []); - // Pass userId up to the filter - const { execute, returnedData: courses, inProgress: isLoading, error } = useAsyncUseCase( - () => repository.getAll(userId) + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; // Match what's configured in CMSResourceTable logic + + // Pass page, pageSize, search to repository + const { execute, returnedData: data, inProgress: isLoading, error } = useAsyncUseCase( + () => repository.getAll(userId, page, pageSize, search) ); useEffect(() => { if (userId) { execute(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userId]); + }, [userId, page, search]); return { - courses: courses || [], + courses: data?.items || [], + totalItems: data?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((data?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + searchQuery: search, + setSearch, isLoading, error, fetch: execute diff --git a/src/domain/useCase/useFetchAdminEvents.js b/src/domain/useCase/useFetchAdminEvents.js index 4b8534a..9bfbfef 100644 --- a/src/domain/useCase/useFetchAdminEvents.js +++ b/src/domain/useCase/useFetchAdminEvents.js @@ -1,17 +1,20 @@ import { useAsyncUseCase } from './useAsyncUseCase'; import { EventRepository } from '../../infrastructure/repository/EventRepository'; -import { useMemo, useEffect, useCallback } from 'react'; +import { useMemo, useEffect, useCallback, useState } from 'react'; /** * UseCase hook for fetching all events (Admin/CMS view). + * Supports server-side pagination & search. */ export const useFetchAdminEvents = () => { const repository = useMemo(() => new EventRepository(), []); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; const fetchEvents = useCallback(async () => { - const response = await repository.apiClient.get(repository.endpointBase); - return response.data || []; - }, [repository]); + return await repository.getAll(page, pageSize, search); + }, [repository, page, search]); const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchEvents); @@ -20,9 +23,14 @@ export const useFetchAdminEvents = () => { }, [execute]); return { - events: returnedData || [], + events: returnedData?.items || [], + totalItems: returnedData?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((returnedData?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + setSearch, isLoading: inProgress, error, - fetch: fetchEvents + fetch: execute }; }; diff --git a/src/domain/useCase/useFetchAdminFaqs.js b/src/domain/useCase/useFetchAdminFaqs.js new file mode 100644 index 0000000..4e50bfc --- /dev/null +++ b/src/domain/useCase/useFetchAdminFaqs.js @@ -0,0 +1,50 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { FaqRepository } from '../../infrastructure/repository/FaqRepository'; +import { useMemo, useEffect, useCallback, useState } from 'react'; + +export const useFetchAdminFaqs = () => { + const repository = useMemo(() => new FaqRepository(), []); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; + + const fetchFaqs = useCallback(async () => { + return await repository.getAll(page, pageSize, search); + }, [repository, page, search]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchFaqs); + + useEffect(() => { + execute(); + }, [execute]); + + const createFaq = useCallback(async (data) => { + await repository.create(data); + await execute(); + }, [repository, execute]); + + const updateFaq = useCallback(async (id, data) => { + await repository.update(id, data); + await execute(); + }, [repository, execute]); + + const deleteFaq = useCallback(async (id) => { + await repository.deleteFaq(id); + await execute(); + }, [repository, execute]); + + return { + faqs: returnedData?.items || [], + totalItems: returnedData?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((returnedData?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + setSearch, + isLoading: inProgress, + error, + fetch: execute, + createFaq, + updateFaq, + deleteFaq + }; +}; diff --git a/src/domain/useCase/useFetchAdminHelpCenters.js b/src/domain/useCase/useFetchAdminHelpCenters.js new file mode 100644 index 0000000..35c3a89 --- /dev/null +++ b/src/domain/useCase/useFetchAdminHelpCenters.js @@ -0,0 +1,50 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { HelpCenterRepository } from '../../infrastructure/repository/HelpCenterRepository'; +import { useMemo, useEffect, useCallback, useState } from 'react'; + +export const useFetchAdminHelpCenters = () => { + const repository = useMemo(() => new HelpCenterRepository(), []); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; + + const fetchHelpCenters = useCallback(async () => { + return await repository.getAll(page, pageSize, search); + }, [repository, page, search]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchHelpCenters); + + useEffect(() => { + execute(); + }, [execute]); + + const createHelpCenter = useCallback(async (data) => { + await repository.create(data); + await execute(); + }, [repository, execute]); + + const updateHelpCenter = useCallback(async (id, data) => { + await repository.update(id, data); + await execute(); + }, [repository, execute]); + + const deleteHelpCenter = useCallback(async (id) => { + await repository.deleteHelpCenter(id); + await execute(); + }, [repository, execute]); + + return { + helpCenters: returnedData?.items || [], + totalItems: returnedData?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((returnedData?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + setSearch, + isLoading: inProgress, + error, + fetch: execute, + createHelpCenter, + updateHelpCenter, + deleteHelpCenter + }; +}; diff --git a/src/domain/useCase/useFetchAdminMedia.js b/src/domain/useCase/useFetchAdminMedia.js new file mode 100644 index 0000000..06ccfa9 --- /dev/null +++ b/src/domain/useCase/useFetchAdminMedia.js @@ -0,0 +1,36 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { MediaRepository } from '../../infrastructure/repository/MediaRepository'; +import { useMemo, useEffect, useCallback } from 'react'; +import { EntityMapper } from '../mapper/EntityMapper'; + +/** + * Hook for managing the Media Library in Admin CMS. + */ +export const useFetchAdminMedia = () => { + const repository = useMemo(() => new MediaRepository(), []); + + const fetchMediaFiles = useCallback(async () => { + const response = await repository.getMediaFiles(); + const rawItems = Array.isArray(response) ? response : response?.results || []; + return rawItems.map(item => EntityMapper.toMedia(item)); + }, [repository]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchMediaFiles); + + useEffect(() => { + execute(); + }, [execute]); + + const deleteMediaFile = useCallback(async (id) => { + await repository.deleteMediaFile(id); + await execute(); // Refresh list + }, [repository, execute]); + + return { + mediaFiles: returnedData || [], + isLoading: inProgress, + error, + reloadMedia: execute, + deleteMediaFile + }; +}; diff --git a/src/domain/useCase/useFetchAdminNotifications.js b/src/domain/useCase/useFetchAdminNotifications.js new file mode 100644 index 0000000..7872c58 --- /dev/null +++ b/src/domain/useCase/useFetchAdminNotifications.js @@ -0,0 +1,42 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { AdminNotificationRepository } from '../../infrastructure/repository/AdminNotificationRepository'; +import { useMemo, useEffect, useCallback, useState } from 'react'; + +/** + * UseCase hook for fetching admin notifications (CMS governance view). + */ +export const useFetchAdminNotifications = () => { + const repository = useMemo(() => new AdminNotificationRepository(), []); + const [statusFilter, setStatusFilter] = useState(null); + + const fetchNotifications = useCallback(async () => { + return await repository.getAll(statusFilter); + }, [repository, statusFilter]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchNotifications); + + useEffect(() => { + execute(); + }, [execute]); + + const markRead = useCallback(async (id) => { + await repository.markRead(id); + await execute(); + }, [repository, execute]); + + const updateStatus = useCallback(async (id, status) => { + await repository.updateStatus(id, status); + await execute(); + }, [repository, execute]); + + return { + notifications: returnedData || [], + isLoading: inProgress, + error, + fetch: execute, + markRead, + updateStatus, + statusFilter, + setStatusFilter + }; +}; diff --git a/src/domain/useCase/useFetchAdminProblems.js b/src/domain/useCase/useFetchAdminProblems.js index 386b74c..133b2df 100644 --- a/src/domain/useCase/useFetchAdminProblems.js +++ b/src/domain/useCase/useFetchAdminProblems.js @@ -1,20 +1,20 @@ import { useAsyncUseCase } from './useAsyncUseCase'; import { ProblemRepository } from '@infrastructure/repository/ProblemRepository'; -import { useMemo, useEffect, useCallback } from 'react'; +import { useMemo, useEffect, useCallback, useState } from 'react'; /** * UseCase hook for fetching all problems (Admin/CMS view). - * Automatically executes on mount. + * Supports server-side pagination & search. */ export const useFetchAdminProblems = () => { const repository = useMemo(() => new ProblemRepository(), []); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; const fetchLogic = useCallback(async () => { - // Fetch all problems for the CMS table - console.log(repository) - const data = await repository.getAll(); - return Array.isArray(data) ? data : []; - }, [repository]); + return await repository.getAll(page, pageSize, search); + }, [repository, page, search]); const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchLogic); @@ -23,7 +23,12 @@ export const useFetchAdminProblems = () => { }, [execute]); return { - problems: returnedData || [], + problems: returnedData?.items || [], + totalItems: returnedData?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((returnedData?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + setSearch, isLoading: inProgress, error, fetch: execute diff --git a/src/domain/useCase/useFetchAdminReports.js b/src/domain/useCase/useFetchAdminReports.js new file mode 100644 index 0000000..abd5628 --- /dev/null +++ b/src/domain/useCase/useFetchAdminReports.js @@ -0,0 +1,51 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { ReportRepository } from '../../infrastructure/repository/ReportRepository'; +import { useMemo, useEffect, useCallback, useState } from 'react'; + +/** + * UseCase hook for fetching and managing reports (Admin/CMS view). + * Supports server-side pagination & search. + */ +export const useFetchAdminReports = () => { + const repository = useMemo(() => new ReportRepository(), []); + const [statusFilter, setStatusFilter] = useState(null); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; + + const fetchReports = useCallback(async () => { + return await repository.getAll(statusFilter, page, pageSize, search); + }, [repository, statusFilter, page, search]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchReports); + + useEffect(() => { + execute(); + }, [execute]); + + const updateStatus = useCallback(async (id, status) => { + await repository.updateStatus(id, status); + await execute(); // Refresh list + }, [repository, execute]); + + const deleteReport = useCallback(async (id) => { + await repository.deleteReport(id); + await execute(); // Refresh list + }, [repository, execute]); + + return { + reports: returnedData?.items || [], + totalItems: returnedData?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((returnedData?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + setSearch, + isLoading: inProgress, + error, + fetch: execute, + updateStatus, + deleteReport, + statusFilter, + setStatusFilter + }; +}; diff --git a/src/domain/useCase/useFetchAdminRoadmaps.js b/src/domain/useCase/useFetchAdminRoadmaps.js new file mode 100644 index 0000000..07e90cb --- /dev/null +++ b/src/domain/useCase/useFetchAdminRoadmaps.js @@ -0,0 +1,46 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { RoadmapRepository } from '@infrastructure/repository/RoadmapRepository'; +import { RoadmapDTO } from '@infrastructure/DTO/RoadmapDTO'; +import { EntityMapper } from '@domain/mapper/EntityMapper'; +import { useMemo, useEffect, useCallback, useState } from 'react'; + +/** + * UseCase hook for fetching roadmaps in Admin CMS view. + * Supports server-side pagination & search. + */ +export const useFetchAdminRoadmaps = () => { + const repository = useMemo(() => new RoadmapRepository(), []); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; + + const fetchLogic = useCallback(async () => { + const result = await repository.getAll(page, pageSize, search); + const items = (result?.items || []) + .map(item => new RoadmapDTO(item)) + .map(dto => EntityMapper.toRoadmap(dto)) + .filter(Boolean); + return { + items, + meta: result?.meta || { pagination: { total: items.length } } + }; + }, [repository, page, search]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchLogic); + + useEffect(() => { + execute(); + }, [execute]); + + return { + roadmaps: returnedData?.items || [], + totalItems: returnedData?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((returnedData?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + setSearch, + isLoading: inProgress, + error, + fetch: execute + }; +}; diff --git a/src/domain/useCase/useFetchAdminTags.js b/src/domain/useCase/useFetchAdminTags.js new file mode 100644 index 0000000..0fc57ea --- /dev/null +++ b/src/domain/useCase/useFetchAdminTags.js @@ -0,0 +1,42 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { GlobalTagRepository } from '../../infrastructure/repository/GlobalTagRepository'; +import { useMemo, useEffect, useCallback, useState } from 'react'; + +/** + * UseCase hook for managing Global Tags in Admin CMS. + * Supports server-side pagination & search. + */ +export const useFetchAdminTags = () => { + const repository = useMemo(() => new GlobalTagRepository(), []); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; + + const fetchTags = useCallback(async () => { + return await repository.getAll(page, pageSize, search); + }, [repository, page, search]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchTags); + + useEffect(() => { + execute(); + }, [execute]); + + const deleteTag = useCallback(async (id) => { + await repository.deleteTag(id); + await execute(); // Refresh list + }, [repository, execute]); + + return { + tags: returnedData?.items || [], + totalItems: returnedData?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((returnedData?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + setSearch, + isLoading: inProgress, + error, + fetch: execute, + deleteTag + }; +}; diff --git a/src/domain/useCase/useFetchAdminUsers.js b/src/domain/useCase/useFetchAdminUsers.js new file mode 100644 index 0000000..ba122f1 --- /dev/null +++ b/src/domain/useCase/useFetchAdminUsers.js @@ -0,0 +1,35 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { UserRepository } from '../../infrastructure/repository/UserRepository'; +import { useMemo, useEffect, useCallback } from 'react'; +import { EntityMapper } from '../mapper/EntityMapper'; + +/** + * Hook for managing Users in Admin CMS. + */ +export const useFetchAdminUsers = () => { + const repository = useMemo(() => new UserRepository(), []); + + const fetchUsers = useCallback(async () => { + const rawUsers = await repository.getAllUsers(); + return rawUsers.map(user => EntityMapper.toUser(user)); + }, [repository]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchUsers); + + useEffect(() => { + execute(); + }, [execute]); + + const deleteUser = useCallback(async (id) => { + await repository.deleteUser(id); + await execute(); // Refresh list + }, [repository, execute]); + + return { + users: returnedData || [], + isLoading: inProgress, + error, + reloadUsers: execute, + deleteUser + }; +}; diff --git a/src/domain/useCase/useFetchCMSAnalytics.js b/src/domain/useCase/useFetchCMSAnalytics.js new file mode 100644 index 0000000..b22e58e --- /dev/null +++ b/src/domain/useCase/useFetchCMSAnalytics.js @@ -0,0 +1,57 @@ +import { useState, useCallback } from 'react'; +import { CourseRepository } from '@infrastructure/repository/CourseRepository'; +import { EventRepository } from '@infrastructure/repository/EventRepository'; +import { ReportRepository } from '@infrastructure/repository/ReportRepository'; +import { UserRepository } from '@infrastructure/repository/UserRepository'; + +/** + * UseCase: Fetches high-level metrics for the CMS Dashboard. + * Utilizes Promise.all to fetch metadata without heavily loading payload arrays. + */ +export const useFetchCMSAnalytics = () => { + const [stats, setStats] = useState({ + totalCourses: 0, + totalEvents: 0, + pendingReports: 0, + totalUsers: 0 + }); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchAnalytics = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const courseRepo = new CourseRepository(); + const eventRepo = new EventRepository(); + const reportRepo = new ReportRepository(); + const userRepo = new UserRepository(); + + const [coursesRes, eventsRes, reportsRes, usersRes] = await Promise.all([ + courseRepo.getAll(null, 1, 1), // passing size 1 just to get meta.total + eventRepo.getAll(1, 1), + reportRepo.getAll(1, 1, '', 'PENDING'), + userRepo.getAllUsers() + ]); + + setStats({ + totalCourses: coursesRes?.meta?.pagination?.total || 0, + totalEvents: eventsRes?.meta?.pagination?.total || 0, + pendingReports: reportsRes?.meta?.pagination?.total || 0, + totalUsers: Array.isArray(usersRes) ? usersRes.length : 0 + }); + } catch (err) { + console.error('[CMS Analytics] Fetch failed:', err); + setError(err.message || 'Failed to load analytics'); + } finally { + setIsLoading(false); + } + }, []); + + return { + stats, + isLoading, + error, + fetchAnalytics + }; +}; diff --git a/src/domain/useCase/useFetchCategorizations.js b/src/domain/useCase/useFetchCategorizations.js index 7c6b603..e63360c 100644 --- a/src/domain/useCase/useFetchCategorizations.js +++ b/src/domain/useCase/useFetchCategorizations.js @@ -8,12 +8,12 @@ import { useMemo, useEffect } from 'react'; export const useFetchCategorizations = () => { const repository = useMemo(() => new CategorizationRepository(), []); - const { execute: fetchCourseTypes, returnedData: courseTypes, inProgress: isLoadingCourseTypes } = useAsyncUseCase( - () => repository.getCourseTypes() + const { execute: fetchCourseTypes, returnedData: courseTypesData, inProgress: isLoadingCourseTypes } = useAsyncUseCase( + () => repository.getCourseTypes(1, 100) ); - const { execute: fetchProblemTypes, returnedData: problemTypes, inProgress: isLoadingProblemTypes } = useAsyncUseCase( - () => repository.getProblemTypes() + const { execute: fetchProblemTypes, returnedData: problemTypesData, inProgress: isLoadingProblemTypes } = useAsyncUseCase( + () => repository.getProblemTypes(1, 100) ); // Fetch on mount @@ -24,8 +24,8 @@ export const useFetchCategorizations = () => { }, []); return { - courseTypes: courseTypes || [], - problemTypes: problemTypes || [], + courseTypes: courseTypesData?.items || [], + problemTypes: problemTypesData?.items || [], isLoading: isLoadingCourseTypes || isLoadingProblemTypes }; }; diff --git a/src/domain/useCase/useFetchProblems.js b/src/domain/useCase/useFetchProblems.js index aacd61f..6000c8d 100644 --- a/src/domain/useCase/useFetchProblems.js +++ b/src/domain/useCase/useFetchProblems.js @@ -10,10 +10,9 @@ import { EntityMapper } from '@domain/mapper/EntityMapper'; export const useFetchProblems = () => { const repository = useMemo(() => new ProblemRepository(), []); - const fetchLogic = useCallback(async (params = {}) => { - console.log("repo", repository); - const data = await repository.getAll(params); - const items = Array.isArray(data) ? data : []; + const fetchLogic = useCallback(async () => { + const result = await repository.getAll(1, 100); // Fetch all for public view + const items = Array.isArray(result?.items) ? result.items : []; return items .map(item => new ProblemDTO(item)) .map(dto => EntityMapper.toCardProblem(dto)) diff --git a/src/domain/useCase/useFetchRoadmaps.js b/src/domain/useCase/useFetchRoadmaps.js index 3cdb829..f90ff3c 100644 --- a/src/domain/useCase/useFetchRoadmaps.js +++ b/src/domain/useCase/useFetchRoadmaps.js @@ -13,8 +13,8 @@ export const useFetchRoadmaps = () => { const repository = new RoadmapRepository(); const fetchLogic = useCallback(async () => { - const rawData = await repository.getAll(); - const items = Array.isArray(rawData) ? rawData : []; + const result = await repository.getAll(1, 100); // Fetch all for public view + const items = Array.isArray(result?.items) ? result.items : []; return items .map(item => new RoadmapDTO(item)) .map(dto => EntityMapper.toRoadmap(dto)) diff --git a/src/infrastructure/repository/AdminNotificationRepository.js b/src/infrastructure/repository/AdminNotificationRepository.js new file mode 100644 index 0000000..c2991fd --- /dev/null +++ b/src/infrastructure/repository/AdminNotificationRepository.js @@ -0,0 +1,42 @@ +import { BaseRepository } from './BaseRepository'; + +/** + * AdminNotificationRepository: Handles admin notification consumption. + */ +export class AdminNotificationRepository extends BaseRepository { + constructor() { + super(); + this.endpoint = '/api/admin-notifications'; + } + + /** + * Fetches all admin notifications. + * @param {string} status - Optional filter: 'PENDING', 'RESOLVED' + * @returns {Promise} + */ + async getAll(status = null) { + let query = `populate[0]=actor.avatar&sort=createdAt:desc`; + if (status) { + query += `&filters[status][$eq]=${status}`; + } + const response = await this.get(`${this.endpoint}?${query}`); + return response?.data || response || []; + } + + /** + * Marks an admin notification as read. + * @param {string} id + */ + async markRead(id) { + return await this.put(this.endpoint, id, { read: true }); + } + + /** + * Updates the status of an admin notification. + * @param {string} id + * @param {string} status - 'PENDING' or 'RESOLVED' + */ + async updateStatus(id, status) { + return await this.put(this.endpoint, id, { status }); + } +} diff --git a/src/infrastructure/repository/ArticleRepository.js b/src/infrastructure/repository/ArticleRepository.js index f87891c..cfd912e 100644 --- a/src/infrastructure/repository/ArticleRepository.js +++ b/src/infrastructure/repository/ArticleRepository.js @@ -33,6 +33,23 @@ export class ArticleRepository extends IContentInteraction { async comment(contentId, contentType, commentData) { } async trackEngagement(contentId) { } + /** + * Fetches all articles for admin/CMS view. + * @returns {Promise} + */ + async getAll() { + const response = await this.apiClient.get(`${this.endpoint}?populate[0]=author.avatar&sort=createdAt:desc`); + return response?.data || response || []; + } + + /** + * Deletes an article by its documentId. + * @param {string} id + */ + async delete(id) { + return await this.apiClient.delete(`${this.endpoint}/${id}`); + } + /** * Lists articles authored by a specific user with pagination support. * @param {string} username diff --git a/src/infrastructure/repository/BlogRepository.js b/src/infrastructure/repository/BlogRepository.js index d3f1052..e1f1259 100644 --- a/src/infrastructure/repository/BlogRepository.js +++ b/src/infrastructure/repository/BlogRepository.js @@ -27,6 +27,23 @@ export class BlogRepository extends IContentInteraction { async comment(contentId, contentType, commentData) {} async trackEngagement(contentId) {} + /** + * Fetches all blogs for admin/CMS view. + * @returns {Promise} + */ + async getAll() { + const response = await this.apiClient.get(`${this.endpoint}?populate[0]=publisher.avatar&populate[1]=image&sort=createdAt:desc`); + return response?.data || response || []; + } + + /** + * Deletes a blog by its documentId. + * @param {string} id + */ + async delete(id) { + return await this.apiClient.delete(`${this.endpoint}/${id}`); + } + /** * Lists blogs authored by a specific user with pagination support. * @param {string} username diff --git a/src/infrastructure/repository/CategorizationRepository.js b/src/infrastructure/repository/CategorizationRepository.js index d4df89d..320471a 100644 --- a/src/infrastructure/repository/CategorizationRepository.js +++ b/src/infrastructure/repository/CategorizationRepository.js @@ -8,17 +8,35 @@ export class CategorizationRepository extends BaseRepository { this.apiClient = apiClient; } - async getCourseTypes() { + async getCourseTypes(page = 1, pageSize = 10, search = '') { const endpoint = import.meta.env.VITE_API_COURSE_TYPES; - const response = await this.apiClient.get(endpoint); + const filters = search ? `&filters[title][$containsi]=${encodeURIComponent(search)}` : ''; + const response = await this.apiClient.get(`${endpoint}?pagination[page]=${page}&pagination[pageSize]=${pageSize}&sort=createdAt:desc${filters}`); const data = response?.data || []; - return CategorizationResponse.fromArray(data); + return { + items: CategorizationResponse.fromArray(data), + meta: response?.meta || { pagination: { total: data.length } } + }; } - async getProblemTypes() { + async getProblemTypes(page = 1, pageSize = 10, search = '') { const endpoint = import.meta.env.VITE_API_PROBLEM_TYPES; - const response = await this.apiClient.get(endpoint); + const filters = search ? `&filters[title][$containsi]=${encodeURIComponent(search)}` : ''; + const response = await this.apiClient.get(`${endpoint}?pagination[page]=${page}&pagination[pageSize]=${pageSize}&sort=createdAt:desc${filters}`); const data = response?.data || []; - return CategorizationResponse.fromArray(data); + return { + items: CategorizationResponse.fromArray(data), + meta: response?.meta || { pagination: { total: data.length } } + }; + } + + async deleteCourseType(id) { + const endpoint = `${import.meta.env.VITE_API_COURSE_TYPES}/${id}`; + return await this.delete(endpoint); + } + + async deleteProblemType(id) { + const endpoint = `${import.meta.env.VITE_API_PROBLEM_TYPES}/${id}`; + return await this.delete(endpoint); } } diff --git a/src/infrastructure/repository/CommentAdminRepository.js b/src/infrastructure/repository/CommentAdminRepository.js new file mode 100644 index 0000000..80489c1 --- /dev/null +++ b/src/infrastructure/repository/CommentAdminRepository.js @@ -0,0 +1,29 @@ +import { BaseRepository } from './BaseRepository'; + +/** + * CommentAdminRepository: Handles admin moderation of comments. + */ +export class CommentAdminRepository extends BaseRepository { + constructor() { + super(); + this.endpoint = '/api/comments'; + } + + /** + * Fetches all comments for admin moderation. + * @returns {Promise} + */ + async getAll() { + const query = `populate[0]=users_permissions_user.avatar&sort=createdAt:desc`; + const response = await this.get(`${this.endpoint}?${query}`); + return response?.data || response || []; + } + + /** + * Deletes a comment (moderation action). + * @param {string} id + */ + async deleteComment(id) { + return await this.delete(`${this.endpoint}/${id}`); + } +} diff --git a/src/infrastructure/repository/CourseRepository.js b/src/infrastructure/repository/CourseRepository.js index 49bdfb3..ae11e7e 100644 --- a/src/infrastructure/repository/CourseRepository.js +++ b/src/infrastructure/repository/CourseRepository.js @@ -34,22 +34,37 @@ export class CourseRepository extends IContentInteraction { /** * Fetches all courses for the CMS view. */ - async getAll(userId = null) { - let endpoint = `${this.endpoint}?populate=*`; + async getAll(userId = null, page = 1, pageSize = 10, search = '') { + const filters = []; if (userId) { - endpoint += `&filters[users_permissions_user][id][$eq]=${userId}`; + filters.push(`filters[users_permissions_user][id][$eq]=${userId}`); + } + if (search) { + filters.push(`filters[title][$containsi]=${encodeURIComponent(search)}`); } - const response = await this.apiClient.get(endpoint); - // Strapi v4/v5 data extraction - const dataArray = response?.data || response || []; - if (!Array.isArray(dataArray)) return []; + const filterStr = filters.length > 0 ? `&${filters.join('&')}` : ''; + const endpoint = `${this.endpoint}?populate=*&pagination[page]=${page}&pagination[pageSize]=${pageSize}${filterStr}&sort=createdAt:desc`; + + try { + const response = await this.apiClient.get(endpoint); + + const dataArray = response?.data || response || []; + + if (!Array.isArray(dataArray)) { + return { items: [], meta: { pagination: { total: 0 } } }; + } - // Enforce strict Domain Mapping to prevent React DevTools prototype crashes - return dataArray.map(item => { - const dto = new CourseDTO(item); - return EntityMapper.toCardCourse(dto); - }); + const items = dataArray.map(item => { + const dto = new CourseDTO(item); + return EntityMapper.toCardCourse(dto); + }); + + return { items, meta: response?.meta || { pagination: { total: items.length } } }; + } catch (error) { + console.error('[CourseRepository] getAll failed:', error); + return { items: [], meta: { pagination: { total: 0 } } }; + } } /** @@ -113,6 +128,15 @@ export class CourseRepository extends IContentInteraction { * @param {string} query * @returns {Promise} */ + /** + * Deletes a course by its documentId. + * @param {string} id + * @returns {Promise} + */ + async delete(id) { + return await this.apiClient.delete(`${this.endpoint}/${id}`); + } + async search(query) { if (!query) return []; try { diff --git a/src/infrastructure/repository/EventRepository.js b/src/infrastructure/repository/EventRepository.js index 3c5fe79..a45d35a 100644 --- a/src/infrastructure/repository/EventRepository.js +++ b/src/infrastructure/repository/EventRepository.js @@ -23,8 +23,15 @@ export class EventRepository extends IEventInteraction { console.log(`${this.endpointBase}/${id}?populate=*`) return await this.apiClient.get(`${this.endpointBase}/${id}?populate=*`); } - async getAll() { - return await this.apiClient.get(`${this.endpointBase}?populate=*`); + async getAll(page = 1, pageSize = 10, search = '') { + const filters = search ? `&filters[title][$containsi]=${encodeURIComponent(search)}` : ''; + const endpoint = `${this.endpointBase}?populate=*&pagination[page]=${page}&pagination[pageSize]=${pageSize}&sort=createdAt:desc${filters}`; + const response = await this.apiClient.get(endpoint); + const dataArray = response?.data || response || []; + return { + items: Array.isArray(dataArray) ? dataArray : [], + meta: response?.meta || { pagination: { total: Array.isArray(dataArray) ? dataArray.length : 0 } } + }; } async update(id, data) { diff --git a/src/infrastructure/repository/FaqRepository.js b/src/infrastructure/repository/FaqRepository.js new file mode 100644 index 0000000..1a4e9e5 --- /dev/null +++ b/src/infrastructure/repository/FaqRepository.js @@ -0,0 +1,33 @@ +import { BaseRepository } from './BaseRepository'; + +/** + * FaqRepository: Handles FAQ management. + */ +export class FaqRepository extends BaseRepository { + constructor() { + super(); + this.endpoint = '/api/faqs'; + } + + async getAll(page = 1, pageSize = 10, search = '') { + const filters = search ? `&filters[question][$containsi]=${encodeURIComponent(search)}` : ''; + const response = await this.get(`${this.endpoint}?sort=createdAt:desc&pagination[page]=${page}&pagination[pageSize]=${pageSize}${filters}`); + const dataArray = response?.data || response || []; + return { + items: Array.isArray(dataArray) ? dataArray : [], + meta: response?.meta || { pagination: { total: Array.isArray(dataArray) ? dataArray.length : 0 } } + }; + } + + async create(data) { + return await this.post(this.endpoint, { data }); + } + + async update(id, data) { + return await this.put(this.endpoint, id, { data }); + } + + async deleteFaq(id) { + return await this.delete(`${this.endpoint}/${id}`); + } +} diff --git a/src/infrastructure/repository/GlobalTagRepository.js b/src/infrastructure/repository/GlobalTagRepository.js new file mode 100644 index 0000000..0c9dcb4 --- /dev/null +++ b/src/infrastructure/repository/GlobalTagRepository.js @@ -0,0 +1,33 @@ +import { BaseRepository } from './BaseRepository'; + +/** + * GlobalTagRepository: Handles admin management of global tags. + */ +export class GlobalTagRepository extends BaseRepository { + constructor() { + super(); + this.endpoint = '/api/global-tags'; + } + + /** + * Fetches all global tags. + * @returns {Promise} + */ + async getAll(page = 1, pageSize = 10, search = '') { + const filters = search ? `&filters[name][$containsi]=${encodeURIComponent(search)}` : ''; + const response = await this.get(`${this.endpoint}?sort=count:desc&pagination[page]=${page}&pagination[pageSize]=${pageSize}${filters}`); + const dataArray = response?.data || response || []; + return { + items: Array.isArray(dataArray) ? dataArray : [], + meta: response?.meta || { pagination: { total: Array.isArray(dataArray) ? dataArray.length : 0 } } + }; + } + + /** + * Deletes a global tag. + * @param {string} id + */ + async deleteTag(id) { + return await this.delete(`${this.endpoint}/${id}`); + } +} diff --git a/src/infrastructure/repository/HelpCenterRepository.js b/src/infrastructure/repository/HelpCenterRepository.js new file mode 100644 index 0000000..408410d --- /dev/null +++ b/src/infrastructure/repository/HelpCenterRepository.js @@ -0,0 +1,33 @@ +import { BaseRepository } from './BaseRepository'; + +/** + * HelpCenterRepository: Handles Help Center content management. + */ +export class HelpCenterRepository extends BaseRepository { + constructor() { + super(); + this.endpoint = '/api/help-centers'; + } + + async getAll(page = 1, pageSize = 10, search = '') { + const filters = search ? `&filters[title][$containsi]=${encodeURIComponent(search)}` : ''; + const response = await this.get(`${this.endpoint}?sort=createdAt:desc&pagination[page]=${page}&pagination[pageSize]=${pageSize}${filters}`); + const dataArray = response?.data || response || []; + return { + items: Array.isArray(dataArray) ? dataArray : [], + meta: response?.meta || { pagination: { total: Array.isArray(dataArray) ? dataArray.length : 0 } } + }; + } + + async create(data) { + return await this.post(this.endpoint, { data }); + } + + async update(id, data) { + return await this.put(this.endpoint, id, { data }); + } + + async deleteHelpCenter(id) { + return await this.delete(`${this.endpoint}/${id}`); + } +} diff --git a/src/infrastructure/repository/MediaRepository.js b/src/infrastructure/repository/MediaRepository.js index 0ffbe33..aa71421 100644 --- a/src/infrastructure/repository/MediaRepository.js +++ b/src/infrastructure/repository/MediaRepository.js @@ -39,6 +39,21 @@ export class MediaRepository { return results.map(media => media.id); } + /** + * Gets all uploaded media files. + * Strapi endpoint for files is usually /api/upload/files. + */ + async getMediaFiles() { + return await this.apiClient.get('/api/upload/files?sort=createdAt:desc'); + } + + /** + * Deletes a specific media file by ID. + */ + async deleteMediaFile(id) { + return await this.apiClient.delete(`/api/upload/files/${id}`); + } + /** * Internal validation for media files. * @private diff --git a/src/infrastructure/repository/ProblemRepository.js b/src/infrastructure/repository/ProblemRepository.js index 5a54e64..74986d9 100644 --- a/src/infrastructure/repository/ProblemRepository.js +++ b/src/infrastructure/repository/ProblemRepository.js @@ -13,18 +13,26 @@ export class ProblemRepository extends BaseRepository { /** * Fetches admin-owned problems or all problems if authorized. */ - async getAll() { + async getAll(page = 1, pageSize = 10, search = '') { try { - const query = qs.stringify({ + const queryObj = { populate: ['problem_types'], - sort: ['createdAt:desc'] - }, { encodeValuesOnly: true }); + sort: ['createdAt:desc'], + pagination: { page, pageSize } + }; + if (search) { + queryObj.filters = { title: { $containsi: search } }; + } + const query = qs.stringify(queryObj, { encodeValuesOnly: true }); const response = await this.get(`${this.endpoint}?${query}`); - return response.data || []; + return { + items: response?.data || [], + meta: response?.meta || { pagination: { total: 0 } } + }; } catch (error) { console.error('[ProblemRepository] Fetch admin failed:', error); - throw error; + return { items: [], meta: { pagination: { total: 0 } } }; } } diff --git a/src/infrastructure/repository/ReportRepository.js b/src/infrastructure/repository/ReportRepository.js new file mode 100644 index 0000000..c1efa39 --- /dev/null +++ b/src/infrastructure/repository/ReportRepository.js @@ -0,0 +1,49 @@ +import { BaseRepository } from './BaseRepository'; + +/** + * ReportRepository: Handles admin review of user reports. + */ +export class ReportRepository extends BaseRepository { + constructor() { + super(); + this.endpoint = '/api/reports'; + } + + /** + * Fetches all reports for admin review. + * @param {string} status - Optional filter: 'pending', 'resolved', 'dismissed' + * @returns {Promise} + */ + async getAll(status = null, page = 1, pageSize = 10, search = '') { + let query = `populate[0]=reporter_user.avatar&populate[1]=reported_user.avatar&populate[2]=report_types&sort=createdAt:desc&pagination[page]=${page}&pagination[pageSize]=${pageSize}`; + if (status) { + query += `&filters[review_status][$eq]=${status}`; + } + if (search) { + query += `&filters[content_type][$containsi]=${encodeURIComponent(search)}`; + } + const response = await this.get(`${this.endpoint}?${query}`); + const dataArray = response?.data || response || []; + return { + items: Array.isArray(dataArray) ? dataArray : [], + meta: response?.meta || { pagination: { total: Array.isArray(dataArray) ? dataArray.length : 0 } } + }; + } + + /** + * Updates the review status of a report. + * @param {string} id + * @param {string} status - 'pending', 'resolved', 'dismissed' + */ + async updateStatus(id, status) { + return await this.put(this.endpoint, id, { review_status: status }); + } + + /** + * Deletes a report entry. + * @param {string} id + */ + async deleteReport(id) { + return await this.delete(`${this.endpoint}/${id}`); + } +} diff --git a/src/infrastructure/repository/RoadmapRepository.js b/src/infrastructure/repository/RoadmapRepository.js index 055daf1..3f6a0f0 100644 --- a/src/infrastructure/repository/RoadmapRepository.js +++ b/src/infrastructure/repository/RoadmapRepository.js @@ -32,8 +32,13 @@ export class RoadmapRepository extends IRoadmapInteraction { * @param {object} [params] - Optional query params (filters, pagination, etc.) * @returns {Promise} Raw roadmap data array. */ - async getAll(params = {}) { - const response = await this.apiClient.get(this.endpoint, params); - return response?.data || response || []; + async getAll(page = 1, pageSize = 10, search = '') { + const filters = search ? `&filters[title][$containsi]=${encodeURIComponent(search)}` : ''; + const response = await this.apiClient.get(`${this.endpoint}?populate=*&pagination[page]=${page}&pagination[pageSize]=${pageSize}&sort=createdAt:desc${filters}`); + const dataArray = response?.data || response || []; + return { + items: Array.isArray(dataArray) ? dataArray : [], + meta: response?.meta || { pagination: { total: Array.isArray(dataArray) ? dataArray.length : 0 } } + }; } } diff --git a/src/infrastructure/repository/UserRepository.js b/src/infrastructure/repository/UserRepository.js index 0b41410..93f16d6 100644 --- a/src/infrastructure/repository/UserRepository.js +++ b/src/infrastructure/repository/UserRepository.js @@ -131,4 +131,32 @@ export class UserRepository extends BaseRepository { throw error; } } + + /** + * Gets all users (Admin use case). + * @returns {Promise} + */ + async getAllUsers() { + try { + const params = { populate: 'role' }; + const response = await this.get(this.endpoint, params); + return Array.isArray(response) ? response : (response?.data || []); + } catch (error) { + console.error('[UserRepository] Get all users failed:', error); + throw error; + } + } + + /** + * Deletes a user by ID (Admin use case). + * @param {string|number} userId + */ + async deleteUser(userId) { + try { + return await this.delete(`${this.endpoint}/${userId}`); + } catch (error) { + console.error('[UserRepository] Delete user failed:', error); + throw error; + } + } } diff --git a/src/presentation/feature/cms/components/CMSMobileNav.jsx b/src/presentation/feature/cms/components/CMSMobileNav.jsx new file mode 100644 index 0000000..2db1183 --- /dev/null +++ b/src/presentation/feature/cms/components/CMSMobileNav.jsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { PATHS } from '@presentation/routes/paths'; +import { cn } from '@core/utils/cn'; +import { ChevronDown, X } from 'lucide-react'; + +/** + * CMSMobileNav: A responsive dropdown navigation for tablets and mobile devices. + * Shown when the main left CMSSidebar is hidden (< lg breakpoint). + */ +export const CMSMobileNav = ({ sections, activeSection }) => { + const [isOpen, setIsOpen] = useState(false); + + const toggleMenu = () => setIsOpen(!isOpen); + + // Find current section data to display its name + const currentSection = sections.find(s => s.name === activeSection) || sections[0]; + + return ( +
+ {/* Dropdown Toggle Button */} + + + {/* Dropdown Content */} + {isOpen && ( + <> + {/* Backdrop for closing when clicking outside */} +
setIsOpen(false)} + /> + +
+
+ {sections.map((section) => ( + setIsOpen(false)} + className={cn( + "w-full flex items-center justify-between py-3 px-4 transition-all duration-200 rounded-xl group", + activeSection === section.name + ? "bg-near-black text-ivory shadow-md" + : "text-text-muted hover:bg-surface-sunken hover:text-text-primary" + )} + > +
+ + + {section.name} + +
+ + {section.count} + + + ))} +
+
+ + )} +
+ ); +}; diff --git a/src/presentation/feature/cms/components/CMSResourceTable.jsx b/src/presentation/feature/cms/components/CMSResourceTable.jsx index 532be99..94d2a16 100644 --- a/src/presentation/feature/cms/components/CMSResourceTable.jsx +++ b/src/presentation/feature/cms/components/CMSResourceTable.jsx @@ -5,21 +5,44 @@ import { useDeleteEvent } from '@domain/useCase/useDeleteEvent'; import { useDeleteProblem } from '@domain/useCase/useDeleteProblem'; import { useDeleteRoadmap } from '@domain/useCase/useDeleteRoadmap'; import { useDeleteReportType } from '@domain/useCase/useDeleteReportType'; -import { Activity, Calendar, ChevronRight, Database, Edit2, Layout, Plus, Settings, ShieldCheck, Trash2, RefreshCw, Loader2 } from 'lucide-react'; +import { useDeleteCourse } from '@domain/useCase/useDeleteCourse'; +import { Activity, Calendar, ChevronRight, ChevronLeft, Database, Edit2, Layout, Plus, Settings, ShieldCheck, Trash2, RefreshCw, Loader2, Eye, EyeOff, Search, CheckSquare, Square } from 'lucide-react'; import { cn } from '@core/utils/cn'; /** * CMSResourceTable: Reusable premium table for management resources. - * Supports Light/Dark modes with semantic tokens. + * Supports delete for all content types + publish/unpublish toggle. */ -export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, onRefresh }) => { +export const CMSResourceTable = ({ + sectionName, + items, + isLoading, + icon: Icon, + onRefresh, + onDelete, + columns, + // Server-Side support + serverPagination = false, + serverPage = 1, + serverTotalPages = 1, + serverTotalItems = 0, + onPageChange = null, + onSearchChange = null +}) => { const [openDropdownId, setOpenDropdownId] = useState(null); const dropdownRef = useRef(null); const { deleteEvent, inProgress: isDeletingEvent } = useDeleteEvent(); const { deleteProblem, inProgress: isDeletingProblem } = useDeleteProblem(); const { deleteRoadmap, inProgress: isDeletingRoadmap } = useDeleteRoadmap(); const { deleteReportType, inProgress: isDeletingReportType } = useDeleteReportType(); - const isDeleting = isDeletingEvent || isDeletingProblem || isDeletingRoadmap || isDeletingReportType; + const { deleteCourse, inProgress: isDeletingCourse } = useDeleteCourse(); + const isDeleting = isDeletingEvent || isDeletingProblem || isDeletingRoadmap || isDeletingReportType || isDeletingCourse; + + // Search, Pagination, and Selection state + const [searchQuery, setSearchQuery] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIds, setSelectedIds] = useState([]); + const itemsPerPage = 10; // Close dropdown on outside click useEffect(() => { @@ -34,7 +57,6 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on const getCreateRoute = () => { if (sectionName === 'Courses') return PATHS.COURSE_CREATE; - if (sectionName === 'Articles') return `${PATHS.ARTICLES}/write`; if (sectionName === 'Events') return PATHS.EVENT_CREATE; if (sectionName === 'Problems') return PATHS.PROBLEM_CREATE; if (sectionName === 'Roadmaps') return `${PATHS.CONTENT_MANAGEMENT}/roadmaps/create`; @@ -45,15 +67,13 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on const handleRemove = async (id, title) => { if (window.confirm(`Are you sure you want to permanently remove "${title}"? This action cannot be undone.`)) { try { - if (sectionName === 'Events') { - await deleteEvent(id); - } else if (sectionName === 'Problems') { - await deleteProblem(id); - } else if (sectionName === 'Roadmaps') { - await deleteRoadmap(id); - } else if (sectionName === 'Report-Reasons') { - await deleteReportType(id); - } + if (sectionName === 'Events') await deleteEvent(id); + else if (sectionName === 'Problems') await deleteProblem(id); + else if (sectionName === 'Roadmaps') await deleteRoadmap(id); + else if (sectionName === 'Report-Reasons') await deleteReportType(id); + else if (sectionName === 'Courses') await deleteCourse(id); + else if (onDelete) await onDelete(id); // Generic delete handler for Global-Tags, FAQs, etc. + if (onRefresh) onRefresh(); } catch (err) { console.error("Removal failed:", err); @@ -61,6 +81,79 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on } }; + const handleBulkRemove = async () => { + if (selectedIds.length === 0) return; + if (window.confirm(`Are you sure you want to permanently remove ${selectedIds.length} select entries?`)) { + try { + for (const id of selectedIds) { + if (sectionName === 'Events') await deleteEvent(id); + else if (sectionName === 'Problems') await deleteProblem(id); + else if (sectionName === 'Roadmaps') await deleteRoadmap(id); + else if (sectionName === 'Report-Reasons') await deleteReportType(id); + else if (sectionName === 'Courses') await deleteCourse(id); + else if (onDelete) await onDelete(id); + } + if (onRefresh) onRefresh(); + setSelectedIds([]); + } catch (err) { + console.error("Bulk removal failed:", err); + } + } + }; + + const toggleSelection = (id) => { + setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]); + }; + + const toggleAll = (visibleItems) => { + if (selectedIds.length === visibleItems.length) { + setSelectedIds([]); + } else { + setSelectedIds(visibleItems.map(item => String(item?.uid || item?.documentId || item?.id))); + } + }; + + // Client/Server-side transformations + const safeItems = Array.isArray(items) ? items : []; + + let filteredItems, paginatedItems, totalPages; + + if (serverPagination) { + filteredItems = safeItems; + paginatedItems = safeItems; + totalPages = serverTotalPages; + } else { + filteredItems = safeItems.filter(item => { + if (!searchQuery) return true; + const title = item?.title || item?.name || item?.username || item?.type || ''; + return title.toLowerCase().includes(searchQuery.toLowerCase()); + }); + totalPages = Math.max(1, Math.ceil(filteredItems.length / itemsPerPage)); + paginatedItems = filteredItems.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); + } + + const activePage = serverPagination ? serverPage : currentPage; + + // Reset page if out of bounds due to search + useEffect(() => { + if (!serverPagination && currentPage > totalPages) setCurrentPage(1); + }, [totalPages, currentPage, serverPagination]); + + // Clear selection on page change or data change + useEffect(() => { + setSelectedIds([]); + }, [activePage, items]); + + // Debounced search trigger for server-side + useEffect(() => { + if (serverPagination && onSearchChange) { + const timeoutId = setTimeout(() => { + onSearchChange(searchQuery); + }, 500); // 500ms debounce + return () => clearTimeout(timeoutId); + } + }, [searchQuery, serverPagination, onSearchChange]); + return (
{/* Scholarly Header Section */} @@ -80,17 +173,29 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on
-
+
+
+
+ +
+ setSearchQuery(e.target.value)} + className="w-full h-14 pl-12 pr-4 bg-surface-sunken border border-border-default rounded-2xl text-[13px] font-medium font-serif tracking-wide placeholder:text-text-muted focus:outline-none focus:border-near-black transition-colors" + /> +
New {sectionName.slice(0, -1)} Entry @@ -101,11 +206,19 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on {/* Repository Table Area */}
{/* Sticky Ledger Head */} -
- Manuscript Descriptor +
+
+ + Manuscript Descriptor +
- Shelf Status - Reader Engagement + {columns?.status || 'Shelf Status'} + {columns?.metric || 'Engagement'} Edit
@@ -117,32 +230,41 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on

Restoring Library Indices...

- ) : (!Array.isArray(items) || items.length === 0) ? ( + ) : paginatedItems.length === 0 ? (

Archive Empty

-

Initialize your first {sectionName.slice(0, -1).toLowerCase()} entry above.

+

Initialize your first {sectionName.slice(0, -1).toLowerCase()} entry or modify search.

- ) : items.map((item, idx) => { + ) : paginatedItems.map((item, idx) => { const rawId = item?.uid || item?.documentId || item?.id; const id = typeof rawId === 'object' || !rawId ? `fallback-id-${idx}` : String(rawId); - const title = item?.type || item?.title || `Untitled ${sectionName.slice(0, -1)} (${id})`; + const title = item?.title || item?.name || item?.username || item?.type || `Untitled ${sectionName.slice(0, -1)} (${id})`; - const isActive = item?.publishedAt != null; - const statusLabel = isActive ? 'Archived' : 'Drafted'; - const statusStyles = isActive + const isActive = item?.isActive !== undefined ? item.isActive : (item?.publishedAt != null); + const statusLabel = item?.statusLabel ?? (isActive ? 'Published' : 'Draft'); + const statusStyles = item?.statusStyles ?? (isActive ? 'bg-near-black text-ivory border-near-black' - : 'bg-surface-sunken text-text-muted border-border-default'; + : 'bg-surface-sunken text-text-muted border-border-default'); + + // Use real engagement_score from backend if available + const engagementScore = item?.metricValue ?? item?.engagement_score ?? item?.engagementScore ?? null; const dateStr = item?.createdAt ? new Date(item.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : 'Date Unknown'; return ( -
-
+
+
+
@@ -155,7 +277,7 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on
{statusLabel} @@ -164,7 +286,9 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on
- {(Math.random() * 50).toFixed(1)}k+ + + {engagementScore !== null ? `${engagementScore}` : 'โ€”'} +
@@ -183,7 +307,7 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on - {/* Dropdown Menu - Unified & Premium */} + {/* Dropdown Menu */} {openDropdownId === id && (
@@ -206,12 +330,21 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on Examine Manuscript + {/* Publish/Unpublish Status Indicator */} +
+ {isActive ? : } + {statusLabel} +
+ +
+
@@ -223,6 +356,52 @@ export const CMSResourceTable = ({ sectionName, items, isLoading, icon: Icon, on })}
+ + {/* Pagination & Bulk Operations Bar */} +
+
+ {serverPagination ? ( + Showing {paginatedItems.length} items (Total: {serverTotalItems} entries) + ) : ( + Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, filteredItems.length)} of {filteredItems.length} entries + )} +
+ + {selectedIds.length > 0 && ( +
+ {selectedIds.length} selected +
+ +
+ )} + +
+ +
+ {activePage} / {totalPages} +
+ +
+
); }; diff --git a/src/presentation/feature/cms/components/CMSSidebar.jsx b/src/presentation/feature/cms/components/CMSSidebar.jsx index 2a9da1e..5a7fbc2 100644 --- a/src/presentation/feature/cms/components/CMSSidebar.jsx +++ b/src/presentation/feature/cms/components/CMSSidebar.jsx @@ -9,7 +9,7 @@ import { cn } from '@core/utils/cn'; */ export const CMSSidebar = ({ sections, activeSection }) => { return ( -
+

diff --git a/src/presentation/feature/cms/layout/CMSLayout.jsx b/src/presentation/feature/cms/layout/CMSLayout.jsx index b686c8d..4cafab1 100644 --- a/src/presentation/feature/cms/layout/CMSLayout.jsx +++ b/src/presentation/feature/cms/layout/CMSLayout.jsx @@ -7,24 +7,48 @@ import { Calendar, Image, Map, - Flag + Flag, + PenTool, + ShieldAlert, + Bell, + MessageSquare, + Tag, + HelpCircle, + BookMarked, + Layers, + Box, + Users, + LayoutDashboard } from 'lucide-react'; import { CMSSidebar } from '../components/CMSSidebar'; import { CMSActionBar } from '../components/CMSActionBar'; +import { CMSMobileNav } from '../components/CMSMobileNav'; import { PATHS } from '@presentation/routes/paths'; /** * CMS Section definitions. * Single source of truth for sidebar rendering. + * Grouped by category for better organization. */ const CMS_SECTIONS = [ + { name: 'Dashboard', icon: LayoutDashboard }, + // Content Management { name: 'Courses', icon: BookOpen }, - { name: 'Articles', icon: FileText }, { name: 'Problems', icon: Code2 }, { name: 'Roadmaps', icon: Map }, { name: 'Events', icon: Calendar }, + // Moderation & Governance + { name: 'Accounts', icon: Users }, + { name: 'Reports', icon: ShieldAlert }, + { name: 'Admin-Alerts', icon: Bell }, + // Configuration + { name: 'Course-Types', icon: Layers }, + { name: 'Problem-Types', icon: Box }, + { name: 'Global-Tags', icon: Tag }, + { name: 'Report-Reasons', icon: Flag }, + { name: 'FAQs', icon: HelpCircle }, + { name: 'Help-Centers', icon: BookMarked }, { name: 'Media', icon: Image }, - { name: 'Report-Reasons', icon: Flag } ]; /** @@ -38,7 +62,7 @@ const CMSLayout = () => { // Derive the active section from the current URL pathname const pathSegments = location.pathname.replace(PATHS.CONTENT_MANAGEMENT, '').split('/').filter(Boolean); - const sectionSlug = pathSegments[0] || 'courses'; + const sectionSlug = pathSegments[0] || 'dashboard'; const activeSection = sectionSlug.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('-'); // Build sections array without counts (each module page owns its own data) @@ -51,13 +75,15 @@ const CMSLayout = () => {
navigate(-1)} /> -
+ + +
-
+
diff --git a/src/presentation/feature/cms/routes/CMSAdminNotificationsPage.jsx b/src/presentation/feature/cms/routes/CMSAdminNotificationsPage.jsx new file mode 100644 index 0000000..41e3b25 --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSAdminNotificationsPage.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { Bell, CheckCircle2, Clock, Eye, RefreshCw, Database } from 'lucide-react'; +import { useFetchAdminNotifications } from '@domain/useCase/useFetchAdminNotifications'; +import { cn } from '@core/utils/cn'; + +/** + * CMSAdminNotificationsPage - Admin panel for viewing platform-level notifications. + * Consumes the admin-notification backend API. + */ +const CMSAdminNotificationsPage = () => { + const { notifications, isLoading, fetch, markRead, updateStatus, statusFilter, setStatusFilter } = useFetchAdminNotifications(); + + const statusTabs = [ + { id: null, label: 'All', icon: Eye }, + { id: 'PENDING', label: 'Pending', icon: Clock }, + { id: 'RESOLVED', label: 'Resolved', icon: CheckCircle2 } + ]; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Admin Alerts

+

Platform-level alerts: reported content, system events.

+
+
+ +
+ + {/* Status Filter */} +
+ {statusTabs.map((tab) => { + const TabIcon = tab.icon; + const isActive = statusFilter === tab.id; + return ( + + ); + })} +
+ + {/* Notifications List */} +
+
+ Admin Notification Feed +
+ +
+ {isLoading ? ( +
+ +

Loading alerts...

+
+ ) : (!Array.isArray(notifications) || notifications.length === 0) ? ( +
+ +

No Alerts

+

All clear โ€” no pending admin notifications.

+
+ ) : notifications.map((notif, idx) => { + const id = notif?.documentId || notif?.id || `notif-${idx}`; + const type = notif?.type || 'unknown'; + const contentType = notif?.content_type || ''; + const status = notif?.status || 'PENDING'; + const isRead = notif?.read || false; + const message = notif?.message_en || notif?.message_ar || `${type} on ${contentType}`; + const actorName = notif?.actor?.username || 'System'; + const dateStr = notif?.createdAt + ? new Date(notif.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + : ''; + + return ( +
+
+
+
+
{message}
+
+ By {actorName} ยท {dateStr} ยท {contentType} +
+
+
+ +
+ + {status} + + {!isRead && ( + + )} + {status === 'PENDING' && ( + + )} +
+
+ ); + })} +
+
+
+ ); +}; + +export default CMSAdminNotificationsPage; diff --git a/src/presentation/feature/cms/routes/CMSCourseTypesPage.jsx b/src/presentation/feature/cms/routes/CMSCourseTypesPage.jsx new file mode 100644 index 0000000..1912ace --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSCourseTypesPage.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Layers } from 'lucide-react'; +import { useFetchAdminCategorizations } from '@domain/useCase/useFetchAdminCategorizations'; +import { CMSResourceTable } from '../components/CMSResourceTable'; + +/** + * CMSCourseTypesPage: Admin page for managing Course Types. + */ +const CMSCourseTypesPage = () => { + const { + courseTypes, + isLoading, + reloadCourseTypes, + deleteCourseType, + courseTypesPage, + courseTypesTotalPages, + courseTypesTotalItems, + setCourseTypePage, + setCourseTypeSearch + } = useFetchAdminCategorizations(); + + // Map DTO to CMSResourceTable format + const tableItems = courseTypes.map(type => ({ + ...type, + title: type.title || 'Untitled Track', + type: 'Course Track', + createdAt: type.createdAt, + isActive: true, + statusLabel: 'Categorization', + statusStyles: 'bg-indigo-900/30 text-indigo-400 border-indigo-500/30', + metricValue: type.courseCount || 0 + })); + + return ( + + ); +}; + +export default CMSCourseTypesPage; diff --git a/src/presentation/feature/cms/routes/CMSCoursesPage.jsx b/src/presentation/feature/cms/routes/CMSCoursesPage.jsx index 26010f2..3b35f7a 100644 --- a/src/presentation/feature/cms/routes/CMSCoursesPage.jsx +++ b/src/presentation/feature/cms/routes/CMSCoursesPage.jsx @@ -8,7 +8,16 @@ import { CMSResourceTable } from '../components/CMSResourceTable'; * Follows SRP: Only fetches and displays course data. */ const CMSCoursesPage = () => { - const { courses, isLoading, fetch: fetchCourses } = useFetchAdminCourses(); + const { + courses, + isLoading, + fetch: fetchCourses, + currentPage, + totalPages, + totalItems, + setPage, + setSearch + } = useFetchAdminCourses(); return ( { isLoading={isLoading} icon={BookOpen} onRefresh={fetchCourses} + serverPagination={true} + serverPage={currentPage} + serverTotalPages={totalPages} + serverTotalItems={totalItems} + onPageChange={setPage} + onSearchChange={setSearch} /> ); }; diff --git a/src/presentation/feature/cms/routes/CMSDashboardPage.jsx b/src/presentation/feature/cms/routes/CMSDashboardPage.jsx new file mode 100644 index 0000000..d1fb22a --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSDashboardPage.jsx @@ -0,0 +1,148 @@ +import React, { useEffect } from 'react'; +import { useFetchCMSAnalytics } from '../../../../domain/useCase/useFetchCMSAnalytics'; +import { ShieldAlert, BookOpen, Calendar, Users, TrendingUp } from 'lucide-react'; +import { PageLoader } from '../../../shared/components/loaders/PageLoader'; +import { Link } from 'react-router-dom'; +import { PATHS } from '../../../routes/paths'; + +const StatCard = ({ title, value, icon: Icon, trend, trendLabel, colorClass }) => ( +
+
+
+ +
+
+ {title} +

{value}

+
+
+
+ + {trend} + + {trendLabel} +
+
+); + +const CMSDashboardPage = () => { + const { stats, isLoading, fetchAnalytics } = useFetchCMSAnalytics(); + + useEffect(() => { + fetchAnalytics(); + }, [fetchAnalytics]); + + if (isLoading) return
; + + return ( +
+ {/* Header */} +
+

Master Ledger

+

A high-level overview of the Academy's current state and pending actions.

+
+ + {/* Metrics Grid */} +
+ + + + +
+ + {/* Content Body */} +
+ {/* Trend Visualizer (CSS Mock) */} +
+
+

Platform Activity Trend

+ Last 30 Days +
+ {/* Simulated Chart using CSS grid and pseudo-elements representing a bar chart */} +
+ {[40, 60, 30, 80, 50, 70, 90, 45, 65, 85, 55, 75, 40, 60, 100].map((height, i) => ( +
+ {/* Tooltip */} +
+ {height} units +
+
+ ))} +
+
+ + {/* Critical Actions */} +
+
+ +

Attention Required

+ +
+ {stats.pendingReports > 0 ? ( +
+
+
+ +
+
+

Unresolved Reports

+

{stats.pendingReports} reports await moderation

+
+
+ Review Reports +
+ ) : ( +
+ +

No pending reports. All clear!

+
+ )} + +
+
+
+ +
+
+

Draft Content

+

Review unpublished materials

+
+
+ Go to Courses +
+
+
+
+
+ ); +}; + +export default CMSDashboardPage; diff --git a/src/presentation/feature/cms/routes/CMSEventsPage.jsx b/src/presentation/feature/cms/routes/CMSEventsPage.jsx index af75d78..e2bd9ff 100644 --- a/src/presentation/feature/cms/routes/CMSEventsPage.jsx +++ b/src/presentation/feature/cms/routes/CMSEventsPage.jsx @@ -8,7 +8,16 @@ import { CMSResourceTable } from '../components/CMSResourceTable'; * Follows SRP: Only fetches and displays event data. */ const CMSEventsPage = () => { - const { events, isLoading, fetch: fetchEvents } = useFetchAdminEvents(); + const { + events, + isLoading, + fetch: fetchEvents, + currentPage, + totalPages, + totalItems, + setPage, + setSearch + } = useFetchAdminEvents(); return ( { isLoading={isLoading} icon={Calendar} onRefresh={fetchEvents} + serverPagination={true} + serverPage={currentPage} + serverTotalPages={totalPages} + serverTotalItems={totalItems} + onPageChange={setPage} + onSearchChange={setSearch} /> ); }; diff --git a/src/presentation/feature/cms/routes/CMSFaqsPage.jsx b/src/presentation/feature/cms/routes/CMSFaqsPage.jsx new file mode 100644 index 0000000..dc10bac --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSFaqsPage.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { HelpCircle } from 'lucide-react'; +import { useFetchAdminFaqs } from '@domain/useCase/useFetchAdminFaqs'; +import { CMSResourceTable } from '../components/CMSResourceTable'; + +/** + * CMSFaqsPage: Admin page for managing FAQs. + */ +const CMSFaqsPage = () => { + const { + faqs, + isLoading, + fetch: reloadFaqs, + deleteFaq, + currentPage, + totalPages, + totalItems, + setPage, + setSearch + } = useFetchAdminFaqs(); + + const tableItems = faqs.map(faq => ({ + ...faq, + title: faq.question || 'Untitled FAQ', + type: 'FAQ', + createdAt: faq.createdAt, + publishedAt: faq.publishedAt + })); + + return ( + + ); +}; + +export default CMSFaqsPage; diff --git a/src/presentation/feature/cms/routes/CMSHelpCentersPage.jsx b/src/presentation/feature/cms/routes/CMSHelpCentersPage.jsx new file mode 100644 index 0000000..ddeac6b --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSHelpCentersPage.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { BookMarked } from 'lucide-react'; +import { useFetchAdminHelpCenters } from '@domain/useCase/useFetchAdminHelpCenters'; +import { CMSResourceTable } from '../components/CMSResourceTable'; + +/** + * CMSHelpCentersPage: Admin page for managing Help Center terms/content. + */ +const CMSHelpCentersPage = () => { + const { + helpCenters, + isLoading, + fetch: reloadHelpCenters, + deleteHelpCenter, + currentPage, + totalPages, + totalItems, + setPage, + setSearch + } = useFetchAdminHelpCenters(); + + const tableItems = helpCenters.map(hc => ({ + ...hc, + title: `Help Document v${hc.id}`, + type: 'Help Document', + createdAt: hc.createdAt, + publishedAt: hc.publishedAt, + metricValue: 'Static' // No engagement metrics for static documents + })); + + return ( + + ); +}; + +export default CMSHelpCentersPage; diff --git a/src/presentation/feature/cms/routes/CMSMediaPage.jsx b/src/presentation/feature/cms/routes/CMSMediaPage.jsx new file mode 100644 index 0000000..b19c587 --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSMediaPage.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Image as ImageIcon } from 'lucide-react'; +import { useFetchAdminMedia } from '@domain/useCase/useFetchAdminMedia'; +import { CMSResourceTable } from '../components/CMSResourceTable'; + +/** + * CMSMediaPage: Admin page for Media Management. + */ +const CMSMediaPage = () => { + const { mediaFiles, isLoading, reloadMedia, deleteMediaFile } = useFetchAdminMedia(); + + const tableItems = mediaFiles.map(file => ({ + ...file, + title: file.name || 'Unknown File', + type: file.mime || 'Media', + createdAt: file.createdAt, + isActive: true, // Files don't have draft states + statusLabel: 'Hosted', + statusStyles: 'bg-indigo-900/30 text-indigo-400 border-indigo-500/30', + metricValue: `${(file.size / 1024).toFixed(1)} KB` + })); + + return ( + + ); +}; + +export default CMSMediaPage; diff --git a/src/presentation/feature/cms/routes/CMSProblemTypesPage.jsx b/src/presentation/feature/cms/routes/CMSProblemTypesPage.jsx new file mode 100644 index 0000000..4dd7366 --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSProblemTypesPage.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Box } from 'lucide-react'; +import { useFetchAdminCategorizations } from '@domain/useCase/useFetchAdminCategorizations'; +import { CMSResourceTable } from '../components/CMSResourceTable'; + +/** + * CMSProblemTypesPage: Admin page for managing Problem Types. + */ +const CMSProblemTypesPage = () => { + const { + problemTypes, + isLoading, + reloadProblemTypes, + deleteProblemType, + problemTypesPage, + problemTypesTotalPages, + problemTypesTotalItems, + setProblemTypePage, + setProblemTypeSearch + } = useFetchAdminCategorizations(); + + // Map DTO to CMSResourceTable format + const tableItems = problemTypes.map(type => ({ + ...type, + title: type.title || 'Untitled Category', + type: 'Problem Category', + createdAt: type.createdAt, + isActive: true, + statusLabel: 'Categorization', + statusStyles: 'bg-indigo-900/30 text-indigo-400 border-indigo-500/30', + metricValue: type.problemCount || 0 + })); + + return ( + + ); +}; + +export default CMSProblemTypesPage; diff --git a/src/presentation/feature/cms/routes/CMSProblemsPage.jsx b/src/presentation/feature/cms/routes/CMSProblemsPage.jsx index 4e7b9ab..0fc30c4 100644 --- a/src/presentation/feature/cms/routes/CMSProblemsPage.jsx +++ b/src/presentation/feature/cms/routes/CMSProblemsPage.jsx @@ -8,7 +8,16 @@ import { CMSResourceTable } from '../components/CMSResourceTable'; * Follows SRP: Only fetches and displays problem data. */ const CMSProblemsPage = () => { - const { problems, isLoading, fetch: fetchProblems } = useFetchAdminProblems(); + const { + problems, + isLoading, + fetch: fetchProblems, + currentPage, + totalPages, + totalItems, + setPage, + setSearch + } = useFetchAdminProblems(); return ( { isLoading={isLoading} icon={Code2} onRefresh={fetchProblems} + serverPagination={true} + serverPage={currentPage} + serverTotalPages={totalPages} + serverTotalItems={totalItems} + onPageChange={setPage} + onSearchChange={setSearch} /> ); }; diff --git a/src/presentation/feature/cms/routes/CMSReportsPage.jsx b/src/presentation/feature/cms/routes/CMSReportsPage.jsx new file mode 100644 index 0000000..a900864 --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSReportsPage.jsx @@ -0,0 +1,163 @@ +import React, { useState } from 'react'; +import { ShieldAlert, CheckCircle2, XCircle, Clock, Eye, Trash2, RefreshCw, AlertTriangle, MessageSquare, Database } from 'lucide-react'; +import { useFetchAdminReports } from '@domain/useCase/useFetchAdminReports'; +import { cn } from '@core/utils/cn'; + +/** + * CMSReportsPage - Admin dashboard for reviewing user reports. + * Provides status filtering and moderation actions. + */ +const CMSReportsPage = () => { + const { reports, isLoading, fetch, updateStatus, deleteReport, statusFilter, setStatusFilter } = useFetchAdminReports(); + + const statusTabs = [ + { id: null, label: 'All', icon: Eye }, + { id: 'pending', label: 'Pending', icon: Clock }, + { id: 'resolved', label: 'Resolved', icon: CheckCircle2 }, + { id: 'dismissed', label: 'Dismissed', icon: XCircle } + ]; + + const getStatusStyles = (status) => { + switch (status) { + case 'resolved': return 'bg-emerald-900/30 text-emerald-400 border-emerald-500/30'; + case 'dismissed': return 'bg-rose-900/30 text-rose-400 border-rose-500/30'; + default: return 'bg-amber-900/30 text-amber-400 border-amber-500/30'; + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Reports Review

+

Review and moderate content reports from users.

+
+
+ +
+ + {/* Status Filter Tabs */} +
+ {statusTabs.map((tab) => { + const TabIcon = tab.icon; + const isActive = statusFilter === tab.id; + return ( + + ); + })} +
+ + {/* Reports List */} +
+
+ Content Report +
+ Status + Type + Actions +
+
+ +
+ {isLoading ? ( +
+ +

Loading reports...

+
+ ) : (!Array.isArray(reports) || reports.length === 0) ? ( +
+ +

No Reports Found

+
+ ) : reports.map((report, idx) => { + const id = report?.documentId || report?.id || `report-${idx}`; + const contentType = report?.content_type || 'unknown'; + const status = report?.review_status || 'pending'; + const reporter = report?.reporter_user?.username || 'Anonymous'; + const reported = report?.reported_user?.username || 'Unknown'; + const dateStr = report?.createdAt + ? new Date(report.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + : 'Unknown'; + + return ( +
+
+
+ +
+
+
+ {reporter} โ†’ {reported} +
+
+ {dateStr} ยท Content: {contentType} +
+
+
+ +
+ + + {contentType} + +
+ {status === 'pending' && ( + <> + + + + )} + +
+
+
+ ); + })} +
+
+
+ ); +}; + +export default CMSReportsPage; diff --git a/src/presentation/feature/cms/routes/CMSRoadmapsPage.jsx b/src/presentation/feature/cms/routes/CMSRoadmapsPage.jsx index 3e61c78..f217626 100644 --- a/src/presentation/feature/cms/routes/CMSRoadmapsPage.jsx +++ b/src/presentation/feature/cms/routes/CMSRoadmapsPage.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { Map } from 'lucide-react'; -import { useFetchRoadmaps } from '@domain/useCase/useFetchRoadmaps'; +import { useFetchAdminRoadmaps } from '@domain/useCase/useFetchAdminRoadmaps'; import { CMSResourceTable } from '../components/CMSResourceTable'; /** @@ -8,12 +8,16 @@ import { CMSResourceTable } from '../components/CMSResourceTable'; * Follows SRP: Only fetches and displays roadmap data into the CMSResourceTable. */ const CMSRoadmapsPage = () => { - // using the existing hook that fetches roadmaps - const { roadmaps, isLoading, fetchRoadmaps } = useFetchRoadmaps(); - - React.useEffect(() => { - fetchRoadmaps(); - }, [fetchRoadmaps]); + const { + roadmaps, + isLoading, + fetch: fetchRoadmaps, + currentPage, + totalPages, + totalItems, + setPage, + setSearch + } = useFetchAdminRoadmaps(); return ( { isLoading={isLoading} icon={Map} onRefresh={fetchRoadmaps} + serverPagination={true} + serverPage={currentPage} + serverTotalPages={totalPages} + serverTotalItems={totalItems} + onPageChange={setPage} + onSearchChange={setSearch} /> ); }; diff --git a/src/presentation/feature/cms/routes/CMSTagsPage.jsx b/src/presentation/feature/cms/routes/CMSTagsPage.jsx new file mode 100644 index 0000000..c522600 --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSTagsPage.jsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { Tag } from 'lucide-react'; +import { useFetchAdminTags } from '@domain/useCase/useFetchAdminTags'; +import { CMSResourceTable } from '../components/CMSResourceTable'; + +/** + * CMSTagsPage: Admin page for managing Global Tags. + * Follows SRP: Delegates data fetching to domain layer, UI to CMSResourceTable. + */ +const CMSTagsPage = () => { + const { + tags, + isLoading, + fetch: reloadTags, + deleteTag, + currentPage, + totalPages, + totalItems, + setPage, + setSearch + } = useFetchAdminTags(); + + // Map Strapi tag DTO to CMSResourceTable format + const tableItems = tags.map(tag => ({ + ...tag, + title: tag.name, + type: 'System Tag', + createdAt: tag.createdAt, + isActive: true, + statusLabel: 'Global Tag', + statusStyles: 'bg-indigo-900/30 text-indigo-400 border-indigo-500/30', + metricValue: tag.count || 0 + })); + + return ( + + ); +}; + +export default CMSTagsPage; diff --git a/src/presentation/feature/cms/routes/CMSUsersPage.jsx b/src/presentation/feature/cms/routes/CMSUsersPage.jsx new file mode 100644 index 0000000..30b714a --- /dev/null +++ b/src/presentation/feature/cms/routes/CMSUsersPage.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Users } from 'lucide-react'; +import { useFetchAdminUsers } from '@domain/useCase/useFetchAdminUsers'; +import { CMSResourceTable } from '../components/CMSResourceTable'; + +/** + * CMSUsersPage: Admin page for User Management. + */ +const CMSUsersPage = () => { + const { users, isLoading, reloadUsers, deleteUser } = useFetchAdminUsers(); + + const tableItems = users.map(user => { + const isConfirmed = user.confirmed === true; + return { + ...user, + title: user.username || 'Unknown User', + type: user.role?.name || 'User', + createdAt: user.createdAt, + isActive: isConfirmed, + statusLabel: isConfirmed ? 'Confirmed' : 'Pending', + statusStyles: isConfirmed + ? 'bg-emerald-900/30 text-emerald-400 border-emerald-500/30' + : 'bg-amber-900/30 text-amber-400 border-amber-500/30', + metricValue: user.id // Using ID as the generic metric representation + }; + }); + + return ( + + ); +}; + +export default CMSUsersPage; diff --git a/src/presentation/feature/cms/routes/EventManagementPage.jsx b/src/presentation/feature/cms/routes/EventManagementPage.jsx index 739243a..06acf42 100644 --- a/src/presentation/feature/cms/routes/EventManagementPage.jsx +++ b/src/presentation/feature/cms/routes/EventManagementPage.jsx @@ -17,7 +17,7 @@ export const EventManagementPage = () => { const navigate = useNavigate(); // Valid Topics Enforcer - const validTopics = ['edit', 'entitlement', 'subscription-analysis']; + const validTopics = ['edit', 'entitlement', 'subscription-analysis', 'activities', 'speakers']; if (!validTopics.includes(topic)) { return ; } @@ -26,7 +26,9 @@ export const EventManagementPage = () => { const tabs = useMemo(() => [ { id: 'edit', label: 'Event Details', icon: Edit2 }, { id: 'entitlement', label: 'Entitlement', icon: ShieldCheck }, - { id: 'subscription-analysis', label: 'Subscribers', icon: Activity } + { id: 'subscription-analysis', label: 'Subscribers', icon: Activity }, + { id: 'activities', label: 'Activities', icon: Activity }, + { id: 'speakers', label: 'Speakers', icon: Activity } ], []); // Active Render mapping @@ -35,6 +37,20 @@ export const EventManagementPage = () => { case 'edit': return ; case 'entitlement': return ; case 'subscription-analysis': return ; + case 'activities': return ( +
+ +

Activities Protocol Pending

+

Event activities scheduling will be orchestrated here.

+
+ ); + case 'speakers': return ( +
+ +

Speakers Protocol Pending

+

Event speakers management will be orchestrated here.

+
+ ); default: return null; } }; diff --git a/src/presentation/feature/cms/routes/ProblemManagementPage.jsx b/src/presentation/feature/cms/routes/ProblemManagementPage.jsx index e14fda1..3cceaae 100644 --- a/src/presentation/feature/cms/routes/ProblemManagementPage.jsx +++ b/src/presentation/feature/cms/routes/ProblemManagementPage.jsx @@ -16,7 +16,7 @@ export const ProblemManagementPage = () => { const navigate = useNavigate(); // Valid Topics Enforcer - const validTopics = ['edit', 'test-cases', 'analysis']; + const validTopics = ['edit', 'test-cases', 'templates', 'analysis']; if (!validTopics.includes(topic)) { return ; } @@ -25,6 +25,7 @@ export const ProblemManagementPage = () => { const tabs = useMemo(() => [ { id: 'edit', label: 'Metadata Schema', icon: Edit2 }, { id: 'test-cases', label: 'Validation Suite', icon: Database }, + { id: 'templates', label: 'Code Templates', icon: Code2 }, { id: 'analysis', label: 'Efficiency Analysis', icon: Activity } ], []); @@ -33,6 +34,13 @@ export const ProblemManagementPage = () => { switch (topic) { case 'edit': return ; case 'test-cases': return ; + case 'templates': return ( +
+ +

Templates Protocol Pending

+

Language specific starting code snippets will be orchestrated here.

+
+ ); case 'analysis': return (
diff --git a/src/presentation/feature/cms/routes/ReportTypeManagementPage.jsx b/src/presentation/feature/cms/routes/ReportTypeManagementPage.jsx index afd5997..8ecc0f9 100644 --- a/src/presentation/feature/cms/routes/ReportTypeManagementPage.jsx +++ b/src/presentation/feature/cms/routes/ReportTypeManagementPage.jsx @@ -49,33 +49,34 @@ const ReportTypeManagementPage = () => { }; return ( -
-
-
- -
-

- {isEdit ? 'Edit Report Reason' : 'New Report Reason'} -

-

- Define the specific reason users can select when reporting content. -

+
+
+
+
+ +
+

+ {isEdit ? 'Edit Report Reason' : 'New Report Reason'} +

+

+ Define the specific reason users can select when reporting content. +

+
-
-
- - Archive Entry +
+ + Archive Entry +
-
-
-
+ +
@@ -112,6 +113,7 @@ const ReportTypeManagementPage = () => {
+
); }; diff --git a/src/presentation/routes/AppRoutes.jsx b/src/presentation/routes/AppRoutes.jsx index a88b962..73918d6 100644 --- a/src/presentation/routes/AppRoutes.jsx +++ b/src/presentation/routes/AppRoutes.jsx @@ -40,6 +40,16 @@ const CMSLayout = lazy(() => import('@presentation/feature/cms/layout/CMSLayout' const CMSCoursesPage = lazy(() => import('@presentation/feature/cms/routes/CMSCoursesPage')); const CMSEventsPage = lazy(() => import('@presentation/feature/cms/routes/CMSEventsPage')); const CMSProblemsPage = lazy(() => import('@presentation/feature/cms/routes/CMSProblemsPage')); +const CMSReportsPage = lazy(() => import('@presentation/feature/cms/routes/CMSReportsPage')); +const CMSAdminNotificationsPage = lazy(() => import('@presentation/feature/cms/routes/CMSAdminNotificationsPage')); +const CMSTagsPage = lazy(() => import('@presentation/feature/cms/routes/CMSTagsPage')); +const CMSFaqsPage = lazy(() => import('@presentation/feature/cms/routes/CMSFaqsPage')); +const CMSHelpCentersPage = lazy(() => import('@presentation/feature/cms/routes/CMSHelpCentersPage')); +const CMSCourseTypesPage = lazy(() => import('@presentation/feature/cms/routes/CMSCourseTypesPage')); +const CMSProblemTypesPage = lazy(() => import('@presentation/feature/cms/routes/CMSProblemTypesPage')); +const CMSUsersPage = lazy(() => import('@presentation/feature/cms/routes/CMSUsersPage')); +const CMSMediaPage = lazy(() => import('@presentation/feature/cms/routes/CMSMediaPage')); +const CMSDashboardPage = lazy(() => import('@presentation/feature/cms/routes/CMSDashboardPage')); // CMS Management Pages (Deep routes) const CourseManagementPage = lazy(() => import('@presentation/feature/cms/routes/CourseManagementPage')); @@ -176,14 +186,22 @@ export const AppRoutes = () => { } > - } /> + } /> + } /> } /> } /> } /> } /> } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* CMS Deep Management Routes */} diff --git a/src/presentation/shared/layout/MainLayout.jsx b/src/presentation/shared/layout/MainLayout.jsx index 240ae92..faedca7 100644 --- a/src/presentation/shared/layout/MainLayout.jsx +++ b/src/presentation/shared/layout/MainLayout.jsx @@ -68,7 +68,7 @@ export const MainLayout = ({ children, className }) => { !isFocusMode ? [ !isAuthPage ? "pt-24" : "pt-4", // Header padding if header is visible ] : [ - "p-0 m-0 w-full min-h-screen", // Full width, natural height for focus mode + "p-0 my-0 w-full min-h-screen mx-auto", // Full width, natural height for focus mode, explicit mx-auto to preserve centering ], className )}> diff --git a/tests/useCases/useFetchCMSAnalytics.test.js b/tests/useCases/useFetchCMSAnalytics.test.js new file mode 100644 index 0000000..1c30964 --- /dev/null +++ b/tests/useCases/useFetchCMSAnalytics.test.js @@ -0,0 +1,116 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useFetchCMSAnalytics } from '../../src/domain/useCase/useFetchCMSAnalytics'; + +const mockCourseGetAll = vi.fn(); +const mockEventGetAll = vi.fn(); +const mockReportGetAll = vi.fn(); +const mockUserGetAll = vi.fn(); + +vi.mock('@infrastructure/repository/CourseRepository', () => ({ + CourseRepository: class { + constructor() { this.getAll = mockCourseGetAll; } + } +})); + +vi.mock('@infrastructure/repository/EventRepository', () => ({ + EventRepository: class { + constructor() { this.getAll = mockEventGetAll; } + } +})); + +vi.mock('@infrastructure/repository/ReportRepository', () => ({ + ReportRepository: class { + constructor() { this.getAll = mockReportGetAll; } + } +})); + +vi.mock('@infrastructure/repository/UserRepository', () => ({ + UserRepository: class { + constructor() { this.getAllUsers = mockUserGetAll; } + } +})); + +describe('useFetchCMSAnalytics Hook', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should successfully aggregate analytics metrics', async () => { + // Mock successful repository responses + mockCourseGetAll.mockResolvedValue({ meta: { pagination: { total: 42 } } }); + mockEventGetAll.mockResolvedValue({ meta: { pagination: { total: 10 } } }); + mockReportGetAll.mockResolvedValue({ meta: { pagination: { total: 5 } } }); + mockUserGetAll.mockResolvedValue(new Array(150).fill({ id: 1 })); // 150 users + + const { result } = renderHook(() => useFetchCMSAnalytics()); + + // Initially loading + expect(result.current.isLoading).toBe(true); + + await act(async () => { + await result.current.fetchAnalytics(); + }); + + expect(mockCourseGetAll).toHaveBeenCalledWith(null, 1, 1); + expect(mockEventGetAll).toHaveBeenCalledWith(1, 1); + expect(mockReportGetAll).toHaveBeenCalledWith(1, 1, '', 'PENDING'); + expect(mockUserGetAll).toHaveBeenCalled(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.stats).toEqual({ + totalCourses: 42, + totalEvents: 10, + pendingReports: 5, + totalUsers: 150 + }); + }); + + it('should handle zero metrics gracefully', async () => { + // Mock responses with zero counts + mockCourseGetAll.mockResolvedValue({ meta: { pagination: { total: 0 } } }); + mockEventGetAll.mockResolvedValue({}); // Empty object fallback test + mockReportGetAll.mockResolvedValue({ meta: null }); // Null middle field fallback test + mockUserGetAll.mockResolvedValue([]); + + const { result } = renderHook(() => useFetchCMSAnalytics()); + + await act(async () => { + await result.current.fetchAnalytics(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.stats).toEqual({ + totalCourses: 0, + totalEvents: 0, + pendingReports: 0, + totalUsers: 0 + }); + }); + + it('should handle errors thrown by repositories', async () => { + const error = new Error('Database connection failed'); + mockCourseGetAll.mockRejectedValue(error); + mockEventGetAll.mockResolvedValue({ meta: { pagination: { total: 10 } } }); + mockReportGetAll.mockResolvedValue({ meta: { pagination: { total: 5 } } }); + mockUserGetAll.mockResolvedValue([]); + + const { result } = renderHook(() => useFetchCMSAnalytics()); + + await act(async () => { + await result.current.fetchAnalytics(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe('Database connection failed'); + // Stats should remain at defaults when error occurs during Promise.all + expect(result.current.stats).toEqual({ + totalCourses: 0, + totalEvents: 0, + pendingReports: 0, + totalUsers: 0 + }); + }); +}); diff --git a/tests/useCases/useFetchRoadmaps.test.js b/tests/useCases/useFetchRoadmaps.test.js index b672af3..4954b92 100644 --- a/tests/useCases/useFetchRoadmaps.test.js +++ b/tests/useCases/useFetchRoadmaps.test.js @@ -21,7 +21,7 @@ describe('useFetchRoadmaps Hook', () => { }); it('should successfully fetch and map roadmaps', async () => { - mockGetAll.mockResolvedValue([{ id: 1 }, { id: 2 }]); + mockGetAll.mockResolvedValue({ items: [{ id: 1 }, { id: 2 }] }); const { result } = renderHook(() => useFetchRoadmaps());