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 */} +
+ +