initial commit
This commit is contained in:
100
DEBUG.md
Normal file
100
DEBUG.md
Normal 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
187
README.md
@@ -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
165
SETUP.md
Normal 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
6
env.example
Normal 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
14
next.config.js
Normal 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
367
package-lock.json
generated
@@ -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",
|
||||
|
||||
15
package.json
15
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
252
src/app/admin/campaigns/[id]/participants/page.tsx
Normal file
252
src/app/admin/campaigns/[id]/participants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
src/app/admin/campaigns/[id]/propositions/page.tsx
Normal file
241
src/app/admin/campaigns/[id]/propositions/page.tsx
Normal 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
301
src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
155
src/app/page.tsx
155
src/app/page.tsx
@@ -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="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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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 été sélectionnées</p>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
166
src/components/AddParticipantModal.tsx
Normal file
166
src/components/AddParticipantModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/components/AddPropositionModal.tsx
Normal file
147
src/components/AddPropositionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
src/components/CreateCampaignModal.tsx
Normal file
189
src/components/CreateCampaignModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/DeleteCampaignModal.tsx
Normal file
118
src/components/DeleteCampaignModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/components/DeleteParticipantModal.tsx
Normal file
113
src/components/DeleteParticipantModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/DeletePropositionModal.tsx
Normal file
110
src/components/DeletePropositionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/components/EditCampaignModal.tsx
Normal file
200
src/components/EditCampaignModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
src/components/EditParticipantModal.tsx
Normal file
159
src/components/EditParticipantModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/EditPropositionModal.tsx
Normal file
141
src/components/EditPropositionModal.tsx
Normal 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
175
src/lib/services.ts
Normal 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
6
src/lib/supabase.ts
Normal 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
58
src/types/index.ts
Normal 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
75
supabase-schema.sql
Normal 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');
|
||||
Reference in New Issue
Block a user