diff --git a/src/App.tsx b/src/App.tsx index a279c7c..cc124b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,8 @@ import Contact from "./features/Contact_And_Support/v1/Pages/Contact"; import ViewEvent from "./features/Events/v1/Pages/ViewEvent"; import LoginPage from "./features/Auth/v1/Pages/LoginPage"; import SignUpPage from "./features/Auth/v1/Pages/SignUpPage"; +import ProjectDetailPage from "./features/Projects/pages/ProjectDetailPage"; +import AdminProjectsPage from "./features/AdminProjects/pages/AdminProjectsPage"; import { startAutoUpdater } from "./system/updater/autoUpdater"; import ProtectedRoute from "./routes/ProtectedRoute"; @@ -31,7 +33,7 @@ function App() { } /> } /> - {/* Protected / Org Layout */} + {/* Protected / Org Routes */} }> {/* Protected Dashboard */} + + + + } + /> + {/* Other routes (not restricted) */} } /> } /> @@ -59,6 +70,16 @@ function App() { } /> } /> + {/* Admin Specific Routes */} + + + + } + /> + 404 Not Found} /> diff --git a/src/features/AdminProjects/components/AnalyticsCards.tsx b/src/features/AdminProjects/components/AnalyticsCards.tsx new file mode 100644 index 0000000..90c4ad2 --- /dev/null +++ b/src/features/AdminProjects/components/AnalyticsCards.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { ProjectAnalytics } from '../types/adminProjects.types'; +import { AreaChart, Area, ResponsiveContainer } from 'recharts'; +import { TrendingUp, TrendingDown, Users, CheckCircle2, XCircle, Clock, BarChart3, AlertTriangle } from 'lucide-react'; +import { cn } from '../../../lib/utils'; + +interface AnalyticsCardsProps { + data: ProjectAnalytics | null; +} + +const AnalyticsCards: React.FC = ({ data }) => { + if (!data) return null; + + const stats = [ + { label: 'Total Projects', value: data.totalProjects, trend: '+12%', trendUp: true, icon: BarChart3, color: 'blue', data: data.trends.total }, + { label: 'Submitted', value: data.submittedProjects, trend: '+5%', trendUp: true, icon: Users, color: 'indigo', data: data.trends.submitted }, + { label: 'Approved', value: data.approvedProjects, trend: '+8%', trendUp: true, icon: CheckCircle2, color: 'emerald', data: data.trends.approved }, + { label: 'Pending', value: data.pendingReviews, trend: '+24%', trendUp: false, icon: Clock, color: 'amber', data: data.trends.pending }, + { label: 'Rejected', value: data.rejectedProjects, trend: '-2%', trendUp: false, icon: XCircle, color: 'rose', data: data.trends.rejected }, + { label: 'Avg Score', value: `${data.averageScore}/10`, trend: '+0.4', trendUp: true, icon: TrendingUp, color: 'violet', data: data.trends.total.map(v => v * 0.1) }, + { label: 'Flagged', value: data.flaggedProjects, trend: '+2', trendUp: false, icon: AlertTriangle, color: 'orange', data: data.trends.rejected }, + ]; + + const colorMap: Record = { + blue: 'from-blue-500/20 to-transparent text-blue-500', + indigo: 'from-indigo-500/20 to-transparent text-indigo-500', + emerald: 'from-emerald-500/20 to-transparent text-emerald-500', + amber: 'from-amber-500/20 to-transparent text-amber-500', + rose: 'from-rose-500/20 to-transparent text-rose-500', + violet: 'from-violet-500/20 to-transparent text-violet-500', + orange: 'from-orange-500/20 to-transparent text-orange-500', + }; + + const chartColorMap: Record = { + blue: '#3b82f6', + indigo: '#6366f1', + emerald: '#10b881', + amber: '#f59e0b', + rose: '#f43f5e', + violet: '#8b5cf6', + orange: '#f97316', + }; + + return ( +
+ {stats.map((stat, i) => ( +
+
+
+ +
+
+ {stat.trendUp ? : } + {stat.trend} +
+
+ +
+

{stat.label}

+

{stat.value}

+
+ + {/* Mini Chart */} +
+ + ({ v }))}> + + + + + + + + + +
+
+ ))} +
+ ); +}; + +export default AnalyticsCards; diff --git a/src/features/AdminProjects/components/AssignJudgeModal.tsx b/src/features/AdminProjects/components/AssignJudgeModal.tsx new file mode 100644 index 0000000..63adab5 --- /dev/null +++ b/src/features/AdminProjects/components/AssignJudgeModal.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import { X, Search, Check, Info } from 'lucide-react'; +import { ProjectJudge } from '../types/adminProjects.types'; +import { cn } from '../../../lib/utils'; + +interface AssignJudgeModalProps { + isOpen: boolean; + onClose: () => void; + judges: ProjectJudge[]; + onAssign: (judgeIds: string[]) => void; + projectName: string; +} + +const AssignJudgeModal: React.FC = ({ isOpen, onClose, judges, onAssign, projectName }) => { + const [selectedIds, setSelectedIds] = useState([]); + const [search, setSearch] = useState(''); + const [activeTrack, setActiveTrack] = useState('All Tracks'); + + if (!isOpen) return null; + + const toggleJudge = (id: string) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] + ); + }; + + const filteredJudges = judges.filter(j => { + const matchesSearch = j.name.toLowerCase().includes(search.toLowerCase()) || + j.expertise.some(e => e.toLowerCase().includes(search.toLowerCase())); + + const matchesTrack = activeTrack === 'All Tracks' || + j.expertise.some(e => e.toLowerCase() === activeTrack.toLowerCase()) || + (activeTrack === 'AI/ML' && j.expertise.some(e => ['ai', 'ml', 'neural nets'].includes(e.toLowerCase()))); + + return matchesSearch && matchesTrack; + }); + + return ( +
+
+ +
+
+
+

Assign Judges

+

Project: {projectName}

+
+ +
+ +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ {['All Tracks', 'AI/ML', 'Cybersecurity'].map((tag) => ( + + ))} +
+ +
+

Recommended Judges ({filteredJudges.length})

+
+ {filteredJudges.map((judge) => ( +
toggleJudge(judge.id)} + className={cn( + "group relative flex items-center gap-4 p-4 rounded-2xl border transition-all cursor-pointer", + selectedIds.includes(judge.id) + ? "bg-blue-500/5 border-blue-500/30 ring-1 ring-blue-500/30" + : "bg-slate-800/30 border-slate-800 hover:border-slate-700" + )} + > +
+ +
+
+ +
+
+

{judge.name}

+ + {judge.matchPercentage}% Match + +
+

{judge.role}

+
+ {judge.expertise.map(e => ( + + {e} + + ))} +
+
+ +
+ {selectedIds.includes(judge.id) && } +
+
+ ))} +
+
+
+ +
+

+ {selectedIds.length} judge{selectedIds.length !== 1 ? 's' : ''} selected +

+
+ + +
+
+
+
+ ); +}; + +export default AssignJudgeModal; diff --git a/src/features/AdminProjects/components/BulkActionBar.tsx b/src/features/AdminProjects/components/BulkActionBar.tsx new file mode 100644 index 0000000..22799d6 --- /dev/null +++ b/src/features/AdminProjects/components/BulkActionBar.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { CheckCircle, XCircle, UserPlus, Trash2, Download, X } from 'lucide-react'; + +interface BulkActionBarProps { + selectedCount: number; + onClear: () => void; + onApprove: () => void; + onReject: () => void; + onAssignJudges: () => void; + onDelete: () => void; +} + +const BulkActionBar: React.FC = ({ + selectedCount, + onClear, + onApprove, + onReject, + onAssignJudges, + onDelete +}) => { + if (selectedCount === 0) return null; + + return ( +
+
+
+
+ {selectedCount} +
+ Projects Selected + +
+ +
+ + + + + + +
+ + + + +
+
+
+ ); +}; + +export default BulkActionBar; diff --git a/src/features/AdminProjects/components/EmptyState.tsx b/src/features/AdminProjects/components/EmptyState.tsx new file mode 100644 index 0000000..bd63de0 --- /dev/null +++ b/src/features/AdminProjects/components/EmptyState.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Search, FolderX, Filter } from 'lucide-react'; + +interface EmptyStateProps { + type?: 'no-results' | 'no-data'; + onClearFilters?: () => void; +} + +const EmptyState: React.FC = ({ type = 'no-results', onClearFilters }) => { + return ( +
+
+
+
+ {type === 'no-results' ? ( + + ) : ( + + )} +
+ ! +
+
+
+ +

+ {type === 'no-results' ? 'No projects found' : 'No projects yet'} +

+

+ {type === 'no-results' + ? "We couldn't find any projects matching your current filters. Try refining your search or clearing all active parameters to start fresh." + : "There are no projects in this organization yet. New submissions will appear here once they are received."} +

+ + {type === 'no-results' && onClearFilters && ( +
+ + +
+ )} +
+ ); +}; + +export default EmptyState; diff --git a/src/features/AdminProjects/components/FilterSidebar.tsx b/src/features/AdminProjects/components/FilterSidebar.tsx new file mode 100644 index 0000000..d4ed1bd --- /dev/null +++ b/src/features/AdminProjects/components/FilterSidebar.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { X } from 'lucide-react'; +import { ProjectFilters } from '../types/adminProjects.types'; + +interface FilterSidebarProps { + filters: ProjectFilters; + setFilters: (filters: ProjectFilters) => void; +} + +const FilterSidebar: React.FC = ({ filters, setFilters }) => { + const activeFilters = [ + { key: 'status', value: filters.status, label: `Status: ${filters.status}` }, + { key: 'event', value: filters.event, label: `Event: ${filters.event}` }, + { key: 'track', value: filters.track, label: `Track: ${filters.track}` }, + { key: 'judgeAssignment', value: filters.judgeAssignment, label: `Judges: ${filters.judgeAssignment}` }, + ].filter(f => f.value !== 'All'); + + if (activeFilters.length === 0) return null; + + const removeFilter = (key: string) => { + setFilters({ ...filters, [key]: 'All' }); + }; + + const clearAll = () => { + setFilters({ + ...filters, + status: 'All', + event: 'All', + track: 'All', + judgeAssignment: 'All' + }); + }; + + return ( +
+ {activeFilters.map((filter) => ( +
+ {filter.label} + +
+ ))} + +
+ ); +}; + +export default FilterSidebar; diff --git a/src/features/AdminProjects/components/LoadingSkeleton.tsx b/src/features/AdminProjects/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..c53117d --- /dev/null +++ b/src/features/AdminProjects/components/LoadingSkeleton.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +const LoadingSkeleton: React.FC<{ view?: 'table' | 'grid' }> = ({ view = 'table' }) => { + if (view === 'grid') { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + return ( +
+
+ {Array.from({ length: 10 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +}; + +export default LoadingSkeleton; diff --git a/src/features/AdminProjects/components/ProjectStatusBadge.tsx b/src/features/AdminProjects/components/ProjectStatusBadge.tsx new file mode 100644 index 0000000..98c7c33 --- /dev/null +++ b/src/features/AdminProjects/components/ProjectStatusBadge.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { ProjectStatus } from '../types/adminProjects.types'; +import { PROJECT_STATUS_COLORS } from '../constants/projectStatus.constants'; +import { cn } from '../../../lib/utils'; + +interface ProjectStatusBadgeProps { + status: ProjectStatus; + className?: string; +} + +const ProjectStatusBadge: React.FC = ({ status, className }) => { + const styles = PROJECT_STATUS_COLORS[status] || PROJECT_STATUS_COLORS.Pending; + + return ( +
+ + {status} +
+ ); +}; + +export default ProjectStatusBadge; diff --git a/src/features/AdminProjects/components/ProjectsGrid.tsx b/src/features/AdminProjects/components/ProjectsGrid.tsx new file mode 100644 index 0000000..be37df4 --- /dev/null +++ b/src/features/AdminProjects/components/ProjectsGrid.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { MoreVertical, Star, Users, CheckCircle, Clock } from 'lucide-react'; +import { Project } from '../types/adminProjects.types'; +import ProjectStatusBadge from './ProjectStatusBadge'; +import { cn } from '../../../lib/utils'; + +interface ProjectsGridProps { + projects: Project[]; + selectedIds: string[]; + onSelect: (id: string) => void; + onViewProject: (project: Project) => void; + onAction: (id: string, action: string) => void; +} + +const ProjectsGrid: React.FC = ({ + projects, + selectedIds, + onSelect, + onViewProject, + onAction, +}) => { + return ( +
+ {projects.map((project) => ( +
onViewProject(project)} + > + {/* Checkbox Overlay */} +
{ e.stopPropagation(); onSelect(project.id); }} + > +
+ {selectedIds.includes(project.id) && } +
+
+ + {/* Thumbnail */} +
+ {project.name} +
+ +
+ +
+ + {project.avgScore} +
+
+
+ + {/* Content */} +
+
+

+ {project.name} +

+ +
+ +

{project.teamName}

+ +
+
+ {project.assignedJudges.map((judge, i) => ( + + ))} + {project.assignedJudges.length === 0 && ( +
+ +
+ )} +
+ +
+ + Updated 2h ago +
+
+
+
+ ))} +
+ ); +}; + +export default ProjectsGrid; diff --git a/src/features/AdminProjects/components/ProjectsTable.tsx b/src/features/AdminProjects/components/ProjectsTable.tsx new file mode 100644 index 0000000..5e6d0c5 --- /dev/null +++ b/src/features/AdminProjects/components/ProjectsTable.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { MoreVertical, ExternalLink, Shield, Trash2, CheckCircle, XCircle, Users } from 'lucide-react'; +import { Project } from '../types/adminProjects.types'; +import ProjectStatusBadge from './ProjectStatusBadge'; +import { format } from 'date-fns'; +import { cn } from '../../../lib/utils'; + +interface ProjectsTableProps { + projects: Project[]; + selectedIds: string[]; + onSelect: (id: string) => void; + onSelectAll: () => void; + onViewProject: (project: Project) => void; + onAction: (id: string, action: string) => void; + pagination: { page: number; pageSize: number }; + setPagination: (pagination: { page: number; pageSize: number }) => void; +} + +const ProjectsTable: React.FC = ({ + projects, + selectedIds, + onSelect, + onSelectAll, + onViewProject, + onAction, + pagination, + setPagination, +}) => { + const isAllSelected = projects.length > 0 && selectedIds.length === projects.length; + + return ( +
+
+ + + + + + + + + + + + + + + + {projects.map((project) => ( + onViewProject(project)} + > + + + + + + + + + + + ))} + +
+
+ {isAllSelected &&
} +
+
Project NameTeamEventTrackStatusAvg ScoreJudges
{ e.stopPropagation(); onSelect(project.id); }}> +
+ {selectedIds.includes(project.id) && } +
+
+
+
+ +
+
+
{project.name}
+
{format(new Date(project.submissionDate), 'MMM d, h:mm a')}
+
+
+
+ {project.teamName} + + {project.eventName} + + + {project.track} + + + + +
+ {project.avgScore} + /10 +
+
+
+ {project.assignedJudges.map((judge, i) => ( + + ))} + {project.assignedJudges.length === 0 && Unassigned} +
+
e.stopPropagation()}> +
+ +
+ + + +
+ + +
+
+
+
+ + {/* Pagination Placeholder */} +
+
+ Page {pagination.page} +
+
+ + +
+
+
+ ); +}; + +export default ProjectsTable; diff --git a/src/features/AdminProjects/components/SearchBar.tsx b/src/features/AdminProjects/components/SearchBar.tsx new file mode 100644 index 0000000..7780dc0 --- /dev/null +++ b/src/features/AdminProjects/components/SearchBar.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Search, Filter, RotateCcw, LayoutGrid, List } from 'lucide-react'; +import { ProjectFilters } from '../types/adminProjects.types'; +import { TRACKS, EVENTS } from '../constants/projectStatus.constants'; + +interface SearchBarProps { + filters: ProjectFilters; + setFilters: (filters: ProjectFilters) => void; + view: 'table' | 'grid'; + setView: (view: 'table' | 'grid') => void; + onRefresh: () => void; +} + +const SearchBar: React.FC = ({ filters, setFilters, view, setView, onRefresh }) => { + return ( +
+
+
+ +
+ setFilters({ ...filters, search: e.target.value })} + /> +
+ +
+ + + + + + + + +
+ +
+ + +
+
+
+ ); +}; + +export default SearchBar; diff --git a/src/features/AdminProjects/constants/projectStatus.constants.ts b/src/features/AdminProjects/constants/projectStatus.constants.ts new file mode 100644 index 0000000..1bc27e0 --- /dev/null +++ b/src/features/AdminProjects/constants/projectStatus.constants.ts @@ -0,0 +1,51 @@ +import { ProjectStatus } from "../types/adminProjects.types"; + +export const PROJECT_STATUS_COLORS: Record = { + Pending: { + bg: 'bg-amber-500/10', + text: 'text-amber-500', + border: 'border-amber-500/20', + glow: 'shadow-[0_0_15px_rgba(245,158,11,0.1)]', + }, + Approved: { + bg: 'bg-emerald-500/10', + text: 'text-emerald-500', + border: 'border-emerald-500/20', + glow: 'shadow-[0_0_15px_rgba(16,185,129,0.1)]', + }, + Rejected: { + bg: 'bg-rose-500/10', + text: 'text-rose-500', + border: 'border-rose-500/20', + glow: 'shadow-[0_0_15px_rgba(244,63,94,0.1)]', + }, + Suspended: { + bg: 'bg-slate-500/10', + text: 'text-slate-500', + border: 'border-slate-500/20', + glow: 'shadow-[0_0_15px_rgba(100,116,139,0.1)]', + }, + Draft: { + bg: 'bg-blue-500/10', + text: 'text-blue-500', + border: 'border-blue-500/20', + glow: 'shadow-[0_0_15px_rgba(59,130,246,0.1)]', + }, +}; + +export const TRACKS = [ + 'AI & ML', + 'Web3', + 'Sustainability', + 'FinTech', + 'HealthTech', + 'Cybersecurity', + 'Open Innovation', +]; + +export const EVENTS = [ + 'Global Hackathon 2026', + 'Nexus AI Summit', + 'Code for Change', + 'DeFi Builders 2025', +]; diff --git a/src/features/AdminProjects/mock/mockAdminProjectsApi.ts b/src/features/AdminProjects/mock/mockAdminProjectsApi.ts new file mode 100644 index 0000000..9e91fa0 --- /dev/null +++ b/src/features/AdminProjects/mock/mockAdminProjectsApi.ts @@ -0,0 +1,98 @@ +import { Project, ProjectAnalytics, ProjectJudge } from "../types/adminProjects.types"; + +const MOCK_JUDGES: ProjectJudge[] = [ + { id: 'j1', name: 'Elena Soros', role: 'Senior AI Researcher @ NeuralLabs', expertise: ['AI/ML', 'Neural Nets'], matchPercentage: 98 }, + { id: 'j2', name: 'Marcus Kovic', role: 'Lead Data Architect @ Prism', expertise: ['AI/ML', 'Data Scale'], matchPercentage: 92 }, + { id: 'j3', name: 'Jane Chen', role: 'Principal Engineer @ FutureLogic', expertise: ['AI/ML', 'Logistics'], matchPercentage: 85 }, + { id: 'j4', name: 'Alex Rivera', role: 'System Architect @ CommDesk', expertise: ['Web3', 'Security'], matchPercentage: 75 }, +]; + +const generateProjects = (count: number): Project[] => { + const names = ['NeuroSense AI', 'DecentralCart', 'GreenGrid Ops', 'MindFlow App', 'CipherVault', 'EcoTrack', 'QuantumPay', 'BioSync']; + const teams = ['Synapse Labs', 'EtherDevs', 'EcoCoders', 'Neural Knights', 'Security Squad', 'Earth Keepers', 'Qubit Team', 'Health Pioneers']; + const statuses: Project['status'][] = ['Approved', 'Pending', 'Rejected', 'Suspended']; + const tracks = ['AI & ML', 'Web3', 'Sustainability', 'FinTech', 'HealthTech']; + + return Array.from({ length: count }, (_, i) => ({ + id: `proj-${i + 1}`, + name: names[i % names.length] + (i > 7 ? ` ${Math.floor(i / 8)}` : ''), + description: 'A revolutionary project built during the Global Hackathon 2026 focusing on solving real-world problems using cutting-edge technology.', + teamName: teams[i % teams.length], + eventName: 'Global Hackathon 2026', + track: tracks[i % tracks.length], + status: statuses[i % statuses.length], + avgScore: parseFloat((Math.random() * 4 + 6).toFixed(1)), // 6.0 to 10.0 + assignedJudges: [MOCK_JUDGES[i % MOCK_JUDGES.length]], + submissionDate: new Date(Date.now() - Math.random() * 1000000000).toISOString(), + lastUpdated: new Date().toISOString(), + isFlagged: Math.random() > 0.9, + techStack: ['React', 'TypeScript', 'Node.js', 'PostgreSQL'], + thumbnail: `https://picsum.photos/seed/${i}/400/225`, + })); +}; + +const MOCK_PROJECTS = generateProjects(50); + +export const mockAdminProjectsApi = { + getProjects: async (filters: any, pagination: { page: number, pageSize: number }) => { + await new Promise(r => setTimeout(r, 800)); // Simulate network lag + + let filtered = [...MOCK_PROJECTS]; + + if (filters.search) { + const s = filters.search.toLowerCase(); + filtered = filtered.filter(p => + p.name.toLowerCase().includes(s) || + p.teamName.toLowerCase().includes(s) || + p.track.toLowerCase().includes(s) + ); + } + + if (filters.status && filters.status !== 'All') { + filtered = filtered.filter(p => p.status === filters.status); + } + + if (filters.track && filters.track !== 'All') { + filtered = filtered.filter(p => p.track === filters.track); + } + + const start = (pagination.page - 1) * pagination.pageSize; + return { + projects: filtered.slice(start, start + pagination.pageSize), + totalCount: filtered.length, + }; + }, + + getAnalytics: async (): Promise => { + return { + totalProjects: 1248, + submittedProjects: 842, + approvedProjects: 312, + rejectedProjects: 42, + pendingReviews: 156, + averageScore: 8.4, + flaggedProjects: 12, + trends: { + total: [40, 45, 52, 58, 65, 72, 80], + submitted: [30, 32, 38, 42, 48, 52, 60], + approved: [10, 12, 15, 18, 22, 25, 30], + pending: [20, 20, 23, 24, 26, 27, 30], + rejected: [2, 3, 2, 4, 3, 5, 4], + } + }; + }, + + getJudges: async (): Promise => { + return MOCK_JUDGES; + }, + + bulkUpdateStatus: async (projectIds: string[], status: Project['status']) => { + await new Promise(r => setTimeout(r, 500)); + return { success: true }; + }, + + assignJudges: async (projectIds: string[], judgeIds: string[]) => { + await new Promise(r => setTimeout(r, 500)); + return { success: true }; + } +}; diff --git a/src/features/AdminProjects/pages/AdminProjectsPage.tsx b/src/features/AdminProjects/pages/AdminProjectsPage.tsx new file mode 100644 index 0000000..817a231 --- /dev/null +++ b/src/features/AdminProjects/pages/AdminProjectsPage.tsx @@ -0,0 +1,236 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Plus, Download, Filter } from 'lucide-react'; +import { adminProjectsService } from '../services/adminProjects.service'; +import { Project, ProjectAnalytics, ProjectFilters, ProjectJudge } from '../types/adminProjects.types'; + +import AnalyticsCards from '../components/AnalyticsCards'; +import SearchBar from '../components/SearchBar'; +import FilterSidebar from '../components/FilterSidebar'; +import ProjectsTable from '../components/ProjectsTable'; +import ProjectsGrid from '../components/ProjectsGrid'; +import BulkActionBar from '../components/BulkActionBar'; +import AssignJudgeModal from '../components/AssignJudgeModal'; +import EmptyState from '../components/EmptyState'; +import LoadingSkeleton from '../components/LoadingSkeleton'; + +const AdminProjectsPage: React.FC = () => { + // State + const [projects, setProjects] = useState([]); + const [analytics, setAnalytics] = useState(null); + const [judges, setJudges] = useState([]); + const [loading, setLoading] = useState(true); + const [view, setView] = useState<'table' | 'grid'>('table'); + const [selectedIds, setSelectedIds] = useState([]); + const [isJudgeModalOpen, setIsJudgeModalOpen] = useState(false); + const [activeProjectForJudge, setActiveProjectForJudge] = useState(null); + + const [filters, setFilters] = useState({ + search: '', + status: 'All', + event: 'All', + track: 'All', + scoreRange: [0, 10], + judgeAssignment: 'All' + }); + + const [pagination, setPagination] = useState({ + page: 1, + pageSize: 10 + }); + + const [debouncedSearch, setDebouncedSearch] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(filters.search); + }, 500); + return () => clearTimeout(timer); + }, [filters.search]); + + // Fetch Data + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [projectsData, analyticsData, judgesData] = await Promise.all([ + adminProjectsService.fetchProjects({ ...filters, search: debouncedSearch }, pagination), + adminProjectsService.fetchAnalytics(), + adminProjectsService.fetchJudges() + ]); + setProjects(projectsData.projects); + setAnalytics(analyticsData); + setJudges(judgesData); + } catch (error) { + console.error('Failed to fetch projects:', error); + } finally { + setLoading(false); + } + }, [debouncedSearch, filters.status, filters.event, filters.track, filters.scoreRange, filters.judgeAssignment, pagination]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Handlers + const handleSelect = (id: string) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] + ); + }; + + const handleSelectAll = () => { + if (selectedIds.length === projects.length) { + setSelectedIds([]); + } else { + setSelectedIds(projects.map(p => p.id)); + } + }; + + const handleAction = (id: string, action: string) => { + const project = projects.find(p => p.id === id); + if (!project) return; + + switch (action) { + case 'approve': + updateProjectStatus([id], 'Approved'); + break; + case 'reject': + updateProjectStatus([id], 'Rejected'); + break; + case 'assign': + setActiveProjectForJudge(project); + setIsJudgeModalOpen(true); + break; + case 'delete': + setProjects(prev => prev.filter(p => p.id !== id)); + break; + default: + console.log(`Action ${action} for ${id}`); + } + }; + + const updateProjectStatus = async (ids: string[], status: Project['status']) => { + try { + await adminProjectsService.bulkUpdateStatus(ids, status); + setProjects(prev => prev.map(p => + ids.includes(p.id) ? { ...p, status } : p + )); + setSelectedIds([]); + // Show success toast (not implemented here but good to have) + } catch (err) { + console.error(err); + } + }; + + const handleAssignJudges = async (judgeIds: string[]) => { + const targetIds = activeProjectForJudge ? [activeProjectForJudge.id] : selectedIds; + try { + await adminProjectsService.assignJudges(targetIds, judgeIds); + setIsJudgeModalOpen(false); + setActiveProjectForJudge(null); + setSelectedIds([]); + fetchData(); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ {/* Header Section */} +
+
+

Projects Management

+

Monitor and moderate global hackathon submissions across all tracks.

+
+
+ + +
+
+ + {/* Analytics Section */} + + + {/* Main Content Area */} +
+ + + + + {loading ? ( + + ) : projects.length > 0 ? ( + view === 'table' ? ( + console.log('View', p)} + onAction={handleAction} + pagination={pagination} + setPagination={setPagination} + /> + ) : ( + console.log('View', p)} + onAction={handleAction} + /> + ) + ) : ( + setFilters({ ...filters, search: '', status: 'All', event: 'All', track: 'All' })} + /> + )} +
+ + {/* Modals & Floating UI */} + setSelectedIds([])} + onApprove={() => updateProjectStatus(selectedIds, 'Approved')} + onReject={() => updateProjectStatus(selectedIds, 'Rejected')} + onAssignJudges={() => { + setActiveProjectForJudge(null); + setIsJudgeModalOpen(true); + }} + onDelete={() => { + setProjects(prev => prev.filter(p => !selectedIds.includes(p.id))); + setSelectedIds([]); + }} + /> + + { + setIsJudgeModalOpen(false); + setActiveProjectForJudge(null); + }} + judges={judges} + projectName={activeProjectForJudge?.name || `${selectedIds.length} Selected Projects`} + onAssign={handleAssignJudges} + /> +
+ ); +}; + +export default AdminProjectsPage; diff --git a/src/features/AdminProjects/services/adminProjects.service.ts b/src/features/AdminProjects/services/adminProjects.service.ts new file mode 100644 index 0000000..239b7ed --- /dev/null +++ b/src/features/AdminProjects/services/adminProjects.service.ts @@ -0,0 +1,28 @@ +import { mockAdminProjectsApi } from "../mock/mockAdminProjectsApi"; +import { PaginationParams, ProjectFilters, ProjectStatus } from "../types/adminProjects.types"; + +export const adminProjectsService = { + fetchProjects: async (filters: ProjectFilters, pagination: PaginationParams) => { + return await mockAdminProjectsApi.getProjects(filters, pagination); + }, + + fetchAnalytics: async () => { + return await mockAdminProjectsApi.getAnalytics(); + }, + + fetchJudges: async () => { + return await mockAdminProjectsApi.getJudges(); + }, + + updateProjectStatus: async (projectId: string, status: ProjectStatus) => { + return await mockAdminProjectsApi.bulkUpdateStatus([projectId], status); + }, + + bulkUpdateStatus: async (projectIds: string[], status: ProjectStatus) => { + return await mockAdminProjectsApi.bulkUpdateStatus(projectIds, status); + }, + + assignJudges: async (projectIds: string[], judgeIds: string[]) => { + return await mockAdminProjectsApi.assignJudges(projectIds, judgeIds); + } +}; diff --git a/src/features/AdminProjects/types/adminProjects.types.ts b/src/features/AdminProjects/types/adminProjects.types.ts new file mode 100644 index 0000000..afd4f46 --- /dev/null +++ b/src/features/AdminProjects/types/adminProjects.types.ts @@ -0,0 +1,68 @@ +export type ProjectStatus = 'Pending' | 'Approved' | 'Rejected' | 'Suspended' | 'Draft'; + +export interface ProjectJudge { + id: string; + name: string; + avatar?: string; + expertise: string[]; + matchPercentage?: number; + role: string; +} + +export interface ProjectAnalytics { + totalProjects: number; + submittedProjects: number; + approvedProjects: number; + rejectedProjects: number; + pendingReviews: number; + averageScore: number; + flaggedProjects: number; + trends: { + total: number[]; + submitted: number[]; + approved: number[]; + pending: number[]; + rejected: number[]; + }; +} + +export interface Project { + id: string; + name: string; + description: string; + thumbnail?: string; + teamName: string; + eventName: string; + track: string; + status: ProjectStatus; + avgScore: number; + assignedJudges: ProjectJudge[]; + submissionDate: string; + lastUpdated: string; + isFlagged: boolean; + techStack: string[]; + githubUrl?: string; + liveDemoUrl?: string; +} + +export interface ProjectFilters { + search: string; + status: ProjectStatus | 'All'; + event: string | 'All'; + track: string | 'All'; + scoreRange: [number, number]; + judgeAssignment: 'All' | 'Assigned' | 'Unassigned'; +} + +export interface PaginationParams { + page: number; + pageSize: number; +} + +export interface AdminProjectsState { + projects: Project[]; + loading: boolean; + error: string | null; + totalCount: number; + analytics: ProjectAnalytics | null; +} diff --git a/src/features/Dashboard/mock/dashboardData.ts b/src/features/Dashboard/mock/dashboardData.ts index 8cafb70..e5bceab 100644 --- a/src/features/Dashboard/mock/dashboardData.ts +++ b/src/features/Dashboard/mock/dashboardData.ts @@ -3,7 +3,7 @@ import { DashboardData } from "../types/dashboard"; export const dashboardData: DashboardData = { user: { name: "Arjun Mehta", - role: "Member", + role: "Admin", }, summary: { diff --git a/src/features/Projects/components/Header.tsx b/src/features/Projects/components/Header.tsx new file mode 100644 index 0000000..9ec9325 --- /dev/null +++ b/src/features/Projects/components/Header.tsx @@ -0,0 +1,168 @@ +import { CalendarClock, PencilLine, Rocket, ShieldCheck, Trash2, XCircle } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Project_Permissions } from "@/features/Projects/constants/permission.constants"; +import type { ProjectRecord, UserRole, ViewerContext } from "@/features/Projects/types/project.types"; +import { hasPermission } from "@/features/Projects/utils/permission.utils"; +import { Button } from "@/shadcnComponet/ui/button"; + +type HeaderProps = { + project: ProjectRecord; + viewer: ViewerContext; + isWorking: boolean; + onEdit: () => void; + onDelete: () => void; + onSubmit: () => void; + onApprove: () => void; + onReject: () => void; +}; + +const statusTheme: Record = { + draft: "bg-slate-100/50 text-slate-700 ring-slate-200/50", + submitted: "bg-amber-100/50 text-amber-800 ring-amber-200/50", + under_review: "bg-sky-100/50 text-sky-800 ring-sky-200/50", + approved: "bg-emerald-100/50 text-emerald-800 ring-emerald-200/50", + rejected: "bg-rose-100/50 text-rose-800 ring-rose-200/50", +}; + +const roleLabel: Record = { + participant: "Participant", + judge: "Judge", + organizer: "Organizer", + admin: "Admin", +}; + +function getStatusLabel(status: ProjectRecord["status"]) { + return status.replace("_", " ").replace(/\b\w/g, (value) => value.toUpperCase()); +} + +export default function Header({ + project, + viewer, + isWorking, + onEdit, + onDelete, + onSubmit, + onApprove, + onReject, +}: HeaderProps) { + const canSubmit = + hasPermission(viewer.permissions, Project_Permissions.SUBMIT_PROJECT) && + project.status === "draft"; + const canEdit = + hasPermission(viewer.permissions, Project_Permissions.UPDATE_PROJECT) && + project.status === "draft"; + const canDelete = + hasPermission(viewer.permissions, Project_Permissions.DELETE_PROJECT) && + (project.status === "draft" || + viewer.role === "admin" || + viewer.role === "organizer"); + const canModerate = + hasPermission(viewer.permissions, Project_Permissions.APPROVE_PROJECT) && + (project.status === "submitted" || project.status === "under_review"); + + return ( +
+ {/* Decorative Background Elements */} +
+
+
+ +
+
+
+ + {getStatusLabel(project.status)} + + + + {roleLabel[viewer.role]} Mode + +
+ +
+
+ +

{project.eventType}

+
+

+ {project.title} +

+
+

+

+ +
+ {project.eventName} +

+
+
+
+ +
+ {canSubmit && ( + + )} + + {canEdit && ( + + )} + + {canModerate && ( +
+ + +
+ )} + + {canDelete && ( + + )} +
+
+
+ ); +} diff --git a/src/features/Projects/components/ModerationPanel.tsx b/src/features/Projects/components/ModerationPanel.tsx new file mode 100644 index 0000000..24e7131 --- /dev/null +++ b/src/features/Projects/components/ModerationPanel.tsx @@ -0,0 +1,104 @@ +import { AlertTriangle, CheckCircle2, ShieldAlert, Trash2, XCircle } from "lucide-react"; + +import type { ProjectRecord, ScoreSummary } from "@/features/Projects/types/project.types"; +import { Button } from "@/shadcnComponet/ui/button"; +import { cn } from "@/lib/utils"; + +type ModerationPanelProps = { + project: ProjectRecord; + scoreSummary: ScoreSummary; + isWorking: boolean; + onApprove: () => void; + onReject: () => void; + onDelete: () => void; +}; + +export default function ModerationPanel({ + project, + scoreSummary, + isWorking, + onApprove, + onReject, + onDelete, +}: ModerationPanelProps) { + return ( +
+
+
+
+

Moderator Suite

+

Organizer Controls

+
+
+ +
+
+ +
+

+ + Decision Context +

+ +
+
+ Current Status + + {project.status.replace("_", " ")} + +
+ +
+ Average Score +
+ + {scoreSummary.averageScore === null ? "Pending" : scoreSummary.averageScore.toFixed(1)} + + {scoreSummary.averageScore !== null && / 40} +
+
+ +
+ Judges Reviewing + {scoreSummary.judgeCount} +
+
+
+ +
+
+ + +
+ +
+
+
+ ); +} diff --git a/src/features/Projects/components/Overview.tsx b/src/features/Projects/components/Overview.tsx new file mode 100644 index 0000000..81f98c9 --- /dev/null +++ b/src/features/Projects/components/Overview.tsx @@ -0,0 +1,268 @@ +import { Globe, Github, Save, X, Layout, Code2, ExternalLink } from "lucide-react"; +import { useState } from "react"; + +import type { ProjectRecord, ProjectUpdateInput } from "@/features/Projects/types/project.types"; +import { Button } from "@/shadcnComponet/ui/button"; + +type OverviewProps = { + project: ProjectRecord; + canEdit: boolean; + isEditing: boolean; + isSaving: boolean; + validationErrors: string[]; + onEditingChange: (editing: boolean) => void; + onSave: (values: ProjectUpdateInput) => Promise; +}; + +type FormState = { + title: string; + description: string; + techStack: string; + repositoryUrl: string; + demoUrl: string; +}; + +function toFormState(project: ProjectRecord): FormState { + return { + title: project.title, + description: project.description, + techStack: project.techStack.join(", "), + repositoryUrl: project.repositoryUrl, + demoUrl: project.demoUrl, + }; +} + +export default function Overview({ + project, + canEdit, + isEditing, + isSaving, + validationErrors, + onEditingChange, + onSave, +}: OverviewProps) { + const [form, setForm] = useState(() => toFormState(project)); + + async function handleSubmit() { + await onSave({ + title: form.title, + description: form.description, + techStack: form.techStack + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + repositoryUrl: form.repositoryUrl, + demoUrl: form.demoUrl, + }); + } + + if (isEditing && canEdit) { + return ( +
+
+
+
+
+ +
+
+

Workspace

+

Project Editor

+
+
+
+ + +
+
+ + {validationErrors.length > 0 && ( +
+

+ + Validation Errors +

+
    + {validationErrors.map((error) => ( +
  • • {error}
  • + ))} +
+
+ )} +
+ +
+
+
+ + setForm((current) => ({ ...current, title: event.target.value }))} + /> +
+ +
+ +