liens publics pour voter pour les participants
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Campaign, Participant } from '@/types';
|
||||
import { campaignService, participantService } from '@/lib/services';
|
||||
import { Campaign, Participant, ParticipantWithVoteStatus } from '@/types';
|
||||
import { campaignService, participantService, voteService } from '@/lib/services';
|
||||
import AddParticipantModal from '@/components/AddParticipantModal';
|
||||
import EditParticipantModal from '@/components/EditParticipantModal';
|
||||
import DeleteParticipantModal from '@/components/DeleteParticipantModal';
|
||||
@@ -17,12 +17,13 @@ export default function CampaignParticipantsPage() {
|
||||
const campaignId = params.id as string;
|
||||
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [participants, setParticipants] = useState<ParticipantWithVoteStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
|
||||
const [copiedParticipantId, setCopiedParticipantId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (campaignId) {
|
||||
@@ -33,13 +34,13 @@ export default function CampaignParticipantsPage() {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [campaignData, participantsData] = await Promise.all([
|
||||
const [campaigns, participantsWithVoteStatus] = await Promise.all([
|
||||
campaignService.getAll().then(campaigns => campaigns.find(c => c.id === campaignId)),
|
||||
participantService.getByCampaign(campaignId)
|
||||
voteService.getParticipantVoteStatus(campaignId)
|
||||
]);
|
||||
|
||||
setCampaign(campaignData || null);
|
||||
setParticipants(participantsData);
|
||||
setCampaign(campaigns || null);
|
||||
setParticipants(participantsWithVoteStatus);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des données:', error);
|
||||
} finally {
|
||||
@@ -181,9 +182,65 @@ export default function CampaignParticipantsPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Inscrit le {new Date(participant.created_at).toLocaleDateString('fr-FR')}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>
|
||||
<strong>Inscrit le :</strong> {new Date(participant.created_at).toLocaleDateString('fr-FR')}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
participant.has_voted
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{participant.has_voted ? 'A voté' : 'N\'a pas voté'}
|
||||
</span>
|
||||
{participant.has_voted && participant.total_voted_amount && (
|
||||
<span>
|
||||
<strong>Total voté :</strong> {participant.total_voted_amount}€
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{campaign?.status === 'voting' && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">Lien de vote personnel</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={`${window.location.origin}/campaigns/${campaignId}/vote/${participant.id}`}
|
||||
className="flex-1 text-xs bg-white border border-blue-300 rounded px-2 py-1 text-blue-700 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/campaigns/${campaignId}/vote/${participant.id}`);
|
||||
setCopiedParticipantId(participant.id);
|
||||
setTimeout(() => setCopiedParticipantId(null), 2000);
|
||||
}}
|
||||
className="inline-flex items-center px-2 py-1 border border-blue-300 rounded text-xs font-medium text-blue-700 bg-white hover:bg-blue-50"
|
||||
title="Copier le lien"
|
||||
>
|
||||
{copiedParticipantId === participant.id ? (
|
||||
<>
|
||||
<svg className="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copié !
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 ml-6">
|
||||
<button
|
||||
|
||||
367
src/app/campaigns/[id]/vote/[participantId]/page.tsx
Normal file
367
src/app/campaigns/[id]/vote/[participantId]/page.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@/types';
|
||||
import { campaignService, participantService, propositionService, voteService } from '@/lib/services';
|
||||
|
||||
// Force dynamic rendering to avoid SSR issues with Supabase
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PublicVotePage() {
|
||||
const params = useParams();
|
||||
const campaignId = params.id as string;
|
||||
const participantId = params.participantId as string;
|
||||
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||
const [participant, setParticipant] = useState<Participant | null>(null);
|
||||
const [propositions, setPropositions] = useState<PropositionWithVote[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [totalVoted, setTotalVoted] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (campaignId && participantId) {
|
||||
loadData();
|
||||
}
|
||||
}, [campaignId, participantId]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [campaigns, participants, propositionsData] = await Promise.all([
|
||||
campaignService.getAll(),
|
||||
participantService.getByCampaign(campaignId),
|
||||
propositionService.getByCampaign(campaignId)
|
||||
]);
|
||||
|
||||
const campaignData = campaigns.find(c => c.id === campaignId);
|
||||
const participantData = participants.find(p => p.id === participantId);
|
||||
|
||||
if (!campaignData) {
|
||||
setError('Campagne non trouvée');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!participantData) {
|
||||
setError('Participant non trouvé');
|
||||
return;
|
||||
}
|
||||
|
||||
if (campaignData.status !== 'voting') {
|
||||
setError('Cette campagne n\'est pas en phase de vote');
|
||||
return;
|
||||
}
|
||||
|
||||
setCampaign(campaignData);
|
||||
setParticipant(participantData);
|
||||
|
||||
// Charger les votes existants
|
||||
const votes = await voteService.getByParticipant(campaignId, participantId);
|
||||
|
||||
// Combiner les propositions avec leurs votes
|
||||
const propositionsWithVotes = propositionsData.map(proposition => ({
|
||||
...proposition,
|
||||
vote: votes.find(vote => vote.proposition_id === proposition.id)
|
||||
}));
|
||||
|
||||
setPropositions(propositionsWithVotes);
|
||||
|
||||
// Calculer le total voté
|
||||
const total = votes.reduce((sum, vote) => sum + vote.amount, 0);
|
||||
setTotalVoted(total);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des données:', error);
|
||||
setError('Erreur lors du chargement des données');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteChange = async (propositionId: string, amount: number) => {
|
||||
try {
|
||||
const existingVote = propositions.find(p => p.id === propositionId)?.vote;
|
||||
|
||||
if (amount === 0) {
|
||||
// Si on sélectionne "Aucun vote", on supprime le vote existant s'il y en a un
|
||||
if (existingVote) {
|
||||
await voteService.delete(existingVote.id);
|
||||
}
|
||||
} else {
|
||||
// Sinon on crée ou met à jour le vote
|
||||
if (existingVote) {
|
||||
// Mettre à jour le vote existant
|
||||
await voteService.update(existingVote.id, { amount });
|
||||
} else {
|
||||
// Créer un nouveau vote
|
||||
await voteService.create({
|
||||
campaign_id: campaignId,
|
||||
participant_id: participantId,
|
||||
proposition_id: propositionId,
|
||||
amount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Recharger les données
|
||||
await loadData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du vote:', error);
|
||||
setError('Erreur lors de l\'enregistrement du vote');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (totalVoted !== campaign?.budget_per_user) {
|
||||
setError(`Vous devez dépenser exactement ${campaign?.budget_per_user}€`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Les votes sont déjà sauvegardés, on affiche juste le succès
|
||||
setSuccess(true);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation:', error);
|
||||
setError('Erreur lors de la validation des votes');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSpendingTiers = () => {
|
||||
if (!campaign) return [];
|
||||
return campaign.spending_tiers.split(',').map(tier => parseInt(tier.trim())).filter(tier => tier > 0);
|
||||
};
|
||||
|
||||
const getVoteStatus = () => {
|
||||
if (!campaign) return { status: 'error', message: 'Campagne non trouvée' };
|
||||
|
||||
const remaining = campaign.budget_per_user - totalVoted;
|
||||
|
||||
if (remaining === 0) {
|
||||
return { status: 'success', message: 'Budget complet ! Vous pouvez valider votre vote.' };
|
||||
} else if (remaining > 0) {
|
||||
return { status: 'warning', message: `Il vous reste ${remaining}€ à dépenser` };
|
||||
} else {
|
||||
return { status: 'error', message: `Vous avez dépensé ${Math.abs(remaining)}€ de trop` };
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Chargement de la page de vote...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !campaign) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
|
||||
<svg className="mx-auto h-12 w-12 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<h2 className="mt-4 text-lg font-medium text-gray-900">Erreur</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">{error}</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
|
||||
<svg className="mx-auto h-12 w-12 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h2 className="mt-4 text-lg font-medium text-gray-900">Vote enregistré !</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Votre vote a été enregistré avec succès. Vous pouvez revenir modifier vos choix tant que la campagne est en cours.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const voteStatus = getVoteStatus();
|
||||
const spendingTiers = getSpendingTiers();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header fixe avec le total */}
|
||||
<div className="sticky top-0 z-40 bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Retour
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Vote - {campaign?.title}</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
{participant?.first_name} {participant?.last_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{totalVoted}€ / {campaign?.budget_per_user}€
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${
|
||||
voteStatus.status === 'success' ? 'text-green-600' :
|
||||
voteStatus.status === 'warning' ? 'text-yellow-600' :
|
||||
'text-red-600'
|
||||
}`}>
|
||||
{voteStatus.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Informations de la campagne */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Informations sur la campagne</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500">Description</h3>
|
||||
<p className="mt-1 text-sm text-gray-900">{campaign?.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500">Budget par participant</h3>
|
||||
<p className="mt-1 text-sm text-gray-900">{campaign?.budget_per_user}€</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500">Paliers de dépenses</h3>
|
||||
<p className="mt-1 text-sm text-gray-900">{campaign?.spending_tiers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Propositions */}
|
||||
{propositions.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune proposition</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Aucune proposition n'a été soumise pour cette campagne.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{propositions.map((proposition) => (
|
||||
<div key={proposition.id} className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{proposition.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{proposition.description}
|
||||
</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="font-medium">Auteur :</span> {proposition.author_first_name} {proposition.author_last_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Votre vote pour cette proposition
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{spendingTiers.map((tier) => (
|
||||
<button
|
||||
key={tier}
|
||||
onClick={() => handleVoteChange(proposition.id, tier)}
|
||||
className={`px-4 py-3 text-sm font-medium rounded-lg border-2 transition-all duration-200 ${
|
||||
proposition.vote?.amount === tier
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{tier}€
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handleVoteChange(proposition.id, 0)}
|
||||
className={`px-4 py-3 text-sm font-medium rounded-lg border-2 transition-all duration-200 ${
|
||||
!proposition.vote
|
||||
? 'border-gray-400 bg-gray-100 text-gray-600'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Aucun vote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bouton de validation */}
|
||||
{propositions.length > 0 && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || totalVoted !== campaign?.budget_per_user}
|
||||
className={`px-8 py-4 text-lg font-medium rounded-lg transition-all duration-200 ${
|
||||
totalVoted === campaign?.budget_per_user
|
||||
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{saving ? 'Enregistrement...' : 'Valider mon vote'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,9 +23,9 @@ export default function AddPropositionModal({
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
author_first_name: '',
|
||||
author_last_name: '',
|
||||
author_email: ''
|
||||
author_first_name: 'admin',
|
||||
author_last_name: 'admin',
|
||||
author_email: 'admin@example.com'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function DeleteParticipantModal({
|
||||
<strong>Email :</strong> {participant.email}
|
||||
</p>
|
||||
<p>
|
||||
Cette action supprimera définitivement le participant et toutes les données associées.
|
||||
Cette action supprimera définitivement le participant et toutes les données associées (votes, etc.).
|
||||
</p>
|
||||
<p className="text-red-600 font-medium mt-3">
|
||||
Cette action est irréversible !
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function EditParticipantModal({
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Prénom du participant"
|
||||
placeholder="Prénom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function EditParticipantModal({
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Nom du participant"
|
||||
placeholder="Nom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { supabase } from './supabase';
|
||||
import { Campaign, Proposition, Participant } from '@/types';
|
||||
import { Campaign, Proposition, Participant, Vote, ParticipantWithVoteStatus } from '@/types';
|
||||
|
||||
// Services pour les campagnes
|
||||
export const campaignService = {
|
||||
@@ -173,3 +173,99 @@ export const participantService = {
|
||||
if (error) throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Services pour les votes
|
||||
export const voteService = {
|
||||
async getByParticipant(campaignId: string, participantId: string): Promise<Vote[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('votes')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.eq('participant_id', participantId);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
},
|
||||
|
||||
async getByProposition(propositionId: string): Promise<Vote[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('votes')
|
||||
.select('*')
|
||||
.eq('proposition_id', propositionId);
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async create(vote: any): Promise<Vote> {
|
||||
const { data, error } = await supabase
|
||||
.from('votes')
|
||||
.insert(vote)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async update(id: string, updates: any): Promise<Vote> {
|
||||
const { data, error } = await supabase
|
||||
.from('votes')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async upsert(vote: { campaign_id: string; participant_id: string; proposition_id: string; amount: number }): Promise<Vote> {
|
||||
const { data, error } = await supabase
|
||||
.from('votes')
|
||||
.upsert(vote, { onConflict: 'participant_id,proposition_id' })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('votes')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async getParticipantVoteStatus(campaignId: string): Promise<ParticipantWithVoteStatus[]> {
|
||||
const { data: participants, error: participantsError } = await supabase
|
||||
.from('participants')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId);
|
||||
|
||||
if (participantsError) throw participantsError;
|
||||
|
||||
const { data: votes, error: votesError } = await supabase
|
||||
.from('votes')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId);
|
||||
|
||||
if (votesError) throw votesError;
|
||||
|
||||
return participants.map(participant => {
|
||||
const participantVotes = votes.filter(vote => vote.participant_id === participant.id);
|
||||
const totalVotedAmount = participantVotes.reduce((sum, vote) => sum + vote.amount, 0);
|
||||
|
||||
return {
|
||||
...participant,
|
||||
has_voted: participantVotes.length > 0,
|
||||
total_voted_amount: totalVotedAmount
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,6 +38,25 @@ export interface Participant {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Vote {
|
||||
id: string;
|
||||
campaign_id: string;
|
||||
participant_id: string;
|
||||
proposition_id: string;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PropositionWithVote extends Proposition {
|
||||
vote?: Vote;
|
||||
}
|
||||
|
||||
export interface ParticipantWithVoteStatus extends Participant {
|
||||
has_voted: boolean;
|
||||
total_voted_amount?: number;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
|
||||
@@ -34,12 +34,28 @@ CREATE TABLE participants (
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Table des votes
|
||||
CREATE TABLE votes (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
||||
participant_id UUID NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
proposition_id UUID NOT NULL REFERENCES propositions(id) ON DELETE CASCADE,
|
||||
amount INTEGER NOT NULL CHECK (amount > 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(participant_id, proposition_id) -- Un seul vote par participant par proposition
|
||||
);
|
||||
|
||||
-- Index pour améliorer les performances
|
||||
CREATE INDEX idx_propositions_campaign_id ON propositions(campaign_id);
|
||||
CREATE INDEX idx_participants_campaign_id ON participants(campaign_id);
|
||||
CREATE INDEX idx_campaigns_status ON campaigns(status);
|
||||
CREATE INDEX idx_campaigns_created_at ON campaigns(created_at DESC);
|
||||
|
||||
-- Index pour optimiser les requêtes
|
||||
CREATE INDEX idx_votes_campaign_participant ON votes(campaign_id, participant_id);
|
||||
CREATE INDEX idx_votes_proposition ON votes(proposition_id);
|
||||
|
||||
-- Trigger pour mettre à jour updated_at automatiquement
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
@@ -54,10 +70,14 @@ CREATE TRIGGER update_campaigns_updated_at
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_votes_updated_at BEFORE UPDATE ON votes
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Politique RLS (Row Level Security) - Activer pour toutes les tables
|
||||
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE propositions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE participants ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Politiques pour permettre l'accès public (à adapter selon vos besoins d'authentification)
|
||||
CREATE POLICY "Allow public read access to campaigns" ON campaigns FOR SELECT USING (true);
|
||||
@@ -67,9 +87,16 @@ CREATE POLICY "Allow public delete access to campaigns" ON campaigns FOR DELETE
|
||||
|
||||
CREATE POLICY "Allow public read access to propositions" ON propositions FOR SELECT USING (true);
|
||||
CREATE POLICY "Allow public insert access to propositions" ON propositions FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Allow public delete access to propositions" ON propositions FOR DELETE USING (true);
|
||||
|
||||
CREATE POLICY "Allow public read access to participants" ON participants FOR SELECT USING (true);
|
||||
CREATE POLICY "Allow public insert access to participants" ON participants FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Allow public delete access to participants" ON participants FOR DELETE USING (true);
|
||||
|
||||
CREATE POLICY "Allow public read access to votes" ON votes FOR SELECT USING (true);
|
||||
CREATE POLICY "Allow public insert access to votes" ON votes FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Allow public update access to votes" ON votes FOR UPDATE USING (true);
|
||||
CREATE POLICY "Allow public delete access to votes" ON votes FOR DELETE USING (true);
|
||||
|
||||
-- Données d'exemple (optionnel)
|
||||
INSERT INTO campaigns (title, description, status, budget_per_user, spending_tiers) VALUES
|
||||
|
||||
Reference in New Issue
Block a user