import * as XLSX from 'xlsx'; import { Proposition, Participant, Vote } from '@/types'; export interface ExportData { campaignTitle: string; propositions: Proposition[]; participants: Participant[]; votes: Vote[]; budgetPerUser: number; propositionStats?: PropositionStats[]; anonymizationLevel?: AnonymizationLevel; } export type AnonymizationLevel = 'full' | 'initials' | 'none'; export interface PropositionStats { proposition: Proposition; voteCount: number; averageAmount: number; minAmount: number; maxAmount: number; totalAmount: number; participationRate: number; voteDistribution: number; consensusScore: number; } export function generateVoteExportODS(data: ExportData): Uint8Array { const { campaignTitle, propositions, participants, votes, budgetPerUser, propositionStats, anonymizationLevel = 'full' } = data; // Créer la matrice de données const matrix: (string | number)[][] = []; // En-têtes : Titre de la campagne matrix.push([`Statistiques de vote - ${campaignTitle}`]); matrix.push([]); // Ligne vide // En-têtes des colonnes : propositions + total const headers = ['Participant', ...propositions.map(p => p.title), 'Total voté', 'Budget restant']; matrix.push(headers); // Données des participants participants.forEach(participant => { const row: (string | number)[] = []; // Nom du participant (avec anonymisation) const participantName = anonymizeParticipantName(participant, anonymizationLevel); row.push(participantName); // Votes pour chaque proposition let totalVoted = 0; propositions.forEach(proposition => { const vote = votes.find(v => v.participant_id === participant.id && v.proposition_id === proposition.id ); const amount = vote ? vote.amount : 0; row.push(amount); totalVoted += amount; }); // Total voté par le participant row.push(totalVoted); // Budget restant const budgetRemaining = budgetPerUser - totalVoted; row.push(budgetRemaining); matrix.push(row); }); // Ligne des totaux const totalRow: (string | number)[] = ['TOTAL']; let grandTotal = 0; propositions.forEach(proposition => { const propositionTotal = votes .filter(v => v.proposition_id === proposition.id) .reduce((sum, vote) => sum + vote.amount, 0); totalRow.push(propositionTotal); grandTotal += propositionTotal; }); totalRow.push(grandTotal); totalRow.push(participants.length * budgetPerUser - grandTotal); matrix.push(totalRow); // Créer le workbook et worksheet const workbook = XLSX.utils.book_new(); const worksheet = XLSX.utils.aoa_to_sheet(matrix); // Ajouter des styles pour les colonnes et cellules worksheet['!cols'] = [ { width: 20 }, // Participant ...propositions.map(() => ({ width: 15 })), // Propositions { width: 12 }, // Total voté { width: 12 } // Budget restant ]; // Ajouter des styles pour les cellules (fond gris pour les totaux) const lastRowIndex = matrix.length - 1; const totalVotedColIndex = headers.length - 2; // Avant-dernière colonne const budgetRemainingColIndex = headers.length - 1; // Dernière colonne // Style pour la ligne des totaux (texte en gras + bordures) for (let col = 0; col < headers.length; col++) { const cellRef = XLSX.utils.encode_cell({ r: lastRowIndex, c: col }); if (!worksheet[cellRef]) { worksheet[cellRef] = { v: matrix[lastRowIndex][col] }; } worksheet[cellRef].s = { font: { bold: true }, border: { top: { style: 'thick' }, bottom: { style: 'thick' }, left: { style: 'thin' }, right: { style: 'thin' } } }; } // Style pour les colonnes des totaux (bordures) for (let row = 0; row < matrix.length; row++) { // Colonne "Total voté" const totalVotedCellRef = XLSX.utils.encode_cell({ r: row, c: totalVotedColIndex }); if (!worksheet[totalVotedCellRef]) { worksheet[totalVotedCellRef] = { v: matrix[row][totalVotedColIndex] }; } worksheet[totalVotedCellRef].s = { border: { left: { style: 'thick' }, right: { style: 'thick' } } }; // Colonne "Budget restant" const budgetRemainingCellRef = XLSX.utils.encode_cell({ r: row, c: budgetRemainingColIndex }); if (!worksheet[budgetRemainingCellRef]) { worksheet[budgetRemainingCellRef] = { v: matrix[row][budgetRemainingColIndex] }; } worksheet[budgetRemainingCellRef].s = { border: { left: { style: 'thick' }, right: { style: 'thick' } } }; } // Ajouter le worksheet au workbook XLSX.utils.book_append_sheet(workbook, worksheet, 'Synthèse des votes'); // Ajouter les onglets pour chaque critère de tri si les stats sont disponibles if (propositionStats) { const sortOptions = [ { value: 'total_impact', label: 'Impact total', description: 'Somme totale investie' }, { value: 'popularity', label: 'Popularité', description: 'Moyenne puis nombre de votants' }, { value: 'consensus', label: 'Consensus', description: 'Plus petit écart-type' }, { value: 'engagement', label: 'Engagement', description: 'Taux de participation' }, { value: 'distribution', label: 'Répartition', description: 'Nombre de votes différents' }, { value: 'alphabetical', label: 'Alphabétique', description: 'Ordre alphabétique' } ]; sortOptions.forEach(sortOption => { const sortedStats = [...propositionStats].sort((a, b) => { switch (sortOption.value) { case 'total_impact': return b.totalAmount - a.totalAmount; case 'popularity': if (b.averageAmount !== a.averageAmount) { return b.averageAmount - a.averageAmount; } return b.voteCount - a.voteCount; case 'consensus': return a.consensusScore - b.consensusScore; case 'engagement': return b.participationRate - a.participationRate; case 'distribution': return b.voteDistribution - a.voteDistribution; case 'alphabetical': return a.proposition.title.localeCompare(b.proposition.title); default: return 0; } }); // Créer la matrice pour cet onglet const statsMatrix: (string | number)[][] = []; // En-tête statsMatrix.push([`Statistiques de vote - ${campaignTitle} - Tri par ${sortOption.label} (${sortOption.description})`]); statsMatrix.push([]); // Ligne vide // En-têtes des colonnes statsMatrix.push([ 'Proposition', 'Votes reçus', 'Montant total', 'Montant moyen', 'Montant min', 'Montant max', 'Taux participation', 'Répartition votes', 'Score consensus' ]); // Données des propositions sortedStats.forEach(stat => { statsMatrix.push([ stat.proposition.title, stat.voteCount, stat.totalAmount, stat.averageAmount, stat.minAmount, stat.maxAmount, Math.round(stat.participationRate * 100) / 100, stat.voteDistribution, Math.round(stat.consensusScore * 100) / 100 ]); }); // Créer le worksheet pour cet onglet const statsWorksheet = XLSX.utils.aoa_to_sheet(statsMatrix); // Dimensionner les colonnes statsWorksheet['!cols'] = [ { width: 30 }, // Proposition { width: 12 }, // Votes reçus { width: 12 }, // Montant total { width: 12 }, // Montant moyen { width: 12 }, // Montant min { width: 12 }, // Montant max { width: 15 }, // Taux participation { width: 15 }, // Répartition votes { width: 15 } // Score consensus ]; // Ajouter le worksheet au workbook XLSX.utils.book_append_sheet(workbook, statsWorksheet, sortOption.label); }); } // Générer le fichier ODS const odsBuffer = XLSX.write(workbook, { bookType: 'ods', type: 'array' }); return new Uint8Array(odsBuffer); } export function downloadODS(data: Uint8Array, filename: string): void { const blob = new Blob([data], { type: 'application/vnd.oasis.opendocument.spreadsheet' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } export function anonymizeParticipantName(participant: Participant, level: AnonymizationLevel): string { switch (level) { case 'full': return 'XXXX'; case 'initials': const firstNameInitial = participant.first_name.charAt(0).toUpperCase(); const lastNameInitial = participant.last_name.charAt(0).toUpperCase(); return `${firstNameInitial}.${lastNameInitial}.`; case 'none': return `${participant.first_name} ${participant.last_name}`; default: return 'XXXX'; } } export function formatFilename(campaignTitle: string): string { const sanitizedTitle = campaignTitle .replace(/[^a-zA-Z0-9\s]/g, '') .replace(/\s+/g, '_') .replace(/_+/g, '_') // Remplacer les underscores multiples par un seul .toLowerCase() .trim(); const date = new Date().toISOString().split('T')[0]; const prefix = sanitizedTitle ? `statistiques_vote_${sanitizedTitle}_` : 'statistiques_vote_'; const filename = `${prefix}${date}.ods`; // Nettoyer les underscores multiples à la fin return filename.replace(/_+/g, '_'); }