From 06bfe11dcce3ae085b3fa853610f049638821dff Mon Sep 17 00:00:00 2001 From: Yannick Le Duc Date: Mon, 25 Aug 2025 15:04:27 +0200 Subject: [PATCH] liens publics pour voter pour les participants --- .../campaigns/[id]/participants/page.tsx | 77 +++- .../[id]/vote/[participantId]/page.tsx | 367 ++++++++++++++++++ src/components/AddPropositionModal.tsx | 6 +- src/components/DeleteParticipantModal.tsx | 2 +- src/components/EditParticipantModal.tsx | 4 +- src/lib/services.ts | 98 ++++- src/types/index.ts | 19 + supabase-schema.sql | 27 ++ 8 files changed, 583 insertions(+), 17 deletions(-) create mode 100644 src/app/campaigns/[id]/vote/[participantId]/page.tsx diff --git a/src/app/admin/campaigns/[id]/participants/page.tsx b/src/app/admin/campaigns/[id]/participants/page.tsx index aea6958..47021e9 100644 --- a/src/app/admin/campaigns/[id]/participants/page.tsx +++ b/src/app/admin/campaigns/[id]/participants/page.tsx @@ -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(null); - const [participants, setParticipants] = useState([]); + const [participants, setParticipants] = useState([]); const [loading, setLoading] = useState(true); const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [selectedParticipant, setSelectedParticipant] = useState(null); + const [copiedParticipantId, setCopiedParticipantId] = useState(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() {

-

- Inscrit le {new Date(participant.created_at).toLocaleDateString('fr-FR')} -

+
+ + Inscrit le : {new Date(participant.created_at).toLocaleDateString('fr-FR')} + + + {participant.has_voted ? 'A voté' : 'N\'a pas voté'} + + {participant.has_voted && participant.total_voted_amount && ( + + Total voté : {participant.total_voted_amount}€ + + )} +
+ + {campaign?.status === 'voting' && ( +
+
+
+

Lien de vote personnel

+
+ + +
+
+
+
+ )}
+ ))} + +
+ + + + ))} + + )} + + {/* Bouton de validation */} + {propositions.length > 0 && ( +
+ +
+ )} + + {error && ( +
+ {error} +
+ )} + + + ); +} diff --git a/src/components/AddPropositionModal.tsx b/src/components/AddPropositionModal.tsx index 37e8a72..bfff790 100644 --- a/src/components/AddPropositionModal.tsx +++ b/src/components/AddPropositionModal.tsx @@ -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(''); diff --git a/src/components/DeleteParticipantModal.tsx b/src/components/DeleteParticipantModal.tsx index d51759c..5b20749 100644 --- a/src/components/DeleteParticipantModal.tsx +++ b/src/components/DeleteParticipantModal.tsx @@ -78,7 +78,7 @@ export default function DeleteParticipantModal({ Email : {participant.email}

- 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.).

Cette action est irréversible ! diff --git a/src/components/EditParticipantModal.tsx b/src/components/EditParticipantModal.tsx index aff9a3a..2dc9fd3 100644 --- a/src/components/EditParticipantModal.tsx +++ b/src/components/EditParticipantModal.tsx @@ -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" /> @@ -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" /> diff --git a/src/lib/services.ts b/src/lib/services.ts index aedf008..62e8ae6 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const { error } = await supabase + .from('votes') + .delete() + .eq('id', id); + + if (error) throw error; + }, + + async getParticipantVoteStatus(campaignId: string): Promise { + 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 + }; + }); + } +}; diff --git a/src/types/index.ts b/src/types/index.ts index 194280f..1c46826 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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: { diff --git a/supabase-schema.sql b/supabase-schema.sql index 4632aa3..abecc41 100644 --- a/supabase-schema.sql +++ b/supabase-schema.sql @@ -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