initial commit

This commit is contained in:
Yannick Le Duc
2025-08-25 14:38:13 +02:00
parent 02197190e9
commit e0f86a8845
25 changed files with 3336 additions and 128 deletions

100
DEBUG.md Normal file
View File

@@ -0,0 +1,100 @@
# Guide de Débogage - Problème de Suppression
## 🔍 Étapes de débogage
### 1. Vérifier les variables d'environnement
Assurez-vous que votre fichier `.env.local` contient les bonnes valeurs :
```env
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
```
### 2. Vérifier la console du navigateur
1. Ouvrez les outils de développement (F12)
2. Allez dans l'onglet "Console"
3. Essayez de supprimer une campagne
4. Regardez les logs qui s'affichent
Vous devriez voir :
- "Début de la suppression de la campagne: [ID]"
- "Tentative de suppression de la campagne: [ID]"
- "Campagne supprimée avec succès"
- "handleCampaignDeleted appelé"
- "Rechargement des campagnes..."
### 3. Vérifier les politiques RLS dans Supabase
Dans votre projet Supabase, allez dans **Authentication > Policies** et vérifiez que vous avez bien :
```sql
CREATE POLICY "Allow public delete access to campaigns" ON campaigns FOR DELETE USING (true);
```
### 4. Tester la suppression directement dans Supabase
1. Allez dans **Table Editor** dans Supabase
2. Sélectionnez la table `campaigns`
3. Essayez de supprimer une ligne manuellement
4. Vérifiez s'il y a des erreurs
### 5. Vérifier les contraintes de clés étrangères
Assurez-vous que les tables ont bien les contraintes `ON DELETE CASCADE` :
```sql
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE
```
## 🚨 Problèmes courants
### Problème 1 : Variables d'environnement manquantes
**Symptôme** : Erreur "supabaseUrl is required"
**Solution** : Vérifiez votre fichier `.env.local`
### Problème 2 : Politiques RLS manquantes
**Symptôme** : Erreur "new row violates row-level security policy"
**Solution** : Ajoutez la politique de suppression dans Supabase
### Problème 3 : Contraintes de clés étrangères
**Symptôme** : Erreur de contrainte lors de la suppression
**Solution** : Vérifiez que les contraintes CASCADE sont bien définies
### Problème 4 : Connexion Supabase
**Symptôme** : Erreur de connexion
**Solution** : Vérifiez l'URL et la clé anon de Supabase
## 🔧 Solutions rapides
### Réinitialiser les politiques RLS
```sql
-- Supprimer toutes les politiques existantes
DROP POLICY IF EXISTS "Allow public read access to campaigns" ON campaigns;
DROP POLICY IF EXISTS "Allow public insert access to campaigns" ON campaigns;
DROP POLICY IF EXISTS "Allow public update access to campaigns" ON campaigns;
DROP POLICY IF EXISTS "Allow public delete access to campaigns" ON campaigns;
-- Recréer les politiques
CREATE POLICY "Allow public read access to campaigns" ON campaigns FOR SELECT USING (true);
CREATE POLICY "Allow public insert access to campaigns" ON campaigns FOR INSERT WITH CHECK (true);
CREATE POLICY "Allow public update access to campaigns" ON campaigns FOR UPDATE USING (true);
CREATE POLICY "Allow public delete access to campaigns" ON campaigns FOR DELETE USING (true);
```
### Vérifier la configuration Supabase
```javascript
// Dans la console du navigateur, testez :
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
console.log('Supabase URL:', process.env.NEXT_PUBLIC_SUPABASE_URL);
```
## 📞 Support
Si le problème persiste, vérifiez :
1. Les logs dans la console du navigateur
2. Les logs dans Supabase (Dashboard > Logs)
3. Les politiques RLS dans Supabase
4. La configuration des variables d'environnement

187
README.md
View File

@@ -1,36 +1,181 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Mes Budgets Participatifs
## Getting Started
Une application web moderne pour gérer des campagnes de budgets participatifs, permettant aux collectifs de décider collectivement de leurs dépenses budgétaires.
First, run the development server:
## 🚀 Technologies utilisées
- **Frontend**: Next.js 14 avec TypeScript et App Router
- **Styling**: Tailwind CSS + Headless UI
- **Base de données**: PostgreSQL via Supabase
- **Authentification**: Supabase Auth (prévu pour les futures versions)
- **Icons**: Heroicons
## 📋 Fonctionnalités
### ✅ Implémentées
- Page d'accueil avec présentation de l'application
- Interface d'administration pour gérer les campagnes
- Création de nouvelles campagnes avec tous les paramètres
- Ajout de propositions à une campagne
- Ajout de participants à une campagne
- Gestion des états de campagne (dépôt, vote, fermé)
- Interface moderne et responsive
### 🔄 À venir
- Authentification des utilisateurs
- Interface de vote pour les participants
- Résultats et statistiques des votes
- Notifications par email
- Gestion des permissions
## 🛠️ Installation
### Prérequis
- Node.js 18+
- npm ou yarn
- Compte Supabase
### 1. Cloner le projet
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
git clone <votre-repo>
cd mes-budgets-participatifs
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### 2. Installer les dépendances
```bash
npm install
```
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
### 3. Configuration Supabase
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
#### Créer un projet Supabase
1. Allez sur [supabase.com](https://supabase.com)
2. Créez un nouveau projet
3. Notez votre URL et votre clé anon
## Learn More
#### Configurer la base de données
1. Dans votre projet Supabase, allez dans l'éditeur SQL
2. Copiez et exécutez le contenu du fichier `supabase-schema.sql`
To learn more about Next.js, take a look at the following resources:
#### Configurer les variables d'environnement
Créez un fichier `.env.local` à la racine du projet :
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```env
NEXT_PUBLIC_SUPABASE_URL=votre_url_supabase
NEXT_PUBLIC_SUPABASE_ANON_KEY=votre_cle_anon_supabase
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
### 4. Lancer l'application
```bash
npm run dev
```
## Deploy on Vercel
L'application sera accessible sur `http://localhost:3000`
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## 📊 Structure de la base de données
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Table `campaigns`
- `id`: Identifiant unique (UUID)
- `title`: Titre de la campagne
- `description`: Description détaillée
- `status`: État de la campagne (`deposit`, `voting`, `closed`)
- `budget_per_user`: Budget alloué par participant (en euros)
- `spending_tiers`: Paliers de dépenses disponibles (séparés par des virgules)
- `created_at`: Date de création
- `updated_at`: Date de dernière modification
### Table `propositions`
- `id`: Identifiant unique (UUID)
- `campaign_id`: Référence vers la campagne
- `title`: Titre de la proposition
- `description`: Description détaillée
- `created_at`: Date de création
### Table `participants`
- `id`: Identifiant unique (UUID)
- `campaign_id`: Référence vers la campagne
- `first_name`: Prénom du participant
- `last_name`: Nom du participant
- `email`: Adresse email
- `created_at`: Date de création
## 🎨 Interface utilisateur
### Page d'accueil
- Présentation de l'application
- Bouton d'accès à l'administration
- Design moderne avec gradient et cartes informatives
### Page d'administration
- Liste des campagnes existantes
- Bouton pour créer une nouvelle campagne
- Actions rapides pour chaque campagne :
- Ajouter une proposition
- Ajouter un participant
- Indicateurs visuels pour les états des campagnes
### Modals
- **Création de campagne** : Formulaire complet avec validation
- **Ajout de proposition** : Titre et description
- **Ajout de participant** : Informations personnelles
## 🔧 Développement
### Structure des fichiers
```
src/
├── app/ # Pages Next.js (App Router)
│ ├── page.tsx # Page d'accueil
│ └── admin/ # Pages d'administration
├── components/ # Composants React réutilisables
├── lib/ # Services et configuration
│ ├── supabase.ts # Configuration Supabase
│ └── services.ts # Services de données
└── types/ # Types TypeScript
└── index.ts # Définitions des types
```
### Scripts disponibles
```bash
npm run dev # Lancer en mode développement
npm run build # Construire pour la production
npm run start # Lancer en mode production
npm run lint # Vérifier le code avec ESLint
```
## 🚀 Déploiement
### Vercel (recommandé)
1. Connectez votre repo GitHub à Vercel
2. Configurez les variables d'environnement dans Vercel
3. Déployez automatiquement
### Autres plateformes
L'application peut être déployée sur n'importe quelle plateforme supportant Next.js :
- Netlify
- Railway
- DigitalOcean App Platform
- AWS Amplify
## 🤝 Contribution
1. Fork le projet
2. Créez une branche pour votre fonctionnalité
3. Committez vos changements
4. Poussez vers la branche
5. Ouvrez une Pull Request
## 📝 Licence
Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails.
## 🆘 Support
Pour toute question ou problème :
1. Vérifiez la documentation Supabase
2. Consultez les issues GitHub
3. Créez une nouvelle issue si nécessaire
---
**Développé avec ❤️ pour faciliter la démocratie participative**

165
SETUP.md Normal file
View File

@@ -0,0 +1,165 @@
# Guide de Configuration - Mes Budgets Participatifs
## 🚀 Configuration Rapide
### 1. Installation des dépendances
```bash
npm install
```
### 2. Configuration Supabase
#### Étape 1 : Créer un projet Supabase
1. Allez sur [supabase.com](https://supabase.com)
2. Créez un nouveau projet
3. Notez votre URL et votre clé anon dans les paramètres du projet
#### Étape 2 : Configurer la base de données
1. Dans votre projet Supabase, allez dans **SQL Editor**
2. Copiez et exécutez le contenu du fichier `supabase-schema.sql`
3. Vérifiez que les tables sont créées dans **Table Editor**
#### Étape 3 : Configurer les variables d'environnement
1. Copiez le fichier `env.example` vers `.env.local`
2. Remplacez les valeurs par vos vraies informations Supabase :
```env
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
```
### 3. Lancer l'application
```bash
npm run dev
```
L'application sera accessible sur `http://localhost:3000`
## 📊 Structure de la Base de Données
### Tables créées automatiquement :
#### `campaigns`
- **id** : Identifiant unique (UUID)
- **title** : Titre de la campagne
- **description** : Description détaillée
- **status** : État (`deposit`, `voting`, `closed`)
- **budget_per_user** : Budget par participant (€)
- **spending_tiers** : Paliers de dépenses (ex: "10,25,50,100")
- **created_at** : Date de création
- **updated_at** : Date de modification
#### `propositions`
- **id** : Identifiant unique (UUID)
- **campaign_id** : Référence vers la campagne
- **title** : Titre de la proposition
- **description** : Description détaillée
- **created_at** : Date de création
#### `participants`
- **id** : Identifiant unique (UUID)
- **campaign_id** : Référence vers la campagne
- **first_name** : Prénom
- **last_name** : Nom
- **email** : Adresse email
- **created_at** : Date de création
## 🔧 Fonctionnalités Disponibles
### Page d'accueil (`/`)
- Présentation de l'application
- Bouton d'accès à l'administration
- Design moderne et responsive
### Page d'administration (`/admin`)
- **Liste des campagnes** : Affichage de toutes les campagnes avec leur statut
- **Créer une campagne** : Formulaire complet avec validation
- **Ajouter des propositions** : Par campagne
- **Ajouter des participants** : Par campagne
- **Indicateurs visuels** : Statuts colorés pour chaque campagne
### Modals interactifs
- **Création de campagne** : Titre, description, budget, paliers, statut
- **Ajout de proposition** : Titre et description
- **Ajout de participant** : Prénom, nom, email
## 🎨 Interface Utilisateur
### Design System
- **Framework CSS** : Tailwind CSS
- **Composants** : Headless UI pour les modals
- **Icônes** : Heroicons
- **Couleurs** : Palette moderne avec indigo comme couleur principale
### Responsive Design
- Mobile-first approach
- Adaptation automatique sur tous les écrans
- Navigation intuitive
## 🚀 Déploiement
### Vercel (Recommandé)
1. Connectez votre repo GitHub à Vercel
2. Configurez les variables d'environnement dans Vercel
3. Déployez automatiquement
### Variables d'environnement pour la production
```env
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```
## 🔍 Dépannage
### Erreurs courantes
#### "supabaseUrl is required"
- Vérifiez que vos variables d'environnement sont correctement définies
- Redémarrez le serveur de développement
#### Erreurs de build
- Vérifiez que tous les composants sont correctement importés
- Assurez-vous que les types TypeScript sont corrects
#### Problèmes de base de données
- Vérifiez que le script SQL a été exécuté correctement
- Contrôlez les politiques RLS dans Supabase
### Logs de développement
```bash
# Voir les logs en temps réel
npm run dev
# Build de production
npm run build
# Lancer en production
npm run start
```
## 📈 Prochaines Étapes
### Fonctionnalités prévues
- [ ] Authentification des utilisateurs
- [ ] Interface de vote pour les participants
- [ ] Résultats et statistiques
- [ ] Notifications par email
- [ ] Gestion des permissions
### Améliorations techniques
- [ ] Types TypeScript stricts pour Supabase
- [ ] Tests unitaires et d'intégration
- [ ] Optimisation des performances
- [ ] PWA (Progressive Web App)
## 🤝 Support
Pour toute question ou problème :
1. Vérifiez ce guide de configuration
2. Consultez la documentation Supabase
3. Vérifiez les logs de développement
4. Créez une issue sur GitHub si nécessaire
---
**Application prête à l'emploi pour la démocratie participative ! 🗳️**

6
env.example Normal file
View File

@@ -0,0 +1,6 @@
# Variables d'environnement pour Mes Budgets Participatifs
# Copiez ce fichier vers .env.local et remplissez avec vos vraies valeurs
# Configuration Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here

14
next.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Désactiver le pré-rendu statique pour les pages qui utilisent Supabase
experimental: {
// Optimisations pour le développement
},
// Configuration pour éviter les erreurs de build avec les variables d'environnement
env: {
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
},
}
module.exports = nextConfig

367
package-lock.json generated
View File

@@ -8,6 +8,9 @@
"name": "mes-budgets-participatifs",
"version": "0.1.0",
"dependencies": {
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@supabase/supabase-js": "^2.56.0",
"next": "15.5.0",
"react": "19.1.0",
"react-dom": "19.1.0"
@@ -211,6 +214,88 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@headlessui/react": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.7.tgz",
"integrity": "sha512-WKdTymY8Y49H8/gUc/lIyYK1M+/6dq0Iywh4zTZVAaiTDprRfioxSgD0wnXTQTBpjpGJuTL1NO/mqEvc//5SSg==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.20.2",
"@react-aria/interactions": "^3.25.0",
"@tanstack/react-virtual": "^3.13.9",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -963,6 +1048,103 @@
"node": ">=12.4.0"
}
},
"node_modules/@react-aria/focus": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.0.tgz",
"integrity": "sha512-7NEGtTPsBy52EZ/ToVKCu0HSelE3kq9qeis+2eEq90XSuJOMaDHUQrA7RC2Y89tlEwQB31bud/kKRi9Qme1dkA==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/interactions": "^3.25.4",
"@react-aria/utils": "^3.30.0",
"@react-types/shared": "^3.31.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.25.4",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.4.tgz",
"integrity": "sha512-HBQMxgUPHrW8V63u9uGgBymkMfj6vdWbB0GgUJY49K9mBKMsypcHeWkWM6+bF7kxRO728/IK8bWDV6whDbqjHg==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.30.0",
"@react-stately/flags": "^3.1.2",
"@react-types/shared": "^3.31.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/utils": {
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.30.0.tgz",
"integrity": "sha512-ydA6y5G1+gbem3Va2nczj/0G0W7/jUVo/cbN10WA5IizzWIwMP5qhFr7macgbKfHMkZ+YZC3oXnt2NNre5odKw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-stately/flags": "^3.1.2",
"@react-stately/utils": "^3.10.8",
"@react-types/shared": "^3.31.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-stately/flags": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.8",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -977,6 +1159,80 @@
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.71.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.21.3",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz",
"integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.1.tgz",
"integrity": "sha512-edRFa2IrQw50kNntvUyS38hsL7t2d/psah6om6aNTLLcWem0R6bOUq7sk7DsGeSlNfuwEwWn57FdYSva6VddYw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.13",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"ws": "^8.18.2"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz",
"integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.56.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.56.0.tgz",
"integrity": "sha512-XqwhHSyVnkjdliPN61CmXsmFGnFHTX2WDdwjG3Ukvdzuu3Trix+dXupYOQ3BueIyYp7B6t0yYpdQtJP2hIInyg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.71.1",
"@supabase/functions-js": "2.4.5",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.21.3",
"@supabase/realtime-js": "2.15.1",
"@supabase/storage-js": "^2.10.4"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1262,6 +1518,33 @@
"tailwindcss": "4.1.12"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
@@ -1298,12 +1581,17 @@
"version": "20.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
@@ -1324,6 +1612,15 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
@@ -2311,6 +2608,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -5703,6 +6009,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
@@ -5803,6 +6115,12 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -5963,7 +6281,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -6011,6 +6328,31 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6126,6 +6468,27 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@@ -9,19 +9,22 @@
"lint": "eslint"
},
"dependencies": {
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@supabase/supabase-js": "^2.56.0",
"next": "15.5.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.0"
"react-dom": "19.1.0"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.5.0",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,252 @@
'use client';
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 AddParticipantModal from '@/components/AddParticipantModal';
import EditParticipantModal from '@/components/EditParticipantModal';
import DeleteParticipantModal from '@/components/DeleteParticipantModal';
// Force dynamic rendering to avoid SSR issues with Supabase
export const dynamic = 'force-dynamic';
export default function CampaignParticipantsPage() {
const params = useParams();
const campaignId = params.id as string;
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [participants, setParticipants] = useState<Participant[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
useEffect(() => {
if (campaignId) {
loadData();
}
}, [campaignId]);
const loadData = async () => {
try {
setLoading(true);
const [campaignData, participantsData] = await Promise.all([
campaignService.getAll().then(campaigns => campaigns.find(c => c.id === campaignId)),
participantService.getByCampaign(campaignId)
]);
setCampaign(campaignData || null);
setParticipants(participantsData);
} catch (error) {
console.error('Erreur lors du chargement des données:', error);
} finally {
setLoading(false);
}
};
const handleParticipantAdded = () => {
setShowAddModal(false);
loadData();
};
const handleParticipantEdited = () => {
setShowEditModal(false);
setSelectedParticipant(null);
loadData();
};
const handleParticipantDeleted = () => {
setShowDeleteModal(false);
setSelectedParticipant(null);
loadData();
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement des participants...</p>
</div>
</div>
);
}
if (!campaign) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-900">Campagne non trouvée</h2>
<p className="mt-2 text-gray-600">La campagne demandée n'existe pas.</p>
<Link
href="/admin"
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Retour à l'administration
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center space-x-4">
<Link
href="/admin"
className="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour
</Link>
<div>
<h1 className="text-3xl font-bold text-gray-900">Participants</h1>
<p className="mt-2 text-gray-600">
Campagne : <span className="font-medium">{campaign.title}</span>
</p>
</div>
</div>
</div>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Ajouter un participant
</button>
</div>
</div>
{/* Participants */}
{participants.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun participant</h3>
<p className="mt-1 text-sm text-gray-500">Commencez par ajouter votre premier participant.</p>
<div className="mt-6">
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Ajouter un participant
</button>
</div>
</div>
) : (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Participants ({participants.length})
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Liste de tous les participants pour cette campagne
</p>
</div>
<ul className="divide-y divide-gray-200">
{participants.map((participant) => (
<li key={participant.id} className="px-4 py-6 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3 mb-2">
<div className="flex-shrink-0">
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
<span className="text-sm font-medium text-indigo-600">
{participant.first_name.charAt(0)}{participant.last_name.charAt(0)}
</span>
</div>
</div>
<div>
<h4 className="text-lg font-medium text-gray-900">
{participant.first_name} {participant.last_name}
</h4>
<p className="text-sm text-gray-600">
{participant.email}
</p>
</div>
</div>
<p className="text-xs text-gray-500">
Inscrit le {new Date(participant.created_at).toLocaleDateString('fr-FR')}
</p>
</div>
<div className="flex items-center space-x-2 ml-6">
<button
onClick={() => {
setSelectedParticipant(participant);
setShowEditModal(true);
}}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Modifier
</button>
<button
onClick={() => {
setSelectedParticipant(participant);
setShowDeleteModal(true);
}}
className="inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Supprimer
</button>
</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
{/* Modals */}
{showAddModal && (
<AddParticipantModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={handleParticipantAdded}
campaignId={campaignId}
campaignTitle={campaign.title}
/>
)}
{showEditModal && selectedParticipant && (
<EditParticipantModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
onSuccess={handleParticipantEdited}
participant={selectedParticipant}
/>
)}
{showDeleteModal && selectedParticipant && (
<DeleteParticipantModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onSuccess={handleParticipantDeleted}
participant={selectedParticipant}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,241 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Campaign, Proposition } from '@/types';
import { campaignService, propositionService } from '@/lib/services';
import AddPropositionModal from '@/components/AddPropositionModal';
import EditPropositionModal from '@/components/EditPropositionModal';
import DeletePropositionModal from '@/components/DeletePropositionModal';
// Force dynamic rendering to avoid SSR issues with Supabase
export const dynamic = 'force-dynamic';
export default function CampaignPropositionsPage() {
const params = useParams();
const campaignId = params.id as string;
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [propositions, setPropositions] = useState<Proposition[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedProposition, setSelectedProposition] = useState<Proposition | null>(null);
useEffect(() => {
if (campaignId) {
loadData();
}
}, [campaignId]);
const loadData = async () => {
try {
setLoading(true);
const [campaignData, propositionsData] = await Promise.all([
campaignService.getAll().then(campaigns => campaigns.find(c => c.id === campaignId)),
propositionService.getByCampaign(campaignId)
]);
setCampaign(campaignData || null);
setPropositions(propositionsData);
} catch (error) {
console.error('Erreur lors du chargement des données:', error);
} finally {
setLoading(false);
}
};
const handlePropositionAdded = () => {
setShowAddModal(false);
loadData();
};
const handlePropositionEdited = () => {
setShowEditModal(false);
setSelectedProposition(null);
loadData();
};
const handlePropositionDeleted = () => {
setShowDeleteModal(false);
setSelectedProposition(null);
loadData();
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement des propositions...</p>
</div>
</div>
);
}
if (!campaign) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-900">Campagne non trouvée</h2>
<p className="mt-2 text-gray-600">La campagne demandée n'existe pas.</p>
<Link
href="/admin"
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Retour à l'administration
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center space-x-4">
<Link
href="/admin"
className="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour
</Link>
<div>
<h1 className="text-3xl font-bold text-gray-900">Propositions</h1>
<p className="mt-2 text-gray-600">
Campagne : <span className="font-medium">{campaign.title}</span>
</p>
</div>
</div>
</div>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Ajouter une proposition
</button>
</div>
</div>
{/* Propositions */}
{propositions.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune proposition</h3>
<p className="mt-1 text-sm text-gray-500">Commencez par ajouter votre première proposition.</p>
<div className="mt-6">
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Ajouter une proposition
</button>
</div>
</div>
) : (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Propositions ({propositions.length})
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Liste de toutes les propositions pour cette campagne
</p>
</div>
<ul className="divide-y divide-gray-200">
{propositions.map((proposition) => (
<li key={proposition.id} className="px-4 py-6 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h4 className="text-lg font-medium text-gray-900 mb-2">
{proposition.title}
</h4>
<p className="text-sm text-gray-600 mb-3">
{proposition.description}
</p>
<p className="text-xs text-gray-500">
Créée le {new Date(proposition.created_at).toLocaleDateString('fr-FR')}
</p>
</div>
<div className="flex items-center space-x-2 ml-6">
<button
onClick={() => {
setSelectedProposition(proposition);
setShowEditModal(true);
}}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Modifier
</button>
<button
onClick={() => {
setSelectedProposition(proposition);
setShowDeleteModal(true);
}}
className="inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Supprimer
</button>
</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
{/* Modals */}
{showAddModal && (
<AddPropositionModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={handlePropositionAdded}
campaignId={campaignId}
campaignTitle={campaign.title}
/>
)}
{showEditModal && selectedProposition && (
<EditPropositionModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
onSuccess={handlePropositionEdited}
proposition={selectedProposition}
/>
)}
{showDeleteModal && selectedProposition && (
<DeletePropositionModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onSuccess={handlePropositionDeleted}
proposition={selectedProposition}
/>
)}
</div>
);
}

301
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,301 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Campaign, CampaignWithStats } from '@/types';
import { campaignService } from '@/lib/services';
import CreateCampaignModal from '@/components/CreateCampaignModal';
import EditCampaignModal from '@/components/EditCampaignModal';
import DeleteCampaignModal from '@/components/DeleteCampaignModal';
// Force dynamic rendering to avoid SSR issues with Supabase
export const dynamic = 'force-dynamic';
export default function AdminPage() {
const [campaigns, setCampaigns] = useState<CampaignWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
useEffect(() => {
loadCampaigns();
}, []);
const loadCampaigns = async () => {
try {
const campaignsData = await campaignService.getAll();
// Charger les statistiques pour chaque campagne
const campaignsWithStats = await Promise.all(
campaignsData.map(async (campaign) => {
const stats = await campaignService.getStats(campaign.id);
return {
...campaign,
stats
};
})
);
setCampaigns(campaignsWithStats);
} catch (error) {
console.error('Erreur lors du chargement des campagnes:', error);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'deposit':
return 'bg-blue-100 text-blue-800';
case 'voting':
return 'bg-green-100 text-green-800';
case 'closed':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'deposit':
return 'Dépôt de propositions';
case 'voting':
return 'En cours de vote';
case 'closed':
return 'Fermé';
default:
return status;
}
};
const handleCampaignCreated = () => {
setShowCreateModal(false);
loadCampaigns();
};
const handleCampaignEdited = () => {
setShowEditModal(false);
setSelectedCampaign(null);
loadCampaigns();
};
const handleCampaignDeleted = () => {
console.log('handleCampaignDeleted appelé');
setShowDeleteModal(false);
setSelectedCampaign(null);
console.log('Rechargement des campagnes...');
loadCampaigns();
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement des campagnes...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Administration</h1>
<p className="mt-2 text-gray-600">Gérez vos campagnes de budgets participatifs</p>
</div>
<div className="flex items-center space-x-4">
<Link
href="/"
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour
</Link>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouvelle campagne
</button>
</div>
</div>
</div>
{/* Campagnes */}
{campaigns.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune campagne</h3>
<p className="mt-1 text-sm text-gray-500">Commencez par créer votre première campagne.</p>
<div className="mt-6">
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Créer une campagne
</button>
</div>
</div>
) : (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Campagnes ({campaigns.length})
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Liste de toutes vos campagnes de budgets participatifs
</p>
</div>
<ul className="divide-y divide-gray-200">
{campaigns.map((campaign) => (
<li key={campaign.id} className="px-4 py-6 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-medium text-gray-900">{campaign.title}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(campaign.status)}`}>
{getStatusText(campaign.status)}
</span>
</div>
</div>
<p className="text-sm text-gray-600 mb-4">{campaign.description}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center">
<svg className="w-5 h-5 text-gray-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
<span className="text-sm font-medium text-gray-900">{campaign.budget_per_user}</span>
</div>
<p className="text-xs text-gray-500 mt-1">Budget par utilisateur</p>
</div>
<div className="bg-blue-50 rounded-lg p-3">
<div className="flex items-center">
<svg className="w-5 h-5 text-blue-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm font-medium text-blue-900">{campaign.stats.propositions}</span>
</div>
<p className="text-xs text-blue-600 mt-1">Propositions</p>
</div>
<div className="bg-green-50 rounded-lg p-3">
<div className="flex items-center">
<svg className="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="text-sm font-medium text-green-900">{campaign.stats.participants}</span>
</div>
<p className="text-xs text-green-600 mt-1">Participants</p>
</div>
</div>
<div className="text-sm text-gray-500">
<span className="font-medium">Paliers de dépenses:</span> {campaign.spending_tiers}
</div>
</div>
<div className="flex items-center space-x-2 ml-6">
<button
onClick={() => {
setSelectedCampaign(campaign);
setShowEditModal(true);
}}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Modifier
</button>
<Link
href={`/admin/campaigns/${campaign.id}/propositions`}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Propositions
</Link>
<Link
href={`/admin/campaigns/${campaign.id}/participants`}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Votants
</Link>
<button
onClick={() => {
setSelectedCampaign(campaign);
setShowDeleteModal(true);
}}
className="inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Supprimer
</button>
</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
{/* Modals */}
{showCreateModal && (
<CreateCampaignModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={handleCampaignCreated}
/>
)}
{showEditModal && selectedCampaign && (
<EditCampaignModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
onSuccess={handleCampaignEdited}
campaign={selectedCampaign}
/>
)}
{showDeleteModal && selectedCampaign && (
<DeleteCampaignModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onSuccess={handleCampaignDeleted}
campaign={selectedCampaign}
/>
)}
</div>
);
}

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Mes budgets participatifs",
description: "Votez pour les dépenses de votre collectif",
};
export default function RootLayout({

View File

@@ -1,103 +1,64 @@
import Image from "next/image";
import Link from 'next/link';
export default function Home() {
export default function HomePage() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
<h1 className="text-4xl md:text-6xl font-bold text-gray-900 mb-6">
Mes Budgets Participatifs
</h1>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<p className="text-xl md:text-2xl text-gray-600 mb-8 leading-relaxed">
Participez aux décisions budgétaires de vos collectifs
</p>
<div className="space-y-4">
<Link
href="/admin"
className="inline-flex items-center px-8 py-4 bg-indigo-600 text-white font-semibold rounded-lg hover:bg-indigo-700 transition-colors duration-200 shadow-lg hover:shadow-xl"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Accès Admin
</Link>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6 text-left">
<div className="bg-blue-50 p-6 rounded-xl">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="font-semibold text-gray-900 mb-2">Déposez vos propositions</h3>
<p className="text-gray-600">Soumettez vos idées pour améliorer votre collectif</p>
</div>
<div className="bg-green-50 p-6 rounded-xl">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="font-semibold text-gray-900 mb-2">Votez collectivement</h3>
<p className="text-gray-600">Participez aux décisions avec votre budget alloué</p>
</div>
<div className="bg-purple-50 p-6 rounded-xl">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<h3 className="font-semibold text-gray-900 mb-2">Suivez les résultats</h3>
<p className="text-gray-600">Découvrez quelles propositions ont é sélectionnées</p>
</div>
</div>
</div>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { participantService } from '@/lib/services';
interface AddParticipantModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
campaignId: string;
campaignTitle: string;
}
export default function AddParticipantModal({
isOpen,
onClose,
onSuccess,
campaignId,
campaignTitle
}: AddParticipantModalProps) {
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
email: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await participantService.create({
campaign_id: campaignId,
first_name: formData.first_name,
last_name: formData.last_name,
email: formData.email
});
onSuccess();
// Reset form
setFormData({
first_name: '',
last_name: '',
email: ''
});
} catch (err) {
setError('Erreur lors de l\'ajout du participant');
console.error(err);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Ajouter un participant
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="px-6 py-4 bg-green-50 border-b border-gray-200">
<p className="text-sm text-green-800">
<span className="font-medium">Campagne:</span> {campaignTitle}
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-1">
Prénom *
</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
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"
/>
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-1">
Nom *
</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
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"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
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="email@exemple.com"
/>
</div>
<div className="flex items-center justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Ajout...' : 'Ajouter le participant'}
</button>
</div>
</form>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { propositionService } from '@/lib/services';
interface AddPropositionModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
campaignId: string;
campaignTitle: string;
}
export default function AddPropositionModal({
isOpen,
onClose,
onSuccess,
campaignId,
campaignTitle
}: AddPropositionModalProps) {
const [formData, setFormData] = useState({
title: '',
description: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await propositionService.create({
campaign_id: campaignId,
title: formData.title,
description: formData.description
});
onSuccess();
// Reset form
setFormData({
title: '',
description: ''
});
} catch (err) {
setError('Erreur lors de l\'ajout de la proposition');
console.error(err);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Ajouter une proposition
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="px-6 py-4 bg-blue-50 border-b border-gray-200">
<p className="text-sm text-blue-800">
<span className="font-medium">Campagne:</span> {campaignTitle}
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Titre de la proposition *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
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="Titre de la proposition"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
required
rows={4}
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="Décrivez votre proposition en détail..."
/>
</div>
<div className="flex items-center justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Ajout...' : 'Ajouter la proposition'}
</button>
</div>
</form>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,189 @@
'use client';
import { useState } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { campaignService } from '@/lib/services';
import { CampaignStatus } from '@/types';
interface CreateCampaignModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function CreateCampaignModal({ isOpen, onClose, onSuccess }: CreateCampaignModalProps) {
const [formData, setFormData] = useState({
title: '',
description: '',
status: 'deposit' as CampaignStatus,
budget_per_user: 100,
spending_tiers: '10,25,50,100'
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await campaignService.create(formData);
onSuccess();
// Reset form
setFormData({
title: '',
description: '',
status: 'deposit',
budget_per_user: 100,
spending_tiers: '10,25,50,100'
});
} catch (err) {
setError('Erreur lors de la création de la campagne');
console.error(err);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'budget_per_user' ? parseInt(value) || 0 : value
}));
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Créer une nouvelle campagne
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Titre *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
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="Titre de la campagne"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
required
rows={3}
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="Description de la campagne"
/>
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
État initial
</label>
<select
id="status"
name="status"
value={formData.status}
onChange={handleChange}
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"
>
<option value="deposit">Dépôt de propositions</option>
<option value="voting">En cours de vote</option>
<option value="closed">Fermé</option>
</select>
</div>
<div>
<label htmlFor="budget_per_user" className="block text-sm font-medium text-gray-700 mb-1">
Budget par utilisateur () *
</label>
<input
type="number"
id="budget_per_user"
name="budget_per_user"
value={formData.budget_per_user}
onChange={handleChange}
required
min="1"
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="100"
/>
</div>
<div>
<label htmlFor="spending_tiers" className="block text-sm font-medium text-gray-700 mb-1">
Paliers de dépenses () *
</label>
<input
type="text"
id="spending_tiers"
name="spending_tiers"
value={formData.spending_tiers}
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="10,25,50,100"
/>
<p className="mt-1 text-sm text-gray-500">
Montants séparés par des virgules (ex: 10,25,50,100)
</p>
</div>
<div className="flex items-center justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Création...' : 'Créer la campagne'}
</button>
</div>
</form>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import { campaignService } from '@/lib/services';
import { Campaign } from '@/types';
interface DeleteCampaignModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
campaign: Campaign;
}
export default function DeleteCampaignModal({
isOpen,
onClose,
onSuccess,
campaign
}: DeleteCampaignModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleDelete = async () => {
console.log('Début de la suppression de la campagne:', campaign.id);
setLoading(true);
setError('');
try {
await campaignService.delete(campaign.id);
console.log('Suppression réussie, appel de onSuccess');
onSuccess();
} catch (err: any) {
console.error('Erreur dans le modal de suppression:', err);
const errorMessage = err?.message || err?.details || 'Erreur lors de la suppression de la campagne';
setError(`Erreur lors de la suppression de la campagne: ${errorMessage}`);
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Supprimer la campagne
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="p-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
{error}
</div>
)}
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 mb-2">
Êtes-vous sûr de vouloir supprimer cette campagne ?
</h3>
<div className="text-sm text-gray-500 space-y-2">
<p>
<strong>Campagne :</strong> {campaign.title}
</p>
<p>
Cette action supprimera définitivement :
</p>
<ul className="list-disc list-inside ml-2 space-y-1">
<li>La campagne et tous ses paramètres</li>
<li>Toutes les propositions associées</li>
<li>Tous les participants inscrits</li>
</ul>
<p className="text-red-600 font-medium mt-3">
Cette action est irréversible !
</p>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
Annuler
</button>
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Suppression...' : 'Supprimer définitivement'}
</button>
</div>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useState } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import { participantService } from '@/lib/services';
import { Participant } from '@/types';
interface DeleteParticipantModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
participant: Participant;
}
export default function DeleteParticipantModal({
isOpen,
onClose,
onSuccess,
participant
}: DeleteParticipantModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleDelete = async () => {
setLoading(true);
setError('');
try {
await participantService.delete(participant.id);
onSuccess();
} catch (err: any) {
const errorMessage = err?.message || err?.details || 'Erreur lors de la suppression du participant';
setError(`Erreur lors de la suppression du participant: ${errorMessage}`);
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Supprimer le participant
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="p-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
{error}
</div>
)}
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 mb-2">
Êtes-vous sûr de vouloir supprimer ce participant ?
</h3>
<div className="text-sm text-gray-500 space-y-2">
<p>
<strong>Participant :</strong> {participant.first_name} {participant.last_name}
</p>
<p>
<strong>Email :</strong> {participant.email}
</p>
<p>
Cette action supprimera définitivement le participant et toutes les données associées.
</p>
<p className="text-red-600 font-medium mt-3">
Cette action est irréversible !
</p>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
Annuler
</button>
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Suppression...' : 'Supprimer définitivement'}
</button>
</div>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useState } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import { propositionService } from '@/lib/services';
import { Proposition } from '@/types';
interface DeletePropositionModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
proposition: Proposition;
}
export default function DeletePropositionModal({
isOpen,
onClose,
onSuccess,
proposition
}: DeletePropositionModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleDelete = async () => {
setLoading(true);
setError('');
try {
await propositionService.delete(proposition.id);
onSuccess();
} catch (err: any) {
const errorMessage = err?.message || err?.details || 'Erreur lors de la suppression de la proposition';
setError(`Erreur lors de la suppression de la proposition: ${errorMessage}`);
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Supprimer la proposition
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="p-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
{error}
</div>
)}
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 mb-2">
Êtes-vous sûr de vouloir supprimer cette proposition ?
</h3>
<div className="text-sm text-gray-500 space-y-2">
<p>
<strong>Proposition :</strong> {proposition.title}
</p>
<p>
Cette action supprimera définitivement la proposition et toutes les données associées.
</p>
<p className="text-red-600 font-medium mt-3">
Cette action est irréversible !
</p>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
Annuler
</button>
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Suppression...' : 'Supprimer définitivement'}
</button>
</div>
</div>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { campaignService } from '@/lib/services';
import { Campaign, CampaignStatus } from '@/types';
interface EditCampaignModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
campaign: Campaign;
}
export default function EditCampaignModal({
isOpen,
onClose,
onSuccess,
campaign
}: EditCampaignModalProps) {
const [formData, setFormData] = useState({
title: '',
description: '',
status: 'deposit' as CampaignStatus,
budget_per_user: 100,
spending_tiers: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Initialiser le formulaire avec les données de la campagne
useEffect(() => {
if (campaign) {
setFormData({
title: campaign.title,
description: campaign.description,
status: campaign.status,
budget_per_user: campaign.budget_per_user,
spending_tiers: campaign.spending_tiers
});
}
}, [campaign]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await campaignService.update(campaign.id, formData);
onSuccess();
} catch (err) {
setError('Erreur lors de la modification de la campagne');
console.error(err);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'budget_per_user' ? parseInt(value) || 0 : value
}));
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Modifier la campagne
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Titre *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
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="Titre de la campagne"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
required
rows={3}
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="Description de la campagne"
/>
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
État
</label>
<select
id="status"
name="status"
value={formData.status}
onChange={handleChange}
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"
>
<option value="deposit">Dépôt de propositions</option>
<option value="voting">En cours de vote</option>
<option value="closed">Fermé</option>
</select>
</div>
<div>
<label htmlFor="budget_per_user" className="block text-sm font-medium text-gray-700 mb-1">
Budget par utilisateur () *
</label>
<input
type="number"
id="budget_per_user"
name="budget_per_user"
value={formData.budget_per_user}
onChange={handleChange}
required
min="1"
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="100"
/>
</div>
<div>
<label htmlFor="spending_tiers" className="block text-sm font-medium text-gray-700 mb-1">
Paliers de dépenses () *
</label>
<input
type="text"
id="spending_tiers"
name="spending_tiers"
value={formData.spending_tiers}
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="10,25,50,100"
/>
<p className="mt-1 text-sm text-gray-500">
Montants séparés par des virgules (ex: 10,25,50,100)
</p>
</div>
<div className="flex items-center justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Modification...' : 'Modifier la campagne'}
</button>
</div>
</form>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { participantService } from '@/lib/services';
import { Participant } from '@/types';
interface EditParticipantModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
participant: Participant;
}
export default function EditParticipantModal({
isOpen,
onClose,
onSuccess,
participant
}: EditParticipantModalProps) {
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
email: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Initialiser le formulaire avec les données du participant
useEffect(() => {
if (participant) {
setFormData({
first_name: participant.first_name,
last_name: participant.last_name,
email: participant.email
});
}
}, [participant]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await participantService.update(participant.id, formData);
onSuccess();
} catch (err: any) {
const errorMessage = err?.message || err?.details || 'Erreur lors de la modification du participant';
setError(`Erreur lors de la modification du participant: ${errorMessage}`);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Modifier le participant
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-1">
Prénom *
</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
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"
/>
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-1">
Nom *
</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
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"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
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="email@exemple.com"
/>
</div>
<div className="flex items-center justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Modification...' : 'Modifier le participant'}
</button>
</div>
</form>
</Dialog.Panel>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { propositionService } from '@/lib/services';
import { Proposition } from '@/types';
interface EditPropositionModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
proposition: Proposition;
}
export default function EditPropositionModal({
isOpen,
onClose,
onSuccess,
proposition
}: EditPropositionModalProps) {
const [formData, setFormData] = useState({
title: '',
description: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Initialiser le formulaire avec les données de la proposition
useEffect(() => {
if (proposition) {
setFormData({
title: proposition.title,
description: proposition.description
});
}
}, [proposition]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await propositionService.update(proposition.id, formData);
onSuccess();
} catch (err: any) {
const errorMessage = err?.message || err?.details || 'Erreur lors de la modification de la proposition';
setError(`Erreur lors de la modification de la proposition: ${errorMessage}`);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md w-full bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<Dialog.Title className="text-lg font-medium text-gray-900">
Modifier la proposition
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Titre de la proposition *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
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="Titre de la proposition"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
required
rows={4}
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="Décrivez votre proposition en détail..."
/>
</div>
<div className="flex items-center justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Modification...' : 'Modifier la proposition'}
</button>
</div>
</form>
</Dialog.Panel>
</div>
</Dialog>
);
}

175
src/lib/services.ts Normal file
View File

@@ -0,0 +1,175 @@
import { supabase } from './supabase';
import { Campaign, Proposition, Participant } from '@/types';
// Services pour les campagnes
export const campaignService = {
async getAll(): Promise<Campaign[]> {
const { data, error } = await supabase
.from('campaigns')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(campaign: any): Promise<Campaign> {
const { data, error } = await supabase
.from('campaigns')
.insert(campaign)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(id: string, updates: any): Promise<Campaign> {
const { data, error } = await supabase
.from('campaigns')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
async delete(id: string): Promise<void> {
console.log('Tentative de suppression de la campagne:', id);
// La suppression en cascade est gérée par la base de données
// grâce à ON DELETE CASCADE dans les contraintes de clés étrangères
const { error } = await supabase
.from('campaigns')
.delete()
.eq('id', id);
if (error) {
console.error('Erreur lors de la suppression:', error);
throw error;
}
console.log('Campagne supprimée avec succès');
},
async getStats(campaignId: string): Promise<{ propositions: number; participants: number }> {
const [propositionsResult, participantsResult] = await Promise.all([
supabase
.from('propositions')
.select('id', { count: 'exact', head: true })
.eq('campaign_id', campaignId),
supabase
.from('participants')
.select('id', { count: 'exact', head: true })
.eq('campaign_id', campaignId)
]);
if (propositionsResult.error) throw propositionsResult.error;
if (participantsResult.error) throw participantsResult.error;
return {
propositions: propositionsResult.count || 0,
participants: participantsResult.count || 0
};
}
};
// Services pour les propositions
export const propositionService = {
async getByCampaign(campaignId: string): Promise<Proposition[]> {
const { data, error } = await supabase
.from('propositions')
.select('*')
.eq('campaign_id', campaignId)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(proposition: any): Promise<Proposition> {
const { data, error } = await supabase
.from('propositions')
.insert(proposition)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(id: string, updates: any): Promise<Proposition> {
const { data, error } = await supabase
.from('propositions')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
async delete(id: string): Promise<void> {
const { error } = await supabase
.from('propositions')
.delete()
.eq('id', id);
if (error) throw error;
}
};
// Services pour les participants
export const participantService = {
async getByCampaign(campaignId: string): Promise<Participant[]> {
const { data, error } = await supabase
.from('participants')
.select('*')
.eq('campaign_id', campaignId)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(participant: any): Promise<Participant> {
const { data, error } = await supabase
.from('participants')
.insert(participant)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(id: string, updates: any): Promise<Participant> {
const { data, error } = await supabase
.from('participants')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
async delete(id: string): Promise<void> {
const { error } = await supabase
.from('participants')
.delete()
.eq('id', id);
if (error) throw error;
}
};

6
src/lib/supabase.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder-key';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

58
src/types/index.ts Normal file
View File

@@ -0,0 +1,58 @@
export type CampaignStatus = 'deposit' | 'voting' | 'closed';
export interface Campaign {
id: string;
title: string;
description: string;
status: CampaignStatus;
budget_per_user: number;
spending_tiers: string; // Montants séparés par des virgules
created_at: string;
updated_at: string;
}
export interface CampaignWithStats extends Campaign {
stats: {
propositions: number;
participants: number;
};
}
export interface Proposition {
id: string;
campaign_id: string;
title: string;
description: string;
created_at: string;
}
export interface Participant {
id: string;
campaign_id: string;
first_name: string;
last_name: string;
email: string;
created_at: string;
}
export interface Database {
public: {
Tables: {
campaigns: {
Row: Campaign;
Insert: Omit<Campaign, 'id' | 'created_at' | 'updated_at'>;
Update: Partial<Omit<Campaign, 'id' | 'created_at' | 'updated_at'>>;
};
propositions: {
Row: Proposition;
Insert: Omit<Proposition, 'id' | 'created_at'>;
Update: Partial<Omit<Proposition, 'id' | 'created_at'>>;
};
participants: {
Row: Participant;
Insert: Omit<Participant, 'id' | 'created_at'>;
Update: Partial<Omit<Participant, 'id' | 'created_at'>>;
};
};
};
}

75
supabase-schema.sql Normal file
View File

@@ -0,0 +1,75 @@
-- Création des tables pour l'application "Mes Budgets Participatifs"
-- Table des campagnes
CREATE TABLE campaigns (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('deposit', 'voting', 'closed')) DEFAULT 'deposit',
budget_per_user INTEGER NOT NULL CHECK (budget_per_user > 0),
spending_tiers TEXT NOT NULL, -- Montants séparés par des virgules (ex: "10,25,50,100")
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Table des propositions
CREATE TABLE propositions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Table des participants
CREATE TABLE participants (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 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);
-- Trigger pour mettre à jour updated_at automatiquement
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_campaigns_updated_at
BEFORE UPDATE ON campaigns
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;
-- 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);
CREATE POLICY "Allow public insert access to campaigns" ON campaigns FOR INSERT WITH CHECK (true);
CREATE POLICY "Allow public update access to campaigns" ON campaigns FOR UPDATE USING (true);
CREATE POLICY "Allow public delete access to campaigns" ON campaigns FOR DELETE USING (true);
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 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);
-- Données d'exemple (optionnel)
INSERT INTO campaigns (title, description, status, budget_per_user, spending_tiers) VALUES
('Amélioration du quartier', 'Propositions pour améliorer notre quartier avec un budget participatif', 'deposit', 100, '10,25,50,100'),
('Équipements sportifs', 'Sélection d équipements sportifs pour la commune', 'voting', 50, '5,10,25,50'),
('Culture et loisirs', 'Projets culturels et de loisirs pour tous', 'closed', 75, '15,30,45,75');