diff --git a/README.md b/README.md index 2bc5e71..b515aaa 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,12 @@ Une application web moderne et éthique pour gérer des campagnes de budgets par - **Test d'envoi** : Fonctionnalité de test des paramètres SMTP - **Templates personnalisables** : Messages d'email configurables +#### 📊 **Export des données** +- **Export ODS** : Export des statistiques de vote en format tableur +- **Format LibreOffice** : Compatible avec LibreOffice Calc, OpenOffice, Excel +- **Données complètes** : Toutes les propositions, participants et votes +- **Totaux automatiques** : Calculs des totaux par ligne et colonne + #### 🎨 **Interface moderne** - **Shadcn/ui** : Composants modernes et accessibles - **Design responsive** : Adaptation mobile/desktop @@ -406,6 +412,7 @@ Pour une documentation complète, consultez le dossier [docs/](docs/) : - **[Tests](docs/TESTING.md)** - Guide complet des tests - **[Tests - Résumé](docs/TESTING_SUMMARY.md)** - Résumé de la suite de tests - **[Tests - Démarrage rapide](docs/README-TESTS.md)** - Démarrage rapide des tests +- **[Export ODS](docs/EXPORT-FEATURE.md)** - Fonctionnalité d'export des statistiques ## 🤝 Contribution diff --git a/docs/EXPORT-FEATURE.md b/docs/EXPORT-FEATURE.md new file mode 100644 index 0000000..03c9839 --- /dev/null +++ b/docs/EXPORT-FEATURE.md @@ -0,0 +1,278 @@ +# 📊 Fonctionnalité d'Export ODS - Statistiques de Vote + +## 🎯 Vue d'ensemble + +La fonctionnalité d'export ODS permet d'exporter les statistiques de vote d'une campagne dans un format tableur compatible avec LibreOffice Calc, OpenOffice Calc et Microsoft Excel. + +## 📋 Fonctionnalités + +### ✅ **Export complet des données** +- **Onglet principal** : "Synthèse des votes" - Matrice des votes (participants × propositions) +- **6 onglets de tri** : Un pour chaque critère de tri des propositions +- **Toutes les propositions** en colonnes +- **Tous les participants** (votants ou non) en lignes +- **Montants investis** à l'intersection colonne/ligne +- **Totaux par ligne** (total voté par participant) +- **Totaux par colonne** (total reçu par proposition) +- **Budget restant** par participant +- **Anonymisation RGPD** : 3 niveaux de protection des données personnelles + +### 📊 **Structure du fichier exporté** + +#### **Onglet principal : "Synthèse des votes"** +``` +Statistiques de vote - [Nom de la campagne] + +Participant | Proposition 1 | Proposition 2 | ... | Total voté | Budget restant +-----------|---------------|---------------|-----|------------|--------------- +Alice Doe | 50 | 30 | ... | 80 | 20 +Bob Smith | 40 | 0 | ... | 40 | 60 +... | ... | ... | ... | ... | ... +TOTAL | 90 | 30 | ... | 120 | 80 +``` + +#### **Onglets de tri (6 onglets)** +Chaque onglet contient les propositions triées selon un critère : + +**Onglet "Impact total"** +``` +Statistiques de vote - [Nom de la campagne] - Tri par Impact total (Somme totale investie) + +Proposition | Votes reçus | Montant total | Montant moyen | Montant min | Montant max | Taux participation | Répartition votes | Score consensus +-----------|-------------|---------------|---------------|-------------|-------------|-------------------|-------------------|------------------ +Prop A | 5 | 250 | 50 | 30 | 70 | 100 | 5 | 15.8 +Prop B | 3 | 120 | 40 | 20 | 60 | 60 | 3 | 16.3 +``` + +**Onglets disponibles :** +- **Impact total** : Tri par montant total investi +- **Popularité** : Tri par montant moyen puis nombre de votants +- **Consensus** : Tri par score de consensus (écart-type) +- **Engagement** : Tri par taux de participation +- **Répartition** : Tri par nombre de votes différents +- **Alphabétique** : Tri par ordre alphabétique + +**Format des en-têtes :** "Statistiques de vote - [Nom Campagne] - Tri par [Critère] ([Description])" + +**Descriptions des critères :** +- **Impact total** : "Somme totale investie" +- **Popularité** : "Moyenne puis nombre de votants" +- **Consensus** : "Plus petit écart-type" +- **Engagement** : "Taux de participation" +- **Répartition** : "Nombre de votes différents" +- **Alphabétique** : "Ordre alphabétique" + +### 🎨 **Formatage** +- **En-tête** avec le titre de la campagne +- **Colonnes dimensionnées** automatiquement +- **Ligne des totaux** avec texte en gras et bordures épaisses +- **Colonnes des totaux** (Total voté, Budget restant) avec bordures épaisses +- **Nom de fichier** automatique avec date + +## 🚀 Utilisation + +### **Configuration de l'anonymisation** + +1. **Accédez** à **Paramètres** > **Exports** +2. **Choisissez** le niveau d'anonymisation : + - **Anonymisation complète** : Noms remplacés par "XXXX" (recommandé) + - **Initiales uniquement** : Premières lettres des noms/prénoms + - **Aucune anonymisation** : Noms complets (attention RGPD) +3. **Sauvegardez** les paramètres + +### **Dans l'interface d'administration** + +1. **Accédez** à la page des statistiques d'une campagne +2. **Cliquez** sur le bouton "Exporter les votes (ODS)" en haut à droite +3. **Attendez** la génération du fichier +4. **Le fichier** se télécharge automatiquement avec le niveau d'anonymisation configuré + +### **Format du nom de fichier** +``` +statistiques_vote_[nom_campagne]_[date].ods +``` + +**Exemples :** +- `statistiques_vote_budget_participatif_2024_2025-08-27.ods` +- `statistiques_vote_campagne_ete_2025-08-27.ods` + +## 🔧 Architecture technique + +### **Fichiers impliqués** + +#### `src/lib/export-utils.ts` +- **`generateVoteExportODS()`** : Génère le fichier ODS +- **`downloadODS()`** : Télécharge le fichier +- **`formatFilename()`** : Formate le nom de fichier + +#### `src/components/ExportStatsButton.tsx` +- **Composant React** pour le bouton d'export +- **Gestion des états** (chargement, erreur) +- **Interface utilisateur** avec icône et texte + +#### `src/app/admin/campaigns/[id]/stats/page.tsx` +- **Intégration** du bouton d'export +- **Passage des données** nécessaires + +### **Dépendances** +```json +{ + "xlsx": "^0.18.5" +} +``` + +## 📊 Structure des données + +### **Interface ExportData** +```typescript +interface ExportData { + campaignTitle: string; + propositions: Proposition[]; + participants: Participant[]; + votes: Vote[]; + budgetPerUser: number; +} +``` + +### **Calculs effectués** + +#### **Totaux par participant** +```typescript +const totalVoted = votes + .filter(v => v.participant_id === participant.id) + .reduce((sum, vote) => sum + vote.amount, 0); +``` + +#### **Totaux par proposition** +```typescript +const propositionTotal = votes + .filter(v => v.proposition_id === proposition.id) + .reduce((sum, vote) => sum + vote.amount, 0); +``` + +#### **Budget restant** +```typescript +const budgetRemaining = budgetPerUser - totalVoted; +``` + +## 🧪 Tests + +### **Tests unitaires** +- **Génération ODS** : Vérification de la structure +- **Formatage des noms** : Gestion des caractères spéciaux +- **Cas limites** : Participants sans votes, propositions vides + +### **Fichier de test** +`src/__tests__/lib/export-utils.test.ts` + +### **Exécution des tests** +```bash +npm test -- src/__tests__/lib/export-utils.test.ts +``` + +## 🔒 Sécurité et RGPD + +### **Anonymisation des données** +- **3 niveaux de protection** configurables dans les paramètres +- **Anonymisation complète** : Noms remplacés par "XXXX" (recommandé) +- **Initiales uniquement** : Premières lettres des noms/prénoms +- **Aucune anonymisation** : Noms complets (avec avertissement RGPD) + +### **Données exportées** +- **Aucune donnée sensible** (mots de passe, clés API) +- **Données publiques** uniquement (votes, participants, propositions) +- **Conformité RGPD** : Respect du niveau d'anonymisation configuré +- **Avertissement** : Alerte RGPD pour l'export sans anonymisation + +### **Validation** +- **Vérification des types** TypeScript +- **Validation des données** avant export +- **Gestion d'erreurs** robuste + +## 🎨 Interface utilisateur + +### **Bouton d'export** +- **Icône** : FileSpreadsheet (Lucide React) +- **Texte** : "Exporter les votes (ODS)" +- **État de chargement** : Spinner + "Export en cours..." +- **Position** : En haut à droite de la page statistiques +- **Anonymisation** : Respecte le paramètre configuré dans les paramètres + +### **États visuels** +- **Normal** : Bouton cliquable +- **Chargement** : Spinner + texte modifié +- **Désactivé** : Quand les données ne sont pas chargées + +## 🔄 Workflow + +### **1. Clic sur le bouton** +```typescript +const handleExport = async () => { + setIsExporting(true); + // Génération et téléchargement + setIsExporting(false); +}; +``` + +### **2. Génération des données** +```typescript +const exportData: ExportData = { + campaignTitle, + propositions, + participants, + votes, + budgetPerUser +}; +``` + +### **3. Création du fichier ODS** +```typescript +const odsData = generateVoteExportODS(exportData); +``` + +### **4. Téléchargement** +```typescript +const filename = formatFilename(campaignTitle); +downloadODS(odsData, filename); +``` + +## 🐛 Dépannage + +### **Problèmes courants** + +#### **Fichier ne se télécharge pas** +- Vérifiez les permissions du navigateur +- Désactivez les bloqueurs de popup +- Vérifiez l'espace disque disponible + +#### **Erreur de génération** +- Vérifiez que toutes les données sont chargées +- Consultez la console du navigateur +- Relancez l'export + +#### **Fichier corrompu** +- Vérifiez la taille du fichier +- Essayez d'ouvrir avec un autre logiciel +- Régénérez l'export + +### **Logs de débogage** +```typescript +console.error('Erreur lors de l\'export:', error); +``` + +## 🚀 Améliorations futures + +### **Fonctionnalités envisagées** +- **Export PDF** : Version imprimable +- **Filtres** : Export partiel (participants spécifiques) +- **Templates** : Formats personnalisables +- **Export automatique** : Programmation d'exports + +### **Optimisations** +- **Compression** : Réduction de la taille des fichiers +- **Cache** : Mise en cache des exports récents +- **Asynchrone** : Export en arrière-plan pour les gros volumes + +--- + +**Cette fonctionnalité facilite l'analyse et le partage des résultats de vote ! 📊✨** diff --git a/package.json b/package.json index c9a039c..651e6be 100644 --- a/package.json +++ b/package.json @@ -44,23 +44,23 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.42.1", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.12", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.0", - "tailwindcss": "^4", - "tw-animate-css": "^1.3.7", - "typescript": "^5", - "@testing-library/react": "^16.0.0", - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/user-event": "^14.5.2", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "@types/jest": "^29.5.12", "msw": "^2.2.3", "playwright": "^1.42.1", - "@playwright/test": "^1.42.1" + "tailwindcss": "^4", + "tw-animate-css": "^1.3.7", + "typescript": "^5" } } diff --git a/scripts/run-tests.js b/scripts/run-tests.js index da9aafd..fcd7295 100755 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -8,7 +8,8 @@ console.log('🧪 Lancement des Tests Automatiques - Mes Budgets Participatifs\n // Tests fonctionnels qui marchent const workingTests = [ 'src/__tests__/basic.test.ts', - 'src/__tests__/lib/utils-simple.test.ts' + 'src/__tests__/lib/utils-simple.test.ts', + 'src/__tests__/lib/export-utils.test.ts' ]; console.log('✅ Tests fonctionnels :'); diff --git a/src/__tests__/lib/export-utils.test.ts b/src/__tests__/lib/export-utils.test.ts new file mode 100644 index 0000000..fc6e5f3 --- /dev/null +++ b/src/__tests__/lib/export-utils.test.ts @@ -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'); + }); + }); +}); diff --git a/src/app/admin/campaigns/[id]/stats/page.tsx b/src/app/admin/campaigns/[id]/stats/page.tsx index d7c8468..fe2ac6b 100644 --- a/src/app/admin/campaigns/[id]/stats/page.tsx +++ b/src/app/admin/campaigns/[id]/stats/page.tsx @@ -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}
+ +- D'autres catégories de paramètres seront ajoutées prochainement. -
-