From 0c7887268cef0af5bafb6b8a3f56de6e93140456 Mon Sep 17 00:00:00 2001 From: Hafid Nur <73023445+hafidnrzs@users.noreply.github.com> Date: Wed, 10 Dec 2025 08:41:30 +0700 Subject: [PATCH] feat(backoffice): FE integration (data fetch) for dashboard, teams, submission, and user management page (#70) * feat(backoffice): create a boilerplate page for Hackathon dashboard - Create an empty page for Hackathon dashboard - Comment out and hide the existing backoffice sidebar * feat(backoffice): Create a nested/dropdown sidebar list - Create a dropdown sidebar list - Show back the old navigation and group them - Make the sidebar responsive for mobile view * test datatable with mock data * base UI for Hackathon backoffice TODO: - Organize table schema for users, teams, and submissions management - Create API Contract for additional back-end endpoint * feat(hackathon): draft data table column & API Contract * update endpoint * feat(backoffice): update page hackathon user management - update data table component - update filtering & pagination - add modal display to edit and add user - hide notification icon in backoffice wrapper * feat(backoffice): add API contract for hackathon users * feat(backoffice): little adjustment in hackathon users management UI and API contract * feat(backoffice): update hackathon team management page - add modal for manage team, add new team, and view project submission - reorganize the table column and data table * feat(backoffice): add searchable city filter - add component for city filter - apply to user management and team management pages * feat(backoffice): improve hackathon team modal UI and add API contract - Add feature to select city in team detail modal using CityFilterSelect component - Add feature to change team logo and banner - Add API contract documentation for hackathon teams in backoffice * feat(backoffice): authentication middleware, error pages, and 404 page * feat(backoffice): hackathon dashboard integration * feat(backoffice): users page integration - Get users data from API - Set up server-side pagination and match URL params - Hide filter that doesn't exist in back-end * feat(backoffice): teams page integration - Get teams data from API - Hide filter that doesn't exists in back-end - Simplify modal according to the back-end * feat(backoffice): submission page integration - Fetch submissions data from API - Move submission modal to hackathon-submissions page --- apps/backoffice/index.html | 2 +- .../(protected)/hackathon-dashboard/page.tsx | 38 +- .../_components/submission-modal.tsx | 249 ++++++++ .../hackathon-submissions/page.tsx | 447 ++++++++++--- .../_components/modal-team-detail-new.tsx | 514 +++++++++++++++ .../_components/team-banner-placeholder.tsx | 82 +++ .../app/(protected)/hackathon-teams/page.tsx | 585 ++++++++++++------ .../_components/modal-user-detail.tsx | 24 +- .../app/(protected)/hackathon-users/page.tsx | 337 ++++++---- .../backoffice/src/app/(protected)/layout.tsx | 2 +- .../(public)/auth/login/_hooks/use-login.ts | 25 +- .../src/app/(public)/auth/login/page.tsx | 8 +- apps/backoffice/src/app/404.tsx | 23 + apps/backoffice/src/app/error.tsx | 29 + .../src/components/city-filter-select.tsx | 148 +++++ apps/backoffice/src/constants/cities.ts | 518 ++++++++++++++++ apps/backoffice/src/middleware.ts | 6 +- apps/backoffice/vite.config.ts | 2 +- libs/service/src/api/admin/index.ts | 54 ++ libs/service/src/api/backoffice.ts | 63 ++ libs/service/src/api/index.ts | 13 +- libs/service/src/hooks/auth/index.ts | 111 +++- libs/service/src/types/admin/index.ts | 64 ++ libs/service/src/types/index.ts | 1 + libs/ui/src/organisms/datatable/datatable.tsx | 134 +++- 25 files changed, 3020 insertions(+), 459 deletions(-) create mode 100644 apps/backoffice/src/app/(protected)/hackathon-submissions/_components/submission-modal.tsx create mode 100644 apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx create mode 100644 apps/backoffice/src/app/(protected)/hackathon-teams/_components/team-banner-placeholder.tsx create mode 100644 apps/backoffice/src/app/404.tsx create mode 100644 apps/backoffice/src/app/error.tsx create mode 100644 apps/backoffice/src/components/city-filter-select.tsx create mode 100644 apps/backoffice/src/constants/cities.ts create mode 100644 libs/service/src/api/admin/index.ts create mode 100644 libs/service/src/api/backoffice.ts create mode 100644 libs/service/src/types/admin/index.ts diff --git a/apps/backoffice/index.html b/apps/backoffice/index.html index 52a9604..8ba1c9a 100644 --- a/apps/backoffice/index.html +++ b/apps/backoffice/index.html @@ -1,5 +1,5 @@ - + Backoffice diff --git a/apps/backoffice/src/app/(protected)/hackathon-dashboard/page.tsx b/apps/backoffice/src/app/(protected)/hackathon-dashboard/page.tsx index c9262c8..48a8d6d 100644 --- a/apps/backoffice/src/app/(protected)/hackathon-dashboard/page.tsx +++ b/apps/backoffice/src/app/(protected)/hackathon-dashboard/page.tsx @@ -1,7 +1,35 @@ import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'; import { FC, ReactElement } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + getAdminUsers, + getAdminTeams, + getAdminSubmissions, +} from '@imphnen-frontend-service/service'; export const HackathonDashboardPage: FC = (): ReactElement => { + // Fetch total participants + const { data: usersData } = useQuery({ + queryKey: ['admin-users-count'], + queryFn: () => getAdminUsers({ page: 1, per_page: 1 }), + }); + + // Fetch total teams + const { data: teamsData } = useQuery({ + queryKey: ['admin-teams-count'], + queryFn: () => getAdminTeams({ page: 1, per_page: 1 }), + }); + + // Fetch total submissions + const { data: submissionsData } = useQuery({ + queryKey: ['admin-submissions-count'], + queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }), + }); + + const totalParticipants = usersData?.meta?.total_data ?? '??'; + const totalTeams = teamsData?.meta?.total_data ?? '??'; + const totalSubmissions = submissionsData?.meta?.total_data ?? '??'; + return (

Dashboard

@@ -10,18 +38,22 @@ export const HackathonDashboardPage: FC = (): ReactElement => { {/* Participant */}

- 1261 + {totalParticipants}

Total Participants

{/* Team */}
-

206

+

+ {totalTeams} +

Total Teams

{/* Project Submitted */}
-

0

+

+ {totalSubmissions} +

Total Project Submitted

diff --git a/apps/backoffice/src/app/(protected)/hackathon-submissions/_components/submission-modal.tsx b/apps/backoffice/src/app/(protected)/hackathon-submissions/_components/submission-modal.tsx new file mode 100644 index 0000000..3bf2867 --- /dev/null +++ b/apps/backoffice/src/app/(protected)/hackathon-submissions/_components/submission-modal.tsx @@ -0,0 +1,249 @@ +import { FC } from 'react'; +import { Button } from '@imphnen-frontend-service/ui/atoms'; +import { + CloseOutlined, + LinkOutlined, + ProjectOutlined, + FileImageOutlined, +} from '@ant-design/icons'; +import { TAdminSubmissionItem } from '@imphnen-frontend-service/service'; +import { cn } from '@imphnen-frontend-service/utils'; + +interface SubmissionModalProps { + isOpen: boolean; + onClose: () => void; + submission: TAdminSubmissionItem; +} + +const SubmissionModal: FC = ({ + isOpen, + onClose, + submission, +}) => { + if (!isOpen) return null; + + const getStatusColor = (status: string) => { + switch (status) { + case 'submitted': + return 'bg-success-50 border-success-200 text-success-800'; + case 'pending': + return 'bg-orange-50 border-orange-200 text-orange-800'; + case 'approved': + return 'bg-blue-50 border-blue-200 text-blue-800'; + case 'rejected': + return 'bg-error-50 border-error-200 text-error-800'; + default: + return 'bg-neutral-50 border-neutral-200 text-neutral-800'; + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {submission.project_name} +

+

+ Team ID: {submission.team_id} • Submitted{' '} + {new Date(submission.submitted_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} +

+
+
+ +
+ + {/* Content */} +
+ {/* Submission Status */} +
+
+
+

+ Status:{' '} + {submission.status.charAt(0).toUpperCase() + + submission.status.slice(1)} +

+

Submitted by: {submission.submitted_by}

+
+
+ + {/* Project Description */} +
+

+ Project Description +

+

+ {submission.description} +

+
+ + {/* Project Links */} +
+

+ Project Links +

+ + {/* Repository URL */} +
+ + +
+ + {/* Demo URL */} + {submission.demo_url && ( +
+ +
+

+ Live Demo +

+ + {submission.demo_url} + +
+
+ )} + + {/* Presentation URL */} + {submission.presentation_url && ( +
+ + +
+ )} +
+ + {/* Screenshots */} + {submission.screenshots && submission.screenshots.length > 0 && ( +
+

+ + Screenshots ({submission.screenshots.length}) +

+
+ {submission.screenshots.map((screenshot, index) => ( + + {`Screenshot + + ))} +
+
+ )} + + {/* Metadata */} +
+
+

Created

+

+ {new Date(submission.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

+
+
+

Last Updated

+

+ {new Date(submission.updated_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

+
+
+
+ + {/* Footer */} +
+ + {/* */} +
+
+
+ ); +}; + +export default SubmissionModal; diff --git a/apps/backoffice/src/app/(protected)/hackathon-submissions/page.tsx b/apps/backoffice/src/app/(protected)/hackathon-submissions/page.tsx index ea8a53b..8466098 100644 --- a/apps/backoffice/src/app/(protected)/hackathon-submissions/page.tsx +++ b/apps/backoffice/src/app/(protected)/hackathon-submissions/page.tsx @@ -1,128 +1,375 @@ -import { FC, ReactElement, useState } from 'react'; +import { + FC, + ReactElement, + useState, + useEffect, + useMemo, + useCallback, +} from 'react'; +import SubmissionModal from './_components/submission-modal'; import { BackofficeWrapper, DataTable, } from '@imphnen-frontend-service/ui/organisms'; -import { - ColumnDef, - getCoreRowModel, - getPaginationRowModel, - PaginationState, - RowSelectionState, - useReactTable, -} from '@tanstack/react-table'; +import { ColumnDef } from '@tanstack/react-table'; import { Button } from '@imphnen-frontend-service/ui/atoms'; import { cn } from '@imphnen-frontend-service/utils'; -import { EditOutlined } from '@ant-design/icons'; +import { + SearchOutlined, + FilterOutlined, + LoadingOutlined, + EyeOutlined, +} from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { + getAdminSubmissions, + TAdminSubmissionItem, +} from '@imphnen-frontend-service/service'; +import { useSearchParams } from 'react-router-dom'; -export const HackathonUsersPage: FC = (): ReactElement => { - const [rowSelection, setRowSelection] = useState({}); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 9, +type SubmissionType = TAdminSubmissionItem; + +export const HackathonSubmissionsPage: FC = (): ReactElement => { + const [searchParams, setSearchParams] = useSearchParams(); + const currentPage = Math.max( + 1, + parseInt(searchParams.get('page') || '1', 10) + ); + const searchQuery = searchParams.get('search') || ''; + const perPage = parseInt(searchParams.get('per_page') || '10', 10); + const statusFilter = searchParams.get('status') || 'all'; + + const [showSubmissionModal, setShowSubmissionModal] = useState(false); + const [selectedSubmission, setSelectedSubmission] = + useState(null); + const [globalFilter, setGlobalFilter] = useState(searchQuery); + + // Fetch submissions from API + const { + data: submissionsResponse, + isLoading, + isFetching, + } = useQuery({ + queryKey: [ + 'admin-submissions', + currentPage, + perPage, + statusFilter, + searchQuery, + ], + queryFn: () => + getAdminSubmissions({ + page: currentPage, + per_page: perPage, + status: statusFilter !== 'all' ? statusFilter : undefined, + search: searchQuery || undefined, + }), + staleTime: 30000, // 30 seconds cache + gcTime: 5 * 60 * 1000, // 5 minutes }); - const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({ - id: i + 1, - project_name: `Project ${i + 1}`, - repository_url: `https://github.com/user/repo${i + 1}`, - demo_url: `https://demo.example.com/project${i + 1}`, - presentation_url: `https://slides.example.com/project${i + 1}`, - })); + const totalData = submissionsResponse?.meta?.total_data || 0; + const totalPages = submissionsResponse?.meta?.total_page || 1; - type UserStatus = 'active' | 'inactive'; + // Handle page change + const handlePageChange = useCallback( + (newPage: number) => { + const params = new URLSearchParams(); + params.set('page', newPage.toString()); + if (perPage !== 10) params.set('per_page', perPage.toString()); + if (searchQuery) params.set('search', searchQuery); + if (statusFilter !== 'all') params.set('status', statusFilter); + setSearchParams(params); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + [setSearchParams, perPage, searchQuery, statusFilter] + ); - interface SubmissionType { - id: number; - project_name: string; - repository_url: string; - demo_url: string; - presentation_url: string; - } + // Validate page number + useEffect(() => { + if (!isLoading && totalPages > 0 && currentPage > totalPages) { + setSearchParams({ page: totalPages.toString() }); + } + }, [currentPage, totalPages, setSearchParams, isLoading]); - const columns: ColumnDef[] = [ - { - header: 'Project Name', - accessorKey: 'project_name', - }, - { - header: 'Repository URL', - accessorKey: 'repository_url', - }, - { - header: 'Demo URL', - accessorKey: 'demo_url', - }, - { - header: 'Presentation URL', - accessorKey: 'presentation_url', - }, - { - header: 'Action', - meta: { cellClassName: cn('w-72') }, - cell: ({ row }) => ( - - ), - }, - ]; + // Sync globalFilter with URL + useEffect(() => { + setGlobalFilter(searchQuery); + }, [searchQuery]); - const table = useReactTable({ - data: mockData, - columns, - state: { - pagination, - rowSelection, + // Handle search + const handleSearch = useCallback(() => { + const params = new URLSearchParams(); + params.set('page', '1'); + if (perPage !== 10) params.set('per_page', perPage.toString()); + if (globalFilter.trim()) { + params.set('search', globalFilter.trim()); + } + if (statusFilter !== 'all') params.set('status', statusFilter); + setSearchParams(params); + }, [globalFilter, setSearchParams, perPage, statusFilter]); + + const handleSearchKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } }, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onPaginationChange: setPagination, - pageCount: Math.ceil(mockData.length / pagination.pageSize), - manualPagination: false, - }); + [handleSearch] + ); + + // Handle per page change + const handlePerPageChange = useCallback( + (newPerPage: number) => { + const params = new URLSearchParams(); + params.set('page', '1'); + params.set('per_page', newPerPage.toString()); + if (searchQuery) params.set('search', searchQuery); + if (statusFilter !== 'all') params.set('status', statusFilter); + setSearchParams(params); + }, + [setSearchParams, searchQuery, statusFilter] + ); + + // Handle status filter change + // const handleStatusFilterChange = useCallback( + // (newStatus: string) => { + // const params = new URLSearchParams(); + // params.set('page', '1'); + // if (perPage !== 10) params.set('per_page', perPage.toString()); + // if (searchQuery) params.set('search', searchQuery); + // if (newStatus !== 'all') params.set('status', newStatus); + // setSearchParams(params); + // }, + // [setSearchParams, perPage, searchQuery] + // ); + + // Handle modal + const handleShowSubmissionModal = useCallback( + (submission: SubmissionType) => { + setSelectedSubmission(submission); + setShowSubmissionModal(true); + }, + [] + ); + + const handleCloseSubmissionModal = useCallback(() => { + setShowSubmissionModal(false); + setSelectedSubmission(null); + }, []); + + // Get submissions data + const filteredData = useMemo(() => { + return submissionsResponse?.data || []; + }, [submissionsResponse]); + + // Memoize columns + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: 'project_name', + header: 'Project Name', + cell: ({ row }) => ( + + {row.original.project_name} + + ), + enableSorting: true, + }, + { + accessorKey: 'team_id', + header: 'Team ID', + cell: ({ row }) => ( + + {row.original.team_id} + + ), + enableSorting: false, + }, + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => { + const status = row.original.status; + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }, + enableSorting: true, + }, + { + accessorKey: 'submitted_at', + header: 'Submitted', + cell: ({ row }) => ( + + {new Date(row.original.submitted_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + + ), + enableSorting: true, + sortingFn: 'datetime', + }, + { + id: 'actions', + header: 'Actions', + meta: { cellClassName: cn('w-48') }, + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + ], + [handleShowSubmissionModal] + ); return (

- Project Submission + Project Submissions

- {/* Filters and actions */}
-
- - - +
+
+ {/* Search bar */} +
+ + setGlobalFilter(e.target.value)} + onKeyPress={handleSearchKeyPress} + /> +
+ + {/* Per Page Dropdown */} +
+ +
+ + {/* Status Filter */} + {/*
+ + +
*/} +
- {/* Table */} - + {/* Active filters */} + {/* {statusFilter !== 'all' && ( +
+ Active filters: + + Status: {statusFilter} + + + +
+ )} */} + + {/* Loading & results */} + {isLoading ? ( +
+ + + Loading submissions... + +
+ ) : filteredData.length > 0 ? ( + <> +
+ Showing {filteredData.length} of {totalData} submissions (Page{' '} + {currentPage} of {totalPages}) + {isFetching && ( + (Updating...) + )} +
+ + + ) : ( +
+ No submissions found. Try adjusting your filters. +
+ )}
- {/* Modals extracted into shared backoffice components */} + {/* Submission Modal */} + {selectedSubmission && ( + + )}
); }; -export default HackathonUsersPage; +export default HackathonSubmissionsPage; diff --git a/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx new file mode 100644 index 0000000..b2f7201 --- /dev/null +++ b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx @@ -0,0 +1,514 @@ +import { FC, useState, useEffect, useMemo, useRef } from 'react'; +import { Button } from '@imphnen-frontend-service/ui/atoms'; +import { CityFilterSelect } from '../../../../components/city-filter-select'; +import TeamBannerPlaceholder from './team-banner-placeholder'; +import { cn } from '@imphnen-frontend-service/utils'; +import { TAdminTeamItem } from '@imphnen-frontend-service/service'; +import { + TeamOutlined, + CloseOutlined, + DeleteOutlined, + SaveOutlined, + CalendarOutlined, + CrownOutlined, + ExclamationOutlined, + UploadOutlined, + CameraOutlined, + EyeOutlined, + EyeInvisibleOutlined, +} from '@ant-design/icons'; + +type TeamType = TAdminTeamItem; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + team: TeamType | null; +} + +const ModalTeamDetail: FC = ({ isOpen, onClose, team }) => { + const [formData, setFormData] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showLogoMenu, setShowLogoMenu] = useState(false); + const logoInputRef = useRef(null); + const bannerInputRef = useRef(null); + + // Initialize form data when modal opens + useEffect(() => { + if (isOpen) { + if (team) { + setFormData({ ...team }); + } else { + setFormData({ + id: '', + name: '', + description: '', + city: '', + banner: null, + logo: null, + visibility: 'public', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + leader_id: '', + }); + } + } + }, [isOpen, team]); + + // Check if form has changes + const hasChanges = useMemo(() => { + if (!formData || !team) return !!formData; + return ( + formData.name !== team.name || + formData.description !== team.description || + formData.city !== team.city || + formData.visibility !== team.visibility || + formData.logo !== team.logo || + formData.banner !== team.banner + ); + }, [formData, team]); + + // Check if required fields are filled + const isFormValid = useMemo(() => { + if (!formData) return false; + return ( + formData.name.trim() !== '' && + formData.city.trim() !== '' && + formData.description.trim() !== '' + ); + }, [formData]); + + const canSave = hasChanges && isFormValid; + + if (!isOpen || !formData) return null; + + const handleInputChange = (field: keyof TeamType, value: string | null) => { + setFormData((prev) => (prev ? { ...prev, [field]: value } : null)); + }; + + const handleSave = () => { + console.log('Saving team:', formData); + onClose(); + }; + + const handleDelete = () => { + if (!team) return; + console.log('Deleting team:', team.id); + setShowDeleteConfirm(false); + onClose(); + }; + + const handleLogoUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + if (file.size > 5 * 1024 * 1024) { + alert('Image size must be less than 5MB'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const logoUrl = e.target?.result as string; + handleInputChange('logo', logoUrl); + setShowLogoMenu(false); + }; + reader.readAsDataURL(file); + }; + + const handleBannerUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + if (file.size > 5 * 1024 * 1024) { + alert('Image size must be less than 5MB'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const bannerUrl = e.target?.result as string; + handleInputChange('banner', bannerUrl); + }; + reader.readAsDataURL(file); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {team ? 'Team Details' : 'Create New Team'} +

+

+ {team + ? 'View and manage team information' + : 'Add a new team to the hackathon'} +

+
+
+ +
+ + {/* Content */} +
setShowLogoMenu(false)}> + + + + {/* Banner Section */} +
+ +
+ +
+ + {formData.banner && ( + + )} +
+
+
+ + {/* Logo & Name */} +
+
+ +
+
+ {formData.logo ? ( + {formData.name + ) : ( + + )} +
+ + {showLogoMenu && ( +
+ + {formData.logo && ( + + )} +
+ )} +
+
+ +
+ + handleInputChange('name', e.target.value)} + /> +
+
+ + {/* Description */} +
+ +