From 3d970aab457cb2ee547d4643949ded8c3a54ddc7 Mon Sep 17 00:00:00 2001 From: Hafid Nur <73023445+hafidnrzs@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:58:31 +0700 Subject: [PATCH] feat(backoffice): submission page integration - Fetch submissions data from API - Move submission modal to hackathon-submissions page --- .../_components/submission-modal.tsx | 249 ++++++++++ .../hackathon-submissions/page.tsx | 447 ++++++++++++++---- .../_components/submission-modal.tsx | 216 --------- .../app/(protected)/hackathon-teams/page.tsx | 22 +- 4 files changed, 598 insertions(+), 336 deletions(-) create mode 100644 apps/backoffice/src/app/(protected)/hackathon-submissions/_components/submission-modal.tsx delete mode 100644 apps/backoffice/src/app/(protected)/hackathon-teams/_components/submission-modal.tsx 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/submission-modal.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/submission-modal.tsx deleted file mode 100644 index b410988..0000000 --- a/apps/backoffice/src/app/(protected)/hackathon-teams/_components/submission-modal.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { FC } from 'react'; -import { Button } from '@imphnen-frontend-service/ui/atoms'; -import { - CloseOutlined, - LinkOutlined, - ProjectOutlined, -} from '@ant-design/icons'; - -interface SubmissionModalProps { - isOpen: boolean; - onClose: () => void; - teamId: string; - teamName: string; -} - -const SubmissionModal: FC = ({ - isOpen, - onClose, - teamId, - teamName, -}) => { - if (!isOpen) return null; - - // Mock submission data - const mockSubmission = { - id: `submission-${teamId}`, - project_name: `${teamName} Project`, - repository_url: `https://github.com/${teamName - .toLowerCase() - .replace(/\s+/g, '-')}/hackathon-project`, - demo_url: `https://${teamName - .toLowerCase() - .replace(/\s+/g, '-')}.vercel.app`, - presentation_url: `https://docs.google.com/presentation/d/${teamId}/edit`, - submitted_at: new Date().toISOString(), - status: 'submitted', - description: - 'An innovative solution built during the IMPHNEN x Kolosal.ai Hackathon 2025.', - }; - - return ( -
-
- {/* Header */} -
-
-
- -
-
-

- Project Submission -

-

- {teamName} - Hackathon Submission Details -

-
-
- -
- - {/* Content */} -
- {/* Submission Status */} -
-
-
-

- Submission Completed -

-

- Submitted on{' '} - {new Date(mockSubmission.submitted_at).toLocaleDateString( - 'en-UK', - { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - } - )} -

-
-
- - {/* Project Information */} -
-
- -

- {mockSubmission.project_name} -

-
- -
- -

- {mockSubmission.description} -

-
- - {/* Links Section */} -
-
- -
- - {mockSubmission.repository_url} - - -
-
- -
- -
- - {mockSubmission.demo_url} - - -
-
-
- -
- -
- - {mockSubmission.presentation_url} - - -
-
-
- - {/* Action Note */} -
-

- Note: This is a submission preview. The team has - successfully submitted their project. You can review the - submission details and access the project links above. -

-
-
- - {/* Footer */} -
-
- - -
-
-
-
- ); -}; - -export default SubmissionModal; diff --git a/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx index fad4889..9e29cb2 100644 --- a/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx +++ b/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx @@ -7,7 +7,6 @@ import { useCallback, } from 'react'; import ModalTeamDetail from './_components/modal-team-detail-new'; -import SubmissionModal from './_components/submission-modal'; import { CityFilterSelect } from '../../../components/city-filter-select'; import { BackofficeWrapper, @@ -43,10 +42,8 @@ export const HackathonTeamsPage: FC = (): ReactElement => { const perPage = parseInt(searchParams.get('per_page') || '10', 10); const [showDetailModal, setShowDetailModal] = useState(false); const [showNewTeamModal, setShowNewTeamModal] = useState(false); - const [showSubmissionModal, setShowSubmissionModal] = useState(false); const [selectedTeam, setSelectedTeam] = useState(null); - const [selectedSubmissionTeam, setSelectedSubmissionTeam] = - useState(null); + useState(null); const [globalFilter, setGlobalFilter] = useState(searchQuery); // Advanced filtering states @@ -105,7 +102,7 @@ export const HackathonTeamsPage: FC = (): ReactElement => { setGlobalFilter(searchQuery); }, [searchQuery]); - // Handle search submission + // Handle search teams const handleSearch = useCallback(() => { const params = new URLSearchParams(); params.set('page', '1'); @@ -157,11 +154,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => { setShowNewTeamModal(false); }, []); - const handleCloseSubmissionModal = useCallback(() => { - setShowSubmissionModal(false); - setSelectedSubmissionTeam(null); - }, []); - // Get teams data from API response const filteredData = useMemo(() => { return teamsResponse?.data || []; @@ -448,16 +440,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => { onClose={handleCloseNewTeamModal} team={null} // null indicates creating new team /> - - {/* Submission Modal */} - {selectedSubmissionTeam && ( - - )} ); };