From 8274722518120b8e16508ff5b6a2bf121d224cfb Mon Sep 17 00:00:00 2001 From: Yannick Le Duc Date: Sun, 21 Sep 2025 20:53:50 +0200 Subject: [PATCH] =?UTF-8?q?ajout=20de=20la=20possibilit=C3=A9=20de=20parta?= =?UTF-8?q?ger=20publiquement=20la=20page=20statistiques=20d'une=20campagn?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/campaigns/[id]/stats/page.tsx | 356 ++------------------ src/app/stats/[id]/page.tsx | 173 ++++++++++ src/components/SharePublicStatsButton.tsx | 60 ++++ src/components/StatsDisplay.tsx | 332 ++++++++++++++++++ src/hooks/useStatsCalculation.ts | 49 +++ 5 files changed, 642 insertions(+), 328 deletions(-) create mode 100644 src/app/stats/[id]/page.tsx create mode 100644 src/components/SharePublicStatsButton.tsx create mode 100644 src/components/StatsDisplay.tsx create mode 100644 src/hooks/useStatsCalculation.ts diff --git a/src/app/admin/campaigns/[id]/stats/page.tsx b/src/app/admin/campaigns/[id]/stats/page.tsx index 8e01948..9d37d07 100644 --- a/src/app/admin/campaigns/[id]/stats/page.tsx +++ b/src/app/admin/campaigns/[id]/stats/page.tsx @@ -6,61 +6,23 @@ import Link from 'next/link'; import { Campaign, Proposition, Participant, Vote } from '@/types'; import { campaignService, propositionService, participantService, voteService } from '@/lib/services'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Progress } from '@/components/ui/progress'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import Navigation from '@/components/Navigation'; import AuthGuard from '@/components/AuthGuard'; import { BarChart3, - Users, - Vote as VoteIcon, - TrendingUp, - Target, - Award, - FileText, - Calendar, - ArrowLeft, - SortAsc, - TrendingDown, - Users2, - Target as TargetIcon, - Hash + ArrowLeft } from 'lucide-react'; import { ExportStatsButton } from '@/components/ExportStatsButton'; +import { SharePublicStatsButton } from '@/components/SharePublicStatsButton'; +import { StatsDisplay } from '@/components/StatsDisplay'; +import { useStatsCalculation } from '@/hooks/useStatsCalculation'; +import Footer from '@/components/Footer'; +import VersionDisplay from '@/components/VersionDisplay'; export const dynamic = 'force-dynamic'; -interface PropositionStats { - proposition: Proposition; - voteCount: number; - averageAmount: number; - minAmount: number; - maxAmount: number; - totalAmount: number; - participationRate: number; - voteDistribution: number; - consensusScore: number; -} - -type SortOption = - | 'popularity' - | 'total_impact' - | 'consensus' - | 'engagement' - | 'distribution' - | 'alphabetical'; - -const sortOptions = [ - { value: 'total_impact', label: 'Impact total', icon: Target, description: 'Somme totale investie' }, - { value: 'popularity', label: 'Popularité', icon: TrendingUp, description: 'Moyenne puis nombre de votants' }, - { value: 'consensus', label: 'Consensus', icon: Users2, description: 'Plus petit écart-type' }, - { value: 'engagement', label: 'Engagement', icon: Users, description: 'Taux de participation' }, - { value: 'distribution', label: 'Répartition', icon: BarChart3, description: 'Nombre de votes différents' }, - { value: 'alphabetical', label: 'Alphabétique', icon: Hash, description: 'Ordre alphabétique' } -]; - function CampaignStatsPageContent() { const params = useParams(); const campaignId = params.id as string; @@ -70,8 +32,8 @@ function CampaignStatsPageContent() { const [propositions, setPropositions] = useState([]); const [votes, setVotes] = useState([]); const [loading, setLoading] = useState(true); - const [propositionStats, setPropositionStats] = useState([]); - const [sortBy, setSortBy] = useState('total_impact'); + + const { propositionStats } = useStatsCalculation(campaign, participants, propositions, votes); useEffect(() => { // Vérifier la configuration Supabase @@ -110,42 +72,6 @@ function CampaignStatsPageContent() { setParticipants(participantsData); setPropositions(propositionsData); setVotes(votesData); - - // Calculer les statistiques des propositions - const stats = propositionsData.map(proposition => { - const propositionVotes = votesData.filter(vote => vote.proposition_id === proposition.id && vote.amount > 0); - const amounts = propositionVotes.map(vote => vote.amount); - const totalAmount = amounts.reduce((sum, amount) => sum + amount, 0); - - // Calculer l'écart-type pour le consensus - const mean = amounts.length > 0 ? totalAmount / amounts.length : 0; - const variance = amounts.length > 0 - ? amounts.reduce((sum, amount) => sum + Math.pow(amount - mean, 2), 0) / amounts.length - : 0; - const consensusScore = Math.sqrt(variance); - - // Calculer le taux de participation pour cette proposition - const participationRate = participantsData.length > 0 - ? (propositionVotes.length / participantsData.length) * 100 - : 0; - - // Calculer la répartition des votes (nombre de montants différents) - const uniqueAmounts = new Set(amounts).size; - - return { - proposition, - voteCount: propositionVotes.length, - averageAmount: amounts.length > 0 ? Math.round(totalAmount / amounts.length) : 0, - minAmount: amounts.length > 0 ? Math.min(...amounts) : 0, - maxAmount: amounts.length > 0 ? Math.max(...amounts) : 0, - totalAmount, - participationRate: Math.round(participationRate * 100) / 100, - voteDistribution: uniqueAmounts, - consensusScore: Math.round(consensusScore * 100) / 100 - }; - }); - - setPropositionStats(stats); } catch (error) { console.error('Erreur lors du chargement des données:', error); } finally { @@ -153,52 +79,6 @@ function CampaignStatsPageContent() { } }; - const getSortedStats = () => { - const sorted = [...propositionStats]; - - switch (sortBy) { - case 'popularity': - return sorted.sort((a, b) => { - if (b.averageAmount !== a.averageAmount) { - return b.averageAmount - a.averageAmount; - } - return b.voteCount - a.voteCount; - }); - - case 'total_impact': - return sorted.sort((a, b) => b.totalAmount - a.totalAmount); - - case 'consensus': - return sorted.sort((a, b) => a.consensusScore - b.consensusScore); - - case 'engagement': - return sorted.sort((a, b) => b.participationRate - a.participationRate); - - case 'distribution': - return sorted.sort((a, b) => b.voteDistribution - a.voteDistribution); - - case 'alphabetical': - return sorted.sort((a, b) => a.proposition.title.localeCompare(b.proposition.title)); - - default: - return sorted; - } - }; - - const getParticipationRate = () => { - if (participants.length === 0) return 0; - const votedParticipants = participants.filter(p => { - const participantVotes = votes.filter(v => v.participant_id === p.id); - return participantVotes.some(v => v.amount > 0); - }); - return Math.round((votedParticipants.length / participants.length) * 100); - }; - - const getAverageVotesPerProposition = () => { - if (propositions.length === 0) return 0; - const totalVotes = votes.filter(v => v.amount > 0).length; - return Math.round(totalVotes / propositions.length); - }; if (loading) { return ( @@ -242,9 +122,6 @@ function CampaignStatsPageContent() { ); } - const participationRate = getParticipationRate(); - const averageVotesPerProposition = getAverageVotesPerProposition(); - return (
@@ -279,6 +156,10 @@ function CampaignStatsPageContent() {
+
- {/* Overview Stats */} -
- - -
-
-

Taux de participation

-

{participationRate}%

-
-
- -
-
- -

- {participants.filter(p => votes.some(v => v.participant_id === p.id && v.amount > 0)).length} / {participants.length} participants -

-
-
+ {/* Stats Display */} + - - -
-
-

Propositions

-

{propositions.length}

-
-
- -
-
-

- {averageVotesPerProposition} votes moy. par proposition -

-
-
-
- - {/* Propositions Stats */} - - -
-
- - - Préférences par proposition - - - Statistiques des montants exprimés par les participants pour chaque proposition - -
- -
- Trier par : - -
-
-
- - {propositionStats.length === 0 ? ( -
- -

- Aucune proposition -

-

- Aucune proposition n'a été soumise pour cette campagne. -

-
- ) : ( -
- {getSortedStats().map((stat, index) => ( -
-
-
-
- - #{index + 1} - -

- {stat.proposition.title} -

-
-
- {index === 0 && stat.averageAmount > 0 && ( - - - {sortBy === 'popularity' ? 'Préférée' : - sortBy === 'total_impact' ? 'Plus d\'impact' : - sortBy === 'consensus' ? 'Plus de consensus' : - sortBy === 'engagement' ? 'Plus d\'engagement' : - sortBy === 'distribution' ? 'Plus de répartition' : 'Première'} - - )} -
- -
-
-

- {stat.voteCount} -

-

- {stat.voteCount === 1 ? 'Votant' : 'Votants'} -

-
-
-

- {stat.averageAmount}€ -

-

Moyenne

-
-
-

- {stat.totalAmount}€ -

-

Total

-
-
-

- {stat.minAmount}€ -

-

Minimum

-
-
-

- {stat.maxAmount}€ -

-

Maximum

-
-
-

- {stat.participationRate}% -

-

Participation

-
-
- - {/* Métriques avancées */} -
-
-
- - Consensus -
- - Écart-type: {stat.consensusScore}€ - -
-
-
- - Répartition -
- - {stat.voteDistribution} montants différents - -
-
- - {stat.voteCount > 0 && ( -
-
- Répartition des préférences - {stat.voteCount} {stat.voteCount === 1 ? 'votant' : 'votants'} -
- -
- )} -
- ))} -
- )} -
-
+ {/* Footer */} +
+ + {/* Version Display */} +
); diff --git a/src/app/stats/[id]/page.tsx b/src/app/stats/[id]/page.tsx new file mode 100644 index 0000000..4632d6b --- /dev/null +++ b/src/app/stats/[id]/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import { Campaign, Proposition, Participant, Vote } from '@/types'; +import { campaignService, propositionService, participantService, voteService } from '@/lib/services'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { BarChart3 } from 'lucide-react'; +import { StatsDisplay } from '@/components/StatsDisplay'; +import { useStatsCalculation } from '@/hooks/useStatsCalculation'; +import Footer from '@/components/Footer'; +import VersionDisplay from '@/components/VersionDisplay'; + +export const dynamic = 'force-dynamic'; + +function PublicStatsPageContent() { + const params = useParams(); + const campaignId = params.id as string; + + const [campaign, setCampaign] = useState(null); + const [participants, setParticipants] = useState([]); + const [propositions, setPropositions] = useState([]); + const [votes, setVotes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const { propositionStats } = useStatsCalculation(campaign, participants, propositions, votes); + + useEffect(() => { + // Vérifier la configuration Supabase + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + // Si pas de configuration ou valeurs par défaut, rediriger vers setup + if (!supabaseUrl || !supabaseAnonKey || + supabaseUrl === 'https://placeholder.supabase.co' || + supabaseAnonKey === 'your-anon-key') { + console.log('🔧 Configuration Supabase manquante, redirection vers /setup'); + window.location.href = '/setup'; + return; + } + + if (campaignId) { + loadData(); + } + }, [campaignId]); + + const loadData = async () => { + try { + setLoading(true); + setError(null); + + const [campaignData, participantsData, propositionsData, votesData] = await Promise.all([ + campaignService.getById(campaignId), + participantService.getByCampaign(campaignId), + propositionService.getByCampaign(campaignId), + voteService.getByCampaign(campaignId) + ]); + + if (!campaignData) { + throw new Error('Campagne non trouvée'); + } + + // Vérifier que la campagne est en cours de vote ou terminée pour permettre l'accès public + if (campaignData.status !== 'voting' && campaignData.status !== 'closed') { + throw new Error('Les statistiques ne sont pas encore disponibles pour cette campagne'); + } + + setCampaign(campaignData); + setParticipants(participantsData); + setPropositions(propositionsData); + setVotes(votesData); + } catch (error) { + console.error('Erreur lors du chargement des données:', error); + setError(error instanceof Error ? error.message : 'Une erreur est survenue'); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+
+
+

Chargement des statistiques...

+
+
+
+
+ ); + } + + if (error || !campaign) { + return ( +
+
+ + +
+ +
+

+ {error || 'Campagne introuvable'} +

+

+ {error === 'Campagne non trouvée' + ? 'La campagne que vous recherchez n\'existe pas ou a été supprimée.' + : error === 'Les statistiques ne sont pas encore disponibles pour cette campagne' + ? 'Cette campagne n\'a pas encore commencé ou les statistiques ne sont pas encore disponibles.' + : 'Une erreur est survenue lors du chargement des données.'} +

+
+
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+ + {campaign.status === 'voting' ? 'En cours de vote' : 'Terminée'} + +
+ +
+
+

+ + Statistiques publiques +

+

+ {campaign.title} +

+

+ {campaign.description} +

+
+
+
+ + {/* Stats Display */} + + + {/* Footer */} +
+ + {/* Version Display */} + +
+
+ ); +} + +export default function PublicStatsPage() { + return ; +} diff --git a/src/components/SharePublicStatsButton.tsx b/src/components/SharePublicStatsButton.tsx new file mode 100644 index 0000000..06b5e21 --- /dev/null +++ b/src/components/SharePublicStatsButton.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Share2, Check } from 'lucide-react'; + +interface SharePublicStatsButtonProps { + campaignId: string; + disabled?: boolean; +} + +export function SharePublicStatsButton({ + campaignId, + disabled = false +}: SharePublicStatsButtonProps) { + const [copied, setCopied] = useState(false); + + const handleShare = async () => { + if (disabled) return; + + try { + const publicUrl = `${window.location.origin}/stats/${campaignId}`; + await navigator.clipboard.writeText(publicUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Erreur lors de la copie:', error); + // Fallback pour les navigateurs qui ne supportent pas clipboard API + const textArea = document.createElement('textarea'); + textArea.value = `${window.location.origin}/stats/${campaignId}`; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + ); +} diff --git a/src/components/StatsDisplay.tsx b/src/components/StatsDisplay.tsx new file mode 100644 index 0000000..03c8187 --- /dev/null +++ b/src/components/StatsDisplay.tsx @@ -0,0 +1,332 @@ +'use client'; + +import { useState } from 'react'; +import { Campaign, Proposition, Participant, Vote } from '@/types'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + BarChart3, + Users, + Vote as VoteIcon, + TrendingUp, + Target, + Award, + FileText, + SortAsc, + TrendingDown, + Users2, + Target as TargetIcon, + Hash +} from 'lucide-react'; + +export interface PropositionStats { + proposition: Proposition; + voteCount: number; + averageAmount: number; + minAmount: number; + maxAmount: number; + totalAmount: number; + participationRate: number; + voteDistribution: number; + consensusScore: number; +} + +type SortOption = + | 'popularity' + | 'total_impact' + | 'consensus' + | 'engagement' + | 'distribution' + | 'alphabetical'; + +const sortOptions = [ + { value: 'total_impact', label: 'Impact total', icon: Target, description: 'Somme totale investie' }, + { value: 'popularity', label: 'Popularité', icon: TrendingUp, description: 'Moyenne puis nombre de votants' }, + { value: 'consensus', label: 'Consensus', icon: Users2, description: 'Plus petit écart-type' }, + { value: 'engagement', label: 'Engagement', icon: Users, description: 'Taux de participation' }, + { value: 'distribution', label: 'Répartition', icon: BarChart3, description: 'Nombre de votes différents' }, + { value: 'alphabetical', label: 'Alphabétique', icon: Hash, description: 'Ordre alphabétique' } +]; + +interface StatsDisplayProps { + campaign: Campaign; + participants: Participant[]; + propositions: Proposition[]; + votes: Vote[]; + propositionStats: PropositionStats[]; + showSorting?: boolean; + showExportButton?: boolean; + exportButton?: React.ReactNode; +} + +export function StatsDisplay({ + campaign, + participants, + propositions, + votes, + propositionStats, + showSorting = true, + showExportButton = false, + exportButton +}: StatsDisplayProps) { + const [sortBy, setSortBy] = useState('total_impact'); + + const getSortedStats = () => { + const sorted = [...propositionStats]; + + switch (sortBy) { + case 'popularity': + return sorted.sort((a, b) => { + if (b.averageAmount !== a.averageAmount) { + return b.averageAmount - a.averageAmount; + } + return b.voteCount - a.voteCount; + }); + + case 'total_impact': + return sorted.sort((a, b) => b.totalAmount - a.totalAmount); + + case 'consensus': + return sorted.sort((a, b) => a.consensusScore - b.consensusScore); + + case 'engagement': + return sorted.sort((a, b) => b.participationRate - a.participationRate); + + case 'distribution': + return sorted.sort((a, b) => b.voteDistribution - a.voteDistribution); + + case 'alphabetical': + return sorted.sort((a, b) => a.proposition.title.localeCompare(b.proposition.title)); + + default: + return sorted; + } + }; + + const getParticipationRate = () => { + if (participants.length === 0) return 0; + const votedParticipants = participants.filter(p => { + const participantVotes = votes.filter(v => v.participant_id === p.id); + return participantVotes.some(v => v.amount > 0); + }); + return Math.round((votedParticipants.length / participants.length) * 100); + }; + + const getAverageVotesPerProposition = () => { + if (propositions.length === 0) return 0; + const totalVotes = votes.filter(v => v.amount > 0).length; + return Math.round(totalVotes / propositions.length); + }; + + const participationRate = getParticipationRate(); + const averageVotesPerProposition = getAverageVotesPerProposition(); + + return ( +
+ {/* Overview Stats */} +
+ + +
+
+

Taux de participation

+

{participationRate}%

+
+
+ +
+
+ +

+ {participants.filter(p => votes.some(v => v.participant_id === p.id && v.amount > 0)).length} / {participants.length} participants +

+
+
+ + + +
+
+

Propositions

+

{propositions.length}

+
+
+ +
+
+

+ {averageVotesPerProposition} votes moy. par proposition +

+
+
+
+ + {/* Propositions Stats */} + + +
+
+ + + Préférences par proposition + + + Statistiques des montants exprimés par les participants pour chaque proposition + +
+ +
+ {showSorting && ( + <> + Trier par : + + + )} + {showExportButton && exportButton} +
+
+
+ + {propositionStats.length === 0 ? ( +
+ +

+ Aucune proposition +

+

+ Aucune proposition n'a été soumise pour cette campagne. +

+
+ ) : ( +
+ {getSortedStats().map((stat, index) => ( +
+
+
+
+ + #{index + 1} + +

+ {stat.proposition.title} +

+
+
+ {index === 0 && stat.averageAmount > 0 && ( + + + {sortBy === 'popularity' ? 'Préférée' : + sortBy === 'total_impact' ? 'Plus d\'impact' : + sortBy === 'consensus' ? 'Plus de consensus' : + sortBy === 'engagement' ? 'Plus d\'engagement' : + sortBy === 'distribution' ? 'Plus de répartition' : 'Première'} + + )} +
+ +
+
+

+ {stat.voteCount} +

+

+ {stat.voteCount === 1 ? 'Votant' : 'Votants'} +

+
+
+

+ {stat.averageAmount}€ +

+

Moyenne

+
+
+

+ {stat.totalAmount}€ +

+

Total

+
+
+

+ {stat.minAmount}€ +

+

Minimum

+
+
+

+ {stat.maxAmount}€ +

+

Maximum

+
+
+

+ {stat.participationRate}% +

+

Participation

+
+
+ + {/* Métriques avancées */} +
+
+
+ + Consensus +
+ + Écart-type: {stat.consensusScore}€ + +
+
+
+ + Répartition +
+ + {stat.voteDistribution} montants différents + +
+
+ + {stat.voteCount > 0 && ( +
+
+ Répartition des préférences + {stat.voteCount} {stat.voteCount === 1 ? 'votant' : 'votants'} +
+ +
+ )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/hooks/useStatsCalculation.ts b/src/hooks/useStatsCalculation.ts new file mode 100644 index 0000000..c67e3ce --- /dev/null +++ b/src/hooks/useStatsCalculation.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import { Campaign, Proposition, Participant, Vote } from '@/types'; +import { PropositionStats } from '@/components/StatsDisplay'; + +export function useStatsCalculation( + campaign: Campaign | null, + participants: Participant[], + propositions: Proposition[], + votes: Vote[] +) { + const propositionStats = useMemo((): PropositionStats[] => { + if (!campaign) return []; + + return propositions.map(proposition => { + const propositionVotes = votes.filter(vote => vote.proposition_id === proposition.id && vote.amount > 0); + const amounts = propositionVotes.map(vote => vote.amount); + const totalAmount = amounts.reduce((sum, amount) => sum + amount, 0); + + // Calculer l'écart-type pour le consensus + const mean = amounts.length > 0 ? totalAmount / amounts.length : 0; + const variance = amounts.length > 0 + ? amounts.reduce((sum, amount) => sum + Math.pow(amount - mean, 2), 0) / amounts.length + : 0; + const consensusScore = Math.sqrt(variance); + + // Calculer le taux de participation pour cette proposition + const participationRate = participants.length > 0 + ? (propositionVotes.length / participants.length) * 100 + : 0; + + // Calculer la répartition des votes (nombre de montants différents) + const uniqueAmounts = new Set(amounts).size; + + return { + proposition, + voteCount: propositionVotes.length, + averageAmount: amounts.length > 0 ? Math.round(totalAmount / amounts.length) : 0, + minAmount: amounts.length > 0 ? Math.min(...amounts) : 0, + maxAmount: amounts.length > 0 ? Math.max(...amounts) : 0, + totalAmount, + participationRate: Math.round(participationRate * 100) / 100, + voteDistribution: uniqueAmounts, + consensusScore: Math.round(consensusScore * 100) / 100 + }; + }); + }, [campaign, participants, propositions, votes]); + + return { propositionStats }; +}