606 lines
26 KiB
TypeScript
606 lines
26 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, Suspense } from "react"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { BarChart3, Users, Trophy, TrendingUp, ArrowLeft, RotateCcw, Download, Eye, Maximize2, Minimize2, Table } from "lucide-react"
|
|
import Link from "next/link"
|
|
import Image from "next/image"
|
|
import { AuthGuard } from "@/components/auth-guard"
|
|
import { useAuth } from "@/hooks/use-auth"
|
|
import { DashboardHeader } from "@/components/dashboard-header"
|
|
import apiClient from "@/lib/api-client"
|
|
import { API_CONFIG } from "@/lib/config"
|
|
import { useToast } from "@/hooks/use-toast"
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'
|
|
|
|
interface VoteEvent {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
start_date: string
|
|
end_date: string
|
|
is_active: boolean
|
|
is_voting_open: boolean
|
|
}
|
|
|
|
interface Candidate {
|
|
id: string
|
|
vote_event_id: string
|
|
name: string
|
|
image_url: string
|
|
description: string
|
|
created_at: string
|
|
updated_at: string
|
|
vote_count: number
|
|
}
|
|
|
|
interface VoteResults {
|
|
vote_event_id: string
|
|
candidates: Candidate[]
|
|
total_votes: number
|
|
}
|
|
|
|
interface ChartData {
|
|
name: string
|
|
votes: number
|
|
percentage: number
|
|
fill: string
|
|
}
|
|
|
|
function ResultsPageContent() {
|
|
const { user } = useAuth()
|
|
const { toast } = useToast()
|
|
const [events, setEvents] = useState<VoteEvent[]>([])
|
|
const [selectedEventId, setSelectedEventId] = useState<string>("")
|
|
const [results, setResults] = useState<VoteResults | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [eventsLoading, setEventsLoading] = useState(true)
|
|
const [isFullPageChart, setIsFullPageChart] = useState(false)
|
|
const [showCountdown, setShowCountdown] = useState(false)
|
|
const [countdown, setCountdown] = useState(10)
|
|
const [showResults, setShowResults] = useState(false)
|
|
|
|
// Chart colors
|
|
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4', '#84CC16', '#F97316']
|
|
|
|
useEffect(() => {
|
|
fetchEvents()
|
|
}, [])
|
|
|
|
// Note: URL parameter handling removed for now - can be added back later if needed
|
|
|
|
useEffect(() => {
|
|
if (selectedEventId) {
|
|
fetchResults(selectedEventId)
|
|
}
|
|
}, [selectedEventId])
|
|
|
|
useEffect(() => {
|
|
let interval: NodeJS.Timeout
|
|
if (showCountdown && countdown > 0) {
|
|
interval = setInterval(() => {
|
|
setCountdown(prev => prev - 1)
|
|
}, 1000)
|
|
} else if (showCountdown && countdown === 0) {
|
|
setShowCountdown(false)
|
|
setShowResults(true)
|
|
}
|
|
return () => clearInterval(interval)
|
|
}, [showCountdown, countdown])
|
|
|
|
const fetchEvents = async () => {
|
|
try {
|
|
setEventsLoading(true)
|
|
const response = await apiClient.get(API_CONFIG.ENDPOINTS.VOTE_EVENTS)
|
|
if (response.data.success) {
|
|
const eventList = response.data.data.vote_events || []
|
|
setEvents(eventList)
|
|
// Auto-select the first event if available
|
|
if (eventList.length > 0 && !selectedEventId) {
|
|
setSelectedEventId(eventList[0].id)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching events:', error)
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to fetch vote events",
|
|
variant: "destructive"
|
|
})
|
|
} finally {
|
|
setEventsLoading(false)
|
|
}
|
|
}
|
|
|
|
const fetchResults = async (eventId: string) => {
|
|
try {
|
|
setLoading(true)
|
|
const response = await apiClient.get(`${API_CONFIG.ENDPOINTS.RESULTS}/${eventId}/results`)
|
|
if (response.data.success) {
|
|
setResults(response.data.data)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching results:', error)
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to fetch voting results",
|
|
variant: "destructive"
|
|
})
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const getChartData = (): (ChartData & { image_url: string; id: string })[] => {
|
|
if (!results) return []
|
|
|
|
return results.candidates
|
|
.sort((a, b) => b.vote_count - a.vote_count) // Sort by vote count descending
|
|
.map((candidate, index) => ({
|
|
name: candidate.name,
|
|
votes: candidate.vote_count,
|
|
percentage: results.total_votes > 0 ? (candidate.vote_count / results.total_votes) * 100 : 0,
|
|
fill: COLORS[index % COLORS.length],
|
|
image_url: candidate.image_url,
|
|
id: candidate.id
|
|
}))
|
|
}
|
|
|
|
const getWinner = (): Candidate | null => {
|
|
if (!results || results.candidates.length === 0) return null
|
|
return results.candidates.reduce((prev, current) =>
|
|
prev.vote_count > current.vote_count ? prev : current
|
|
)
|
|
}
|
|
|
|
const getEventStatus = (event: VoteEvent) => {
|
|
const now = new Date()
|
|
const start = new Date(event.start_date)
|
|
const end = new Date(event.end_date)
|
|
|
|
if (now < start) return "upcoming"
|
|
if (now >= start && now <= end) return "active"
|
|
return "ended"
|
|
}
|
|
|
|
const getStatusBadge = (event: VoteEvent) => {
|
|
const status = getEventStatus(event)
|
|
|
|
if (event.is_voting_open && status === "active") {
|
|
return <Badge className="bg-green-500 text-white">Live Voting</Badge>
|
|
}
|
|
|
|
switch (status) {
|
|
case "active":
|
|
return <Badge className="bg-orange-500 text-white">Active</Badge>
|
|
case "upcoming":
|
|
return <Badge className="bg-blue-500 text-white">Upcoming</Badge>
|
|
case "ended":
|
|
return <Badge className="bg-gray-500 text-white">Ended</Badge>
|
|
default:
|
|
return <Badge variant="outline">Unknown</Badge>
|
|
}
|
|
}
|
|
|
|
const handleShowResults = () => {
|
|
if (!selectedEventId || !results) {
|
|
toast({
|
|
title: "Error",
|
|
description: "Please select an event with available results",
|
|
variant: "destructive"
|
|
})
|
|
return
|
|
}
|
|
setShowCountdown(true)
|
|
setCountdown(10)
|
|
setShowResults(false)
|
|
}
|
|
|
|
const selectedEvent = events.find(e => e.id === selectedEventId)
|
|
const chartData = getChartData()
|
|
const winner = getWinner()
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<DashboardHeader title="Vote Results" />
|
|
|
|
<div className="container mx-auto px-4 py-8">
|
|
{/* Header Section */}
|
|
<div className="mb-8">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Voting Results</h1>
|
|
<p className="text-gray-600 mt-1">View detailed voting results and statistics</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{results && (
|
|
<>
|
|
<Button
|
|
onClick={() => setIsFullPageChart(!isFullPageChart)}
|
|
variant={isFullPageChart ? "default" : "outline"}
|
|
className="gap-2"
|
|
>
|
|
{isFullPageChart ? (
|
|
<>
|
|
<Table className="h-4 w-4" />
|
|
Table View
|
|
</>
|
|
) : (
|
|
<>
|
|
<Maximize2 className="h-4 w-4" />
|
|
Full Chart
|
|
</>
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button onClick={fetchEvents} variant="outline" className="gap-2">
|
|
<RotateCcw className="h-4 w-4" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Event Selection */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<BarChart3 className="h-5 w-5" />
|
|
Select Voting Event
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Choose an event to view its voting results and statistics
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid md:grid-cols-2 gap-4 items-end">
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700 mb-2 block">
|
|
Vote Event
|
|
</label>
|
|
<Select value={selectedEventId} onValueChange={setSelectedEventId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a vote event" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{events.map((event) => (
|
|
<SelectItem key={event.id} value={event.id}>
|
|
<div className="flex items-center justify-between w-full">
|
|
<span>{event.title}</span>
|
|
<div className="ml-2">
|
|
{getStatusBadge(event)}
|
|
</div>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedEvent && (
|
|
<div className="space-y-2">
|
|
<div className="text-sm text-gray-600">
|
|
<strong>Event:</strong> {selectedEvent.title}
|
|
</div>
|
|
<div className="text-sm text-gray-600">
|
|
<strong>Period:</strong> {new Date(selectedEvent.start_date).toLocaleDateString()} - {new Date(selectedEvent.end_date).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Loading State */}
|
|
{loading && (
|
|
<div className="text-center py-12">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600">Loading results...</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Results Content */}
|
|
{!loading && selectedEventId && results && (
|
|
<>
|
|
{/* Show Results Button */}
|
|
{!showCountdown && !showResults && (
|
|
<Card>
|
|
<CardContent className="text-center py-16">
|
|
<Trophy className="h-16 w-16 text-blue-500 mx-auto mb-6" />
|
|
<h2 className="text-2xl font-bold mb-4">Ready to Reveal Results?</h2>
|
|
<p className="text-gray-600 mb-8">
|
|
Click the button below to see the voting results for "{selectedEvent?.title}"
|
|
</p>
|
|
<Button
|
|
onClick={handleShowResults}
|
|
size="lg"
|
|
className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-8 py-3 text-lg"
|
|
>
|
|
<BarChart3 className="mr-2 h-5 w-5" />
|
|
Show Results
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Countdown Animation */}
|
|
{showCountdown && (
|
|
<Card>
|
|
<CardContent className="text-center py-24">
|
|
<div className="relative">
|
|
<div className={`text-8xl font-bold mb-4 transition-all duration-1000 ${
|
|
countdown <= 3 ? 'text-red-500 scale-125' : 'text-blue-500 scale-100'
|
|
}`}>
|
|
{countdown}
|
|
</div>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className={`w-32 h-32 border-4 border-blue-200 rounded-full animate-pulse ${
|
|
countdown <= 3 ? 'border-red-200' : ''
|
|
}`}></div>
|
|
</div>
|
|
</div>
|
|
<p className="text-xl text-gray-600 animate-bounce">
|
|
{countdown > 5 ? 'Preparing results...' :
|
|
countdown > 3 ? 'Almost ready...' : 'Here we go!'}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Animated Results Display */}
|
|
{showResults && (
|
|
<div className="space-y-8">
|
|
{/* Winner Announcement */}
|
|
<Card className="animate-fade-in-up">
|
|
<CardContent className="text-center py-12">
|
|
<div className="mb-6">
|
|
<Trophy className="h-16 w-16 text-yellow-500 mx-auto animate-bounce" />
|
|
</div>
|
|
<h2 className="text-3xl font-bold mb-2 text-gray-900">
|
|
🎉 Winner: {winner?.name || "No votes yet"} 🎉
|
|
</h2>
|
|
<p className="text-xl text-gray-600">
|
|
{winner ? `${winner.vote_count} votes (${((winner.vote_count / results.total_votes) * 100).toFixed(1)}%)` : "Waiting for votes"}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Animated Bar Chart with Profile Images */}
|
|
<Card className="animate-fade-in-up" style={{ animationDelay: '0.3s' }}>
|
|
<CardHeader className="text-center">
|
|
<CardTitle className="text-2xl">Final Results</CardTitle>
|
|
<CardDescription>Vote distribution by candidate</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{chartData.length > 0 ? (
|
|
<div className="space-y-6">
|
|
{chartData.map((candidate, index) => {
|
|
const maxVotes = Math.max(...chartData.map(c => c.votes))
|
|
const barWidth = candidate.votes > 0 ? (candidate.votes / maxVotes) * 100 : 0
|
|
|
|
return (
|
|
<div key={candidate.id} className="relative">
|
|
{/* Candidate Info Row */}
|
|
<div className="flex items-center gap-4 mb-3 p-3 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow">
|
|
{/* Profile Image */}
|
|
<div className="relative w-20 h-20 flex-shrink-0 profile-image-container">
|
|
{candidate.image_url ? (
|
|
<Image
|
|
src={candidate.image_url}
|
|
alt={candidate.name}
|
|
fill
|
|
className="object-cover rounded-full border-4 shadow-lg transition-transform"
|
|
style={{ borderColor: candidate.fill }}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="w-full h-full bg-gray-200 rounded-full flex items-center justify-center border-4 shadow-lg transition-transform"
|
|
style={{ borderColor: candidate.fill }}
|
|
>
|
|
<Users className="h-8 w-8 text-gray-400" />
|
|
</div>
|
|
)}
|
|
{/* Rank Badge */}
|
|
<div className="absolute -top-2 -right-2">
|
|
<div className={`px-3 py-1 rounded-full text-white font-bold text-sm shadow-lg ${
|
|
index === 0 ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
|
index === 1 ? 'bg-gradient-to-r from-gray-400 to-gray-600' :
|
|
index === 2 ? 'bg-gradient-to-r from-orange-400 to-orange-600' :
|
|
'bg-gradient-to-r from-blue-400 to-blue-600'
|
|
}`}>
|
|
#{index + 1}
|
|
</div>
|
|
</div>
|
|
{/* Winner Crown */}
|
|
{index === 0 && candidate.votes > 0 && (
|
|
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
|
<Trophy className="h-6 w-6 text-yellow-500 animate-bounce" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Candidate Name and Info */}
|
|
<div className="flex-grow">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="text-xl font-bold text-gray-900">{candidate.name}</h3>
|
|
{index < 3 && (
|
|
<span className="text-xs px-2 py-1 rounded-full bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-medium">
|
|
{index === 0 ? '🏆 Winner' : index === 1 ? '🥈 2nd' : '🥉 3rd'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-600 font-medium">{candidate.votes} votes</p>
|
|
</div>
|
|
|
|
{/* Percentage Display */}
|
|
<div className="text-right">
|
|
<div
|
|
className="text-3xl font-bold mb-1"
|
|
style={{ color: candidate.fill }}
|
|
>
|
|
{candidate.percentage.toFixed(1)}%
|
|
</div>
|
|
<div className="text-xs text-gray-500 font-medium">
|
|
of {results.total_votes} votes
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enhanced Animated Bar */}
|
|
<div className="relative bg-gradient-to-r from-gray-200 to-gray-300 rounded-full h-12 overflow-hidden shadow-inner mb-4">
|
|
<div
|
|
className="h-full rounded-full flex items-center justify-between px-4 shadow-lg relative overflow-hidden"
|
|
style={{
|
|
background: `linear-gradient(135deg, ${candidate.fill}, ${candidate.fill}dd)`,
|
|
width: `${barWidth}%`,
|
|
transition: 'width 2s ease-out',
|
|
transitionDelay: `${index * 300}ms`
|
|
}}
|
|
>
|
|
{/* Shimmer Effect */}
|
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse"></div>
|
|
|
|
{/* Vote count inside bar */}
|
|
<div className="relative z-10 flex items-center justify-between w-full">
|
|
{barWidth > 20 && (
|
|
<span className="text-white font-bold text-sm">
|
|
{candidate.name}
|
|
</span>
|
|
)}
|
|
{barWidth > 10 && (
|
|
<span className="text-white font-bold text-lg">
|
|
{candidate.votes}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Percentage label outside bar for small bars */}
|
|
{barWidth < 20 && candidate.votes > 0 && (
|
|
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-700 font-bold text-sm">
|
|
{candidate.votes}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Total Votes Summary */}
|
|
<div className="mt-8 p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg border-2 border-blue-200">
|
|
<div className="text-center">
|
|
<div className="text-3xl font-bold text-gray-900 mb-2">
|
|
{results.total_votes}
|
|
</div>
|
|
<p className="text-gray-600">Total Votes Cast</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-96 flex items-center justify-center bg-gray-50 rounded-lg">
|
|
<div className="text-center">
|
|
<BarChart3 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<p className="text-gray-600">No voting data available</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="grid md:grid-cols-3 gap-6 animate-fade-in-up" style={{ animationDelay: '0.6s' }}>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Total Votes</CardTitle>
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-3xl font-bold text-blue-600">{results.total_votes}</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Cast across all candidates
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Candidates</CardTitle>
|
|
<Trophy className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-3xl font-bold text-green-600">{results.candidates.length}</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Participated in voting
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Winning Margin</CardTitle>
|
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-3xl font-bold text-purple-600">
|
|
{winner && results.candidates.length > 1 ?
|
|
`${Math.max(0, winner.vote_count - Math.max(...results.candidates.filter(c => c.id !== winner.id).map(c => c.vote_count)))}` :
|
|
'0'
|
|
}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Vote difference
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* No Event Selected */}
|
|
{!loading && !selectedEventId && (
|
|
<Card>
|
|
<CardContent className="text-center py-12">
|
|
<BarChart3 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium mb-2">Select a Vote Event</h3>
|
|
<p className="text-gray-600">Choose an event from the dropdown above to view its results</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* No Results Available */}
|
|
{!loading && selectedEventId && !results && (
|
|
<Card>
|
|
<CardContent className="text-center py-12">
|
|
<BarChart3 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium mb-2">No Results Available</h3>
|
|
<p className="text-gray-600">Results for this event are not yet available</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ResultsPage() {
|
|
return (
|
|
<AuthGuard requiredRole="superadmin">
|
|
<Suspense fallback={
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600">Loading...</p>
|
|
</div>
|
|
</div>
|
|
}>
|
|
<ResultsPageContent />
|
|
</Suspense>
|
|
</AuthGuard>
|
|
)
|
|
} |