ajout de l'export des votes dans un fichier ODS avec toutes les données (anonymisées par défaut - réglable dans les paramètres)

This commit is contained in:
Yannick Le Duc
2025-08-27 18:38:20 +02:00
parent c94c8038f3
commit b7ce1145e3
10 changed files with 979 additions and 23 deletions

View File

@@ -0,0 +1,164 @@
import { generateVoteExportODS, formatFilename, anonymizeParticipantName, ExportData, AnonymizationLevel } from '@/lib/export-utils';
// Mock data pour les tests
const mockExportData: ExportData = {
campaignTitle: 'Test Campaign',
propositions: [
{ id: 'prop1', title: 'Proposition 1', description: 'Description 1', campaign_id: 'camp1', author_first_name: 'John', author_last_name: 'Doe', author_email: 'john@example.com', created_at: '2024-01-01' },
{ id: 'prop2', title: 'Proposition 2', description: 'Description 2', campaign_id: 'camp1', author_first_name: 'Jane', author_last_name: 'Smith', author_email: 'jane@example.com', created_at: '2024-01-02' }
],
participants: [
{ id: 'part1', first_name: 'Alice', last_name: 'Johnson', email: 'alice@example.com', campaign_id: 'camp1', short_id: 'abc123', created_at: '2024-01-01' },
{ id: 'part2', first_name: 'Bob', last_name: 'Brown', email: 'bob@example.com', campaign_id: 'camp1', short_id: 'def456', created_at: '2024-01-02' }
],
votes: [
{ id: 'vote1', participant_id: 'part1', proposition_id: 'prop1', amount: 50, created_at: '2024-01-01', updated_at: '2024-01-01' },
{ id: 'vote2', participant_id: 'part1', proposition_id: 'prop2', amount: 30, created_at: '2024-01-01', updated_at: '2024-01-01' },
{ id: 'vote3', participant_id: 'part2', proposition_id: 'prop1', amount: 40, created_at: '2024-01-02', updated_at: '2024-01-02' }
],
budgetPerUser: 100,
propositionStats: [
{
proposition: { id: 'prop1', title: 'Proposition 1', description: 'Description 1', campaign_id: 'camp1', author_first_name: 'John', author_last_name: 'Doe', author_email: 'john@example.com', created_at: '2024-01-01' },
voteCount: 2,
averageAmount: 45,
minAmount: 40,
maxAmount: 50,
totalAmount: 90,
participationRate: 100,
voteDistribution: 2,
consensusScore: 5
},
{
proposition: { id: 'prop2', title: 'Proposition 2', description: 'Description 2', campaign_id: 'camp1', author_first_name: 'Jane', author_last_name: 'Smith', author_email: 'jane@example.com', created_at: '2024-01-02' },
voteCount: 1,
averageAmount: 30,
minAmount: 30,
maxAmount: 30,
totalAmount: 30,
participationRate: 50,
voteDistribution: 1,
consensusScore: 0
}
]
};
describe('Export Utils', () => {
describe('generateVoteExportODS', () => {
it('should generate ODS data with correct structure', () => {
const odsData = generateVoteExportODS(mockExportData);
expect(odsData).toBeInstanceOf(Uint8Array);
expect(odsData.length).toBeGreaterThan(0);
});
it('should include campaign title in the export', () => {
const odsData = generateVoteExportODS(mockExportData);
// Vérifier que les données sont générées
expect(odsData).toBeInstanceOf(Uint8Array);
expect(odsData.length).toBeGreaterThan(0);
});
it('should handle empty votes', () => {
const dataWithNoVotes: ExportData = {
...mockExportData,
votes: []
};
const odsData = generateVoteExportODS(dataWithNoVotes);
expect(odsData).toBeInstanceOf(Uint8Array);
expect(odsData.length).toBeGreaterThan(0);
});
it('should handle empty participants', () => {
const dataWithNoParticipants: ExportData = {
...mockExportData,
participants: []
};
const odsData = generateVoteExportODS(dataWithNoParticipants);
expect(odsData).toBeInstanceOf(Uint8Array);
expect(odsData.length).toBeGreaterThan(0);
});
it('should generate additional tabs when propositionStats are provided', () => {
const odsData = generateVoteExportODS(mockExportData);
expect(odsData).toBeInstanceOf(Uint8Array);
expect(odsData.length).toBeGreaterThan(0);
});
it('should handle anonymization levels', () => {
const odsData = generateVoteExportODS({
...mockExportData,
anonymizationLevel: 'initials'
});
expect(odsData).toBeInstanceOf(Uint8Array);
expect(odsData.length).toBeGreaterThan(0);
});
it('should include campaign title in sort tab headers', () => {
const odsData = generateVoteExportODS(mockExportData);
expect(odsData).toBeInstanceOf(Uint8Array);
expect(odsData.length).toBeGreaterThan(0);
// Vérifier que le titre de la campagne est inclus dans les en-têtes des onglets de tri
// Note: Cette vérification est basée sur la structure attendue du fichier ODS
});
});
describe('anonymizeParticipantName', () => {
const mockParticipant = {
id: 'test',
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
campaign_id: 'camp1',
short_id: 'abc123',
created_at: '2024-01-01'
};
it('should anonymize fully', () => {
const result = anonymizeParticipantName(mockParticipant, 'full');
expect(result).toBe('XXXX');
});
it('should show initials', () => {
const result = anonymizeParticipantName(mockParticipant, 'initials');
expect(result).toBe('J.D.');
});
it('should show full name', () => {
const result = anonymizeParticipantName(mockParticipant, 'none');
expect(result).toBe('John Doe');
});
it('should default to full anonymization', () => {
const result = anonymizeParticipantName(mockParticipant, 'invalid' as AnonymizationLevel);
expect(result).toBe('XXXX');
});
});
describe('formatFilename', () => {
it('should format filename correctly', () => {
const filename = formatFilename('Test Campaign 2024!');
expect(filename).toMatch(/^statistiques_vote_test_campaign_2024_\d{4}-\d{2}-\d{2}\.ods$/);
});
it('should handle special characters', () => {
const filename = formatFilename('Campagne avec des caractères spéciaux @#$%');
expect(filename).toMatch(/^statistiques_vote_campagne_avec_des_caractres_spciaux_\d{4}-\d{2}-\d{2}\.ods$/);
expect(filename).toContain('2025-08-27');
expect(filename).not.toContain('__'); // Pas d'underscores doubles
});
it('should handle empty title', () => {
const filename = formatFilename('');
expect(filename).toMatch(/^statistiques_vote_\d{4}-\d{2}-\d{2}\.ods$/);
expect(filename).toContain('2025-08-27');
});
});
});

View File

@@ -28,6 +28,7 @@ import {
Target as TargetIcon,
Hash
} from 'lucide-react';
import { ExportStatsButton } from '@/components/ExportStatsButton';
export const dynamic = 'force-dynamic';
@@ -264,6 +265,18 @@ function CampaignStatsPageContent() {
{campaign.description}
</p>
</div>
<div className="flex gap-2">
<ExportStatsButton
campaignTitle={campaign.title}
propositions={propositions}
participants={participants}
votes={votes}
budgetPerUser={campaign.budget_per_user}
propositionStats={propositionStats}
disabled={loading}
/>
</div>
</div>
</div>

View File

@@ -9,7 +9,8 @@ import { Label } from '@/components/ui/label';
import Navigation from '@/components/Navigation';
import AuthGuard from '@/components/AuthGuard';
import SmtpSettingsForm from '@/components/SmtpSettingsForm';
import { Settings, Monitor, Save, CheckCircle, Mail, FileText } from 'lucide-react';
import { Settings, Monitor, Save, CheckCircle, Mail, FileText, Download } from 'lucide-react';
import { ExportAnonymizationSelect, AnonymizationLevel } from '@/components/ExportAnonymizationSelect';
export const dynamic = 'force-dynamic';
@@ -21,6 +22,7 @@ function SettingsPageContent() {
const [randomizePropositions, setRandomizePropositions] = useState(false);
const [proposePageMessage, setProposePageMessage] = useState('');
const [footerMessage, setFooterMessage] = useState('');
const [exportAnonymization, setExportAnonymization] = useState<AnonymizationLevel>('full');
useEffect(() => {
loadSettings();
@@ -43,6 +45,10 @@ function SettingsPageContent() {
// Charger le message du bas de page
const footerValue = await settingsService.getStringValue('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL) et transparent pour tous');
setFooterMessage(footerValue);
// Charger le niveau d'anonymisation des exports
const anonymizationValue = await settingsService.getStringValue('export_anonymization', 'full') as AnonymizationLevel;
setExportAnonymization(anonymizationValue);
} catch (error) {
console.error('Erreur lors du chargement des paramètres:', error);
} finally {
@@ -60,6 +66,7 @@ function SettingsPageContent() {
await settingsService.setBooleanValue('randomize_propositions', randomizePropositions);
await settingsService.setStringValue('propose_page_message', proposePageMessage);
await settingsService.setStringValue('footer_message', footerMessage);
await settingsService.setStringValue('export_anonymization', exportAnonymization);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (error) {
@@ -216,24 +223,36 @@ function SettingsPageContent() {
</CardContent>
</Card>
{/* Exports Category */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
<Download className="w-5 h-5 text-purple-600 dark:text-purple-300" />
</div>
<div>
<CardTitle className="text-xl">Exports</CardTitle>
<CardDescription>
Paramètres de confidentialité pour les exports de données
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<ExportAnonymizationSelect
value={exportAnonymization}
onValueChange={setExportAnonymization}
/>
</div>
</CardContent>
</Card>
{/* Email Category */}
<SmtpSettingsForm onSave={() => {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}} />
{/* Future Categories Placeholder */}
<Card className="border-dashed">
<CardContent className="p-8 text-center">
<Settings className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
Plus de catégories à venir
</h3>
<p className="text-slate-600 dark:text-slate-300">
D'autres catégories de paramètres seront ajoutées prochainement.
</p>
</CardContent>
</Card>
</div>
</div>
</div>

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Shield, User, UserCheck, AlertTriangle } from 'lucide-react';
export type AnonymizationLevel = 'full' | 'initials' | 'none';
interface ExportAnonymizationSelectProps {
value: AnonymizationLevel;
onValueChange: (value: AnonymizationLevel) => void;
}
const anonymizationOptions = [
{
value: 'full' as AnonymizationLevel,
label: 'Anonymisation complète',
description: 'Noms remplacés par "XXXX"',
icon: Shield,
color: 'text-green-600'
},
{
value: 'initials' as AnonymizationLevel,
label: 'Initiales uniquement',
description: 'Premières lettres des noms/prénoms',
icon: User,
color: 'text-blue-600'
},
{
value: 'none' as AnonymizationLevel,
label: 'Aucune anonymisation',
description: 'Noms et prénoms complets',
icon: UserCheck,
color: 'text-orange-600'
}
];
export function ExportAnonymizationSelect({ value, onValueChange }: ExportAnonymizationSelectProps) {
const [showWarning, setShowWarning] = useState(false);
const handleValueChange = (newValue: AnonymizationLevel) => {
if (newValue === 'none') {
setShowWarning(true);
} else {
setShowWarning(false);
}
onValueChange(newValue);
};
const selectedOption = anonymizationOptions.find(option => option.value === value);
return (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2 block">
Niveau d'anonymisation des exports
</label>
<Select value={value} onValueChange={handleValueChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{anonymizationOptions.map((option) => {
const OptionIcon = option.icon;
return (
<SelectItem key={option.value} value={option.value} className="py-3">
<div className="flex items-center gap-3 w-full">
<OptionIcon className={`w-4 h-4 ${option.color}`} />
<div className="min-w-0 flex-1">
<div className="font-medium">{option.label}</div>
<div className="text-xs text-slate-500">{option.description}</div>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{showWarning && (
<Alert className="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950">
<AlertTriangle className="h-4 w-4 text-orange-600" />
<AlertDescription className="text-orange-800 dark:text-orange-200">
<strong>Attention RGPD :</strong> L'export sans anonymisation contient des données personnelles.
Assurez-vous d'avoir le consentement des participants et de respecter les obligations légales
en matière de protection des données personnelles.
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Download, FileSpreadsheet } from 'lucide-react';
import { generateVoteExportODS, downloadODS, formatFilename, ExportData, AnonymizationLevel } from '@/lib/export-utils';
import { settingsService } from '@/lib/services';
interface ExportStatsButtonProps {
campaignTitle: string;
propositions: any[];
participants: any[];
votes: any[];
budgetPerUser: number;
propositionStats?: any[];
disabled?: boolean;
}
export function ExportStatsButton({
campaignTitle,
propositions,
participants,
votes,
budgetPerUser,
propositionStats,
disabled = false
}: ExportStatsButtonProps) {
const [isExporting, setIsExporting] = useState(false);
const handleExport = async () => {
if (disabled || isExporting) return;
setIsExporting(true);
try {
// Récupérer le niveau d'anonymisation depuis les paramètres
const anonymizationLevel = await settingsService.getStringValue('export_anonymization', 'full') as AnonymizationLevel;
const exportData: ExportData = {
campaignTitle,
propositions,
participants,
votes,
budgetPerUser,
propositionStats,
anonymizationLevel
};
const odsData = generateVoteExportODS(exportData);
const filename = formatFilename(campaignTitle);
downloadODS(odsData, filename);
} catch (error) {
console.error('Erreur lors de l\'export:', error);
// Ici on pourrait ajouter une notification d'erreur
} finally {
setIsExporting(false);
}
};
return (
<Button
onClick={handleExport}
disabled={disabled || isExporting}
variant="outline"
className="gap-2"
>
{isExporting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Export en cours...
</>
) : (
<>
<FileSpreadsheet className="h-4 w-4" />
Exporter les votes (ODS)
</>
)}
</Button>
);
}

297
src/lib/export-utils.ts Normal file
View File

@@ -0,0 +1,297 @@
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, '_');
}