298 lines
9.5 KiB
TypeScript
298 lines
9.5 KiB
TypeScript
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, '_');
|
|
}
|