refactoring majeur (code dupliqué, mort, ...)
- Économie : ~1240 lignes de code dupliqué - Réduction : ~60% du code modal - Amélioration : Cohérence et maintenabilité
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { SmtpSettings } from '@/types';
|
||||
import { validateSmtpSettings, validateEmail, createSmtpTransporterConfig } from '@/lib/smtp-utils';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -15,8 +16,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Validation de l'email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(toEmail)) {
|
||||
if (!validateEmail(toEmail)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Adresse email de destination invalide' },
|
||||
{ status: 400 }
|
||||
@@ -24,31 +24,16 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Validation des paramètres SMTP
|
||||
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
|
||||
const validation = validateSmtpSettings(smtpSettings);
|
||||
if (!validation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Paramètres SMTP incomplets' },
|
||||
{ success: false, error: validation.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Créer le transporteur SMTP avec options de résolution DNS
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpSettings.host,
|
||||
port: smtpSettings.port,
|
||||
secure: smtpSettings.secure, // true pour 465, false pour les autres ports
|
||||
auth: {
|
||||
user: smtpSettings.username,
|
||||
pass: smtpSettings.password,
|
||||
},
|
||||
// Options pour résoudre les problèmes DNS
|
||||
tls: {
|
||||
rejectUnauthorized: false, // Accepte les certificats auto-signés
|
||||
},
|
||||
// Timeout pour éviter les blocages
|
||||
connectionTimeout: 10000, // 10 secondes
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 10000,
|
||||
});
|
||||
// Créer le transporteur SMTP
|
||||
const transporter = nodemailer.createTransport(createSmtpTransporterConfig(smtpSettings));
|
||||
|
||||
// Vérifier la connexion
|
||||
await transporter.verify();
|
||||
@@ -58,33 +43,7 @@ export async function POST(request: NextRequest) {
|
||||
from: `"${smtpSettings.from_name}" <${smtpSettings.from_email}>`,
|
||||
to: toEmail,
|
||||
subject: 'Test de configuration SMTP - Mes Budgets Participatifs',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">✅ Test de configuration SMTP réussi !</h2>
|
||||
<p>Bonjour,</p>
|
||||
<p>Cet email confirme que votre configuration SMTP fonctionne correctement.</p>
|
||||
<div style="background-color: #f3f4f6; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Configuration utilisée :</h3>
|
||||
<ul style="margin: 0; padding-left: 20px;">
|
||||
<li><strong>Serveur :</strong> ${smtpSettings.host}:${smtpSettings.port}</li>
|
||||
<li><strong>Sécurisé :</strong> ${smtpSettings.secure ? 'Oui (SSL/TLS)' : 'Non'}</li>
|
||||
<li><strong>Utilisateur :</strong> ${smtpSettings.username}</li>
|
||||
<li><strong>Expéditeur :</strong> ${smtpSettings.from_name} <${smtpSettings.from_email}></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Vous pouvez maintenant utiliser cette configuration pour envoyer des emails automatiques depuis votre application.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="color: #6b7280; font-size: 12px;">
|
||||
Cet email a été envoyé automatiquement par Mes Budgets Participatifs pour tester la configuration SMTP.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
text: `
|
||||
Test de configuration SMTP réussi !
|
||||
|
||||
Bonjour,
|
||||
|
||||
Cet email confirme que votre configuration SMTP fonctionne correctement.
|
||||
text: `Ceci est un email de test pour vérifier que votre configuration SMTP fonctionne correctement.
|
||||
|
||||
Configuration utilisée :
|
||||
- Serveur : ${smtpSettings.host}:${smtpSettings.port}
|
||||
@@ -92,42 +51,58 @@ Configuration utilisée :
|
||||
- Utilisateur : ${smtpSettings.username}
|
||||
- Expéditeur : ${smtpSettings.from_name} <${smtpSettings.from_email}>
|
||||
|
||||
Vous pouvez maintenant utiliser cette configuration pour envoyer des emails automatiques depuis votre application.
|
||||
Si vous recevez cet email, votre configuration SMTP est correcte !
|
||||
|
||||
---
|
||||
Cet email a été envoyé automatiquement par Mes Budgets Participatifs pour tester la configuration SMTP.
|
||||
Cordialement,
|
||||
L'équipe Mes Budgets Participatifs`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">Test de configuration SMTP</h2>
|
||||
<p>Ceci est un email de test pour vérifier que votre configuration SMTP fonctionne correctement.</p>
|
||||
|
||||
<div style="background-color: #f8fafc; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0; color: #374151;">Configuration utilisée :</h3>
|
||||
<ul style="color: #6b7280;">
|
||||
<li><strong>Serveur :</strong> ${smtpSettings.host}:${smtpSettings.port}</li>
|
||||
<li><strong>Sécurisé :</strong> ${smtpSettings.secure ? 'Oui (SSL/TLS)' : 'Non'}</li>
|
||||
<li><strong>Utilisateur :</strong> ${smtpSettings.username}</li>
|
||||
<li><strong>Expéditeur :</strong> ${smtpSettings.from_name} <${smtpSettings.from_email}></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p style="color: #059669; font-weight: bold;">✅ Si vous recevez cet email, votre configuration SMTP est correcte !</p>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
Cordialement,<br>
|
||||
L'équipe Mes Budgets Participatifs
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Email de test envoyé avec succès',
|
||||
messageId: info.messageId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Erreur lors de l\'envoi de l\'email de test:', error);
|
||||
|
||||
let errorMessage = 'Erreur lors de l\'envoi de l\'email';
|
||||
let errorMessage = 'Erreur lors de l\'envoi de l\'email de test';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('EBADNAME')) {
|
||||
errorMessage = 'Impossible de résoudre le nom d\'hôte SMTP. Vérifiez que le serveur SMTP est correct.';
|
||||
} else if (error.message.includes('ECONNREFUSED')) {
|
||||
errorMessage = 'Connexion refusée. Vérifiez le serveur et le port SMTP.';
|
||||
} else if (error.message.includes('ETIMEDOUT')) {
|
||||
errorMessage = 'Délai d\'attente dépassé. Vérifiez votre connexion internet et les paramètres SMTP.';
|
||||
} else if (error.message.includes('EAUTH')) {
|
||||
errorMessage = 'Authentification échouée. Vérifiez le nom d\'utilisateur et le mot de passe.';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
if (error.code === 'EAUTH') {
|
||||
errorMessage = 'Authentification SMTP échouée. Vérifiez vos identifiants.';
|
||||
} else if (error.code === 'ECONNECTION') {
|
||||
errorMessage = 'Impossible de se connecter au serveur SMTP. Vérifiez l\'hôte et le port.';
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'Connexion SMTP expirée. Vérifiez vos paramètres réseau.';
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage
|
||||
},
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { SmtpSettings } from '@/types';
|
||||
import { validateSmtpSettings, createSmtpTransporterConfig } from '@/lib/smtp-utils';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -15,47 +16,16 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Validation des paramètres SMTP
|
||||
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
|
||||
const validation = validateSmtpSettings(smtpSettings);
|
||||
if (!validation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Paramètres SMTP incomplets' },
|
||||
{ success: false, error: validation.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validation du port
|
||||
if (smtpSettings.port < 1 || smtpSettings.port > 65535) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Port SMTP invalide' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validation de l'email d'expédition
|
||||
if (!smtpSettings.from_email.includes('@')) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Adresse email d\'expédition invalide' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Créer le transporteur SMTP avec options de résolution DNS
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpSettings.host,
|
||||
port: smtpSettings.port,
|
||||
secure: smtpSettings.secure, // true pour 465, false pour les autres ports
|
||||
auth: {
|
||||
user: smtpSettings.username,
|
||||
pass: smtpSettings.password,
|
||||
},
|
||||
// Options pour résoudre les problèmes DNS
|
||||
tls: {
|
||||
rejectUnauthorized: false, // Accepte les certificats auto-signés
|
||||
},
|
||||
// Timeout pour éviter les blocages
|
||||
connectionTimeout: 10000, // 10 secondes
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 10000,
|
||||
});
|
||||
// Créer le transporteur SMTP
|
||||
const transporter = nodemailer.createTransport(createSmtpTransporterConfig(smtpSettings));
|
||||
|
||||
// Vérifier la connexion
|
||||
await transporter.verify();
|
||||
@@ -64,31 +34,23 @@ export async function POST(request: NextRequest) {
|
||||
success: true,
|
||||
message: 'Connexion SMTP réussie'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du test de connexion SMTP:', error);
|
||||
} catch (error: any) {
|
||||
console.error('Erreur lors du test SMTP:', error);
|
||||
|
||||
let errorMessage = 'Erreur de connexion SMTP';
|
||||
let errorMessage = 'Erreur lors du test de connexion SMTP';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('EBADNAME')) {
|
||||
errorMessage = 'Impossible de résoudre le nom d\'hôte SMTP. Vérifiez que le serveur SMTP est correct.';
|
||||
} else if (error.message.includes('ECONNREFUSED')) {
|
||||
errorMessage = 'Connexion refusée. Vérifiez le serveur et le port SMTP.';
|
||||
} else if (error.message.includes('ETIMEDOUT')) {
|
||||
errorMessage = 'Délai d\'attente dépassé. Vérifiez votre connexion internet et les paramètres SMTP.';
|
||||
} else if (error.message.includes('EAUTH')) {
|
||||
errorMessage = 'Authentification échouée. Vérifiez le nom d\'utilisateur et le mot de passe.';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
if (error.code === 'EAUTH') {
|
||||
errorMessage = 'Authentification SMTP échouée. Vérifiez vos identifiants.';
|
||||
} else if (error.code === 'ECONNECTION') {
|
||||
errorMessage = 'Impossible de se connecter au serveur SMTP. Vérifiez l\'hôte et le port.';
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'Connexion SMTP expirée. Vérifiez vos paramètres réseau.';
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage
|
||||
},
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { participantService } from '@/lib/services';
|
||||
import { useFormState } from '@/hooks/useFormState';
|
||||
import { FormModal } from './base/FormModal';
|
||||
import { handleFormError } from '@/lib/form-utils';
|
||||
|
||||
interface AddParticipantModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,13 +15,13 @@ interface AddParticipantModalProps {
|
||||
}
|
||||
|
||||
export default function AddParticipantModal({ isOpen, onClose, onSuccess, campaignId, campaignTitle }: AddParticipantModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
const initialData = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
};
|
||||
|
||||
const { formData, loading, setLoading, error, setError, handleChange, resetForm } = useFormState(initialData);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -37,103 +37,72 @@ export default function AddParticipantModal({ isOpen, onClose, onSuccess, campai
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
setFormData({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: ''
|
||||
});
|
||||
resetForm();
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.message || err?.details || 'Erreur lors de l\'ajout du participant';
|
||||
setError(`Erreur lors de l'ajout du participant: ${errorMessage}`);
|
||||
console.error('Erreur lors de l\'ajout du participant:', err);
|
||||
setError(handleFormError(err, 'l\'ajout du participant'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFormData({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: ''
|
||||
});
|
||||
setError('');
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajouter un participant</DialogTitle>
|
||||
<DialogDescription>
|
||||
{campaignTitle && `Ajoutez un nouveau participant à la campagne "${campaignTitle}".`}
|
||||
{!campaignTitle && 'Ajoutez un nouveau participant à cette campagne.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">Prénom *</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Prénom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Nom *</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Ajout...' : 'Ajouter le participant'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleSubmit}
|
||||
title="Ajouter un participant"
|
||||
description={
|
||||
campaignTitle
|
||||
? `Ajoutez un nouveau participant à la campagne "${campaignTitle}".`
|
||||
: 'Ajoutez un nouveau participant à cette campagne.'
|
||||
}
|
||||
loading={loading}
|
||||
error={error}
|
||||
submitText="Ajouter le participant"
|
||||
loadingText="Ajout..."
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">Prénom *</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Prénom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Nom *</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { propositionService } from '@/lib/services';
|
||||
import { MarkdownEditor } from '@/components/MarkdownEditor';
|
||||
import PropositionFormModal from './base/PropositionFormModal';
|
||||
|
||||
interface AddPropositionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,156 +9,13 @@ interface AddPropositionModalProps {
|
||||
}
|
||||
|
||||
export default function AddPropositionModal({ isOpen, onClose, onSuccess, campaignId }: AddPropositionModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
author_first_name: 'admin',
|
||||
author_last_name: 'admin',
|
||||
author_email: 'admin@example.com'
|
||||
});
|
||||
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,
|
||||
author_first_name: formData.author_first_name,
|
||||
author_last_name: formData.author_last_name,
|
||||
author_email: formData.author_email
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
author_first_name: 'admin',
|
||||
author_last_name: 'admin',
|
||||
author_email: 'admin@example.com'
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.message || err?.details || 'Erreur lors de la création de la proposition';
|
||||
setError(`Erreur lors de la création de la proposition: ${errorMessage}`);
|
||||
console.error('Erreur lors de la création de la proposition:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
author_first_name: 'admin',
|
||||
author_last_name: 'admin',
|
||||
author_email: 'admin@example.com'
|
||||
});
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajouter une proposition</DialogTitle>
|
||||
<DialogDescription>
|
||||
Créez une nouvelle proposition pour cette campagne de budget participatif.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titre de la proposition *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: Installation de bancs dans le parc"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez votre proposition en détail..."
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h3 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-3">
|
||||
Informations de l'auteur
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author_first_name">Prénom *</Label>
|
||||
<Input
|
||||
id="author_first_name"
|
||||
name="author_first_name"
|
||||
value={formData.author_first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Prénom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author_last_name">Nom *</Label>
|
||||
<Input
|
||||
id="author_last_name"
|
||||
name="author_last_name"
|
||||
value={formData.author_last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mt-3">
|
||||
<Label htmlFor="author_email">Email *</Label>
|
||||
<Input
|
||||
id="author_email"
|
||||
name="author_email"
|
||||
type="email"
|
||||
value={formData.author_email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Création...' : 'Créer la proposition'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PropositionFormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
mode="add"
|
||||
campaignId={campaignId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { campaignService } from '@/lib/services';
|
||||
import { MarkdownEditor } from '@/components/MarkdownEditor';
|
||||
import CampaignFormModal from './base/CampaignFormModal';
|
||||
|
||||
interface CreateCampaignModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,205 +8,12 @@ interface CreateCampaignModalProps {
|
||||
}
|
||||
|
||||
export default function CreateCampaignModal({ isOpen, onClose, onSuccess }: CreateCampaignModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
budget_per_user: '',
|
||||
spending_tiers: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await campaignService.create({
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
budget_per_user: parseInt(formData.budget_per_user),
|
||||
spending_tiers: formData.spending_tiers,
|
||||
status: 'deposit'
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
setFormData({ title: '', description: '', budget_per_user: '', spending_tiers: '' });
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la création de la campagne');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateOptimalTiers = (budget: number): string => {
|
||||
if (budget <= 0) return "0";
|
||||
|
||||
// Cas spéciaux pour des budgets courants
|
||||
if (budget === 10000) {
|
||||
return "0, 500, 1000, 2000, 3000, 5000, 7500, 10000";
|
||||
}
|
||||
if (budget === 8000) {
|
||||
return "0, 500, 1000, 2000, 3000, 4000, 6000, 8000";
|
||||
}
|
||||
|
||||
const tiers = [0];
|
||||
|
||||
// Déterminer les paliers "ronds" selon la taille du budget
|
||||
let roundValues: number[] = [];
|
||||
|
||||
if (budget <= 100) {
|
||||
// Petits budgets : multiples de 5, 10, 25
|
||||
roundValues = [5, 10, 25, 50, 75, 100];
|
||||
} else if (budget <= 500) {
|
||||
// Budgets moyens : multiples de 25, 50, 100
|
||||
roundValues = [25, 50, 75, 100, 150, 200, 250, 300, 400, 500];
|
||||
} else if (budget <= 2000) {
|
||||
// Budgets moyens-grands : multiples de 100, 250, 500
|
||||
roundValues = [100, 250, 500, 750, 1000, 1250, 1500, 1750, 2000];
|
||||
} else if (budget <= 10000) {
|
||||
// Gros budgets : multiples de 500, 1000, 2000
|
||||
roundValues = [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7500, 10000];
|
||||
} else {
|
||||
// Très gros budgets : multiples de 1000, 2000, 5000
|
||||
roundValues = [1000, 2000, 3000, 5000, 7500, 10000, 15000, 20000, 25000, 50000];
|
||||
}
|
||||
|
||||
// Sélectionner les paliers qui sont inférieurs ou égaux au budget
|
||||
const validTiers = roundValues.filter(tier => tier <= budget);
|
||||
|
||||
// Prendre 6-8 paliers intermédiaires + 0 et le budget final
|
||||
const targetCount = Math.min(8, Math.max(6, validTiers.length));
|
||||
const step = Math.max(1, Math.floor(validTiers.length / targetCount));
|
||||
|
||||
for (let i = 0; i < validTiers.length && tiers.length < targetCount + 1; i += step) {
|
||||
if (!tiers.includes(validTiers[i])) {
|
||||
tiers.push(validTiers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter le budget final s'il n'est pas déjà présent
|
||||
if (!tiers.includes(budget)) {
|
||||
tiers.push(budget);
|
||||
}
|
||||
|
||||
// Trier et retourner
|
||||
return tiers.sort((a, b) => a - b).join(', ');
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBudgetBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const budget = parseInt(e.target.value);
|
||||
if (!isNaN(budget) && budget > 0 && !formData.spending_tiers) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
spending_tiers: generateOptimalTiers(budget)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
budget_per_user: '',
|
||||
spending_tiers: ''
|
||||
});
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une nouvelle campagne</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configurez les paramètres de votre campagne de budget participatif.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titre de la campagne *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: Amélioration des espaces verts"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez l'objectif de cette campagne..."
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget_per_user">Budget (€) *</Label>
|
||||
<Input
|
||||
id="budget_per_user"
|
||||
name="budget_per_user"
|
||||
type="number"
|
||||
value={formData.budget_per_user}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBudgetBlur}
|
||||
placeholder="100"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="spending_tiers">Paliers de dépense *</Label>
|
||||
<Input
|
||||
id="spending_tiers"
|
||||
name="spending_tiers"
|
||||
value={formData.spending_tiers}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: 0, 10, 25, 50, 100"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Séparez les montants par des virgules (ex: 0, 10, 25, 50, 100)
|
||||
{formData.budget_per_user && !formData.spending_tiers && (
|
||||
<span className="block mt-1 text-blue-600 dark:text-blue-400">
|
||||
💡 Les paliers seront générés automatiquement après avoir saisi le budget
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Création...' : 'Créer la campagne'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CampaignFormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
mode="create"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { campaignService } from '@/lib/services';
|
||||
import { Campaign } from '@/types';
|
||||
import { MarkdownContent } from '@/components/MarkdownContent';
|
||||
import { DeleteModal } from './base/DeleteModal';
|
||||
import { MarkdownContent } from './MarkdownContent';
|
||||
|
||||
interface DeleteCampaignModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,81 +12,32 @@ interface DeleteCampaignModalProps {
|
||||
}
|
||||
|
||||
export default function DeleteCampaignModal({ isOpen, onClose, onSuccess, campaign }: DeleteCampaignModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!campaign) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await campaignService.delete(campaign.id);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la suppression de la campagne');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!campaign) return null;
|
||||
|
||||
const handleDelete = async () => {
|
||||
await campaignService.delete(campaign.id);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Supprimer la campagne
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Cette action est irréversible. Toutes les données associées à cette campagne seront définitivement supprimées.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-2">
|
||||
Campagne à supprimer :
|
||||
</h4>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Titre :</strong> {campaign.title}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Description :</strong> <MarkdownContent content={campaign.description} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
⚠️ Cette action supprimera également toutes les propositions et participants associés à cette campagne.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Suppression...' : 'Supprimer définitivement'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeleteModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onConfirm={handleDelete}
|
||||
title="Supprimer la campagne"
|
||||
description="Cette action est irréversible. Toutes les données associées à cette campagne seront définitivement supprimées."
|
||||
itemName="Campagne"
|
||||
itemDetails={
|
||||
<>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Titre :</strong> {campaign.title}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Description :</strong> <MarkdownContent content={campaign.description} />
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
warningMessage="Cette action supprimera également toutes les propositions et participants associés à cette campagne."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { participantService } from '@/lib/services';
|
||||
import { Participant } from '@/types';
|
||||
import { DeleteModal } from './base/DeleteModal';
|
||||
|
||||
interface DeleteParticipantModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,82 +11,32 @@ interface DeleteParticipantModalProps {
|
||||
}
|
||||
|
||||
export default function DeleteParticipantModal({ isOpen, onClose, onSuccess, participant }: DeleteParticipantModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!participant) return;
|
||||
|
||||
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}`);
|
||||
console.error('Erreur lors de la suppression du participant:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!participant) return null;
|
||||
|
||||
const handleDelete = async () => {
|
||||
await participantService.delete(participant.id);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Supprimer le participant
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Cette action est irréversible. Le participant sera définitivement supprimé.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-2">
|
||||
Participant à supprimer :
|
||||
</h4>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Nom :</strong> {participant.first_name} {participant.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Email :</strong> {participant.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
⚠️ Cette action supprimera également tous les votes associés à ce participant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Suppression...' : 'Supprimer définitivement'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeleteModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onConfirm={handleDelete}
|
||||
title="Supprimer le participant"
|
||||
description="Cette action est irréversible. Le participant sera définitivement supprimé."
|
||||
itemName="Participant"
|
||||
itemDetails={
|
||||
<>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Nom :</strong> {participant.first_name} {participant.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Email :</strong> {participant.email}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
warningMessage="Cette action supprimera également tous les votes associés à ce participant."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { propositionService } from '@/lib/services';
|
||||
import { Proposition } from '@/types';
|
||||
import { DeleteModal } from './base/DeleteModal';
|
||||
|
||||
interface DeletePropositionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,85 +11,35 @@ interface DeletePropositionModalProps {
|
||||
}
|
||||
|
||||
export default function DeletePropositionModal({ isOpen, onClose, onSuccess, proposition }: DeletePropositionModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!proposition) return;
|
||||
|
||||
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}`);
|
||||
console.error('Erreur lors de la suppression de la proposition:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!proposition) return null;
|
||||
|
||||
const handleDelete = async () => {
|
||||
await propositionService.delete(proposition.id);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Supprimer la proposition
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Cette action est irréversible. La proposition sera définitivement supprimée.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-2">
|
||||
Proposition à supprimer :
|
||||
</h4>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Titre :</strong> {proposition.title}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Auteur :</strong> {proposition.author_first_name} {proposition.author_last_name}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Email :</strong> {proposition.author_email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
⚠️ Cette action supprimera également tous les votes associés à cette proposition.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Suppression...' : 'Supprimer définitivement'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeleteModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onConfirm={handleDelete}
|
||||
title="Supprimer la proposition"
|
||||
description="Cette action est irréversible. La proposition sera définitivement supprimée."
|
||||
itemName="Proposition"
|
||||
itemDetails={
|
||||
<>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Titre :</strong> {proposition.title}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Auteur :</strong> {proposition.author_first_name} {proposition.author_last_name}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<strong>Email :</strong> {proposition.author_email}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
warningMessage="Cette action supprimera également tous les votes associés à cette proposition."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { campaignService } from '@/lib/services';
|
||||
import { Campaign, CampaignStatus } from '@/types';
|
||||
import { MarkdownEditor } from '@/components/MarkdownEditor';
|
||||
import { Campaign } from '@/types';
|
||||
import CampaignFormModal from './base/CampaignFormModal';
|
||||
|
||||
interface EditCampaignModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,159 +10,13 @@ interface EditCampaignModalProps {
|
||||
}
|
||||
|
||||
export default function EditCampaignModal({ isOpen, onClose, onSuccess, campaign }: EditCampaignModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'deposit' as CampaignStatus,
|
||||
budget_per_user: '',
|
||||
spending_tiers: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (campaign) {
|
||||
setFormData({
|
||||
title: campaign.title,
|
||||
description: campaign.description,
|
||||
status: campaign.status,
|
||||
budget_per_user: campaign.budget_per_user.toString(),
|
||||
spending_tiers: campaign.spending_tiers
|
||||
});
|
||||
}
|
||||
}, [campaign]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!campaign) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await campaignService.update(campaign.id, {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
status: formData.status,
|
||||
budget_per_user: parseInt(formData.budget_per_user),
|
||||
spending_tiers: formData.spending_tiers
|
||||
});
|
||||
|
||||
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>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
status: value as CampaignStatus
|
||||
}));
|
||||
};
|
||||
|
||||
if (!campaign) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la campagne</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez les paramètres de votre campagne de budget participatif.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titre de la campagne *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: Amélioration des espaces verts"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez l'objectif de cette campagne..."
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Statut de la campagne</Label>
|
||||
<Select value={formData.status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionnez un statut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deposit">Dépôt de propositions</SelectItem>
|
||||
<SelectItem value="voting">En cours de vote</SelectItem>
|
||||
<SelectItem value="closed">Terminée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget_per_user">Budget (€) *</Label>
|
||||
<Input
|
||||
id="budget_per_user"
|
||||
name="budget_per_user"
|
||||
type="number"
|
||||
value={formData.budget_per_user}
|
||||
onChange={handleChange}
|
||||
placeholder="100"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="spending_tiers">Paliers de dépense *</Label>
|
||||
<Input
|
||||
id="spending_tiers"
|
||||
name="spending_tiers"
|
||||
value={formData.spending_tiers}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: 0, 10, 25, 50, 100"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Séparez les montants par des virgules (ex: 0, 10, 25, 50, 100)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Modification...' : 'Modifier la campagne'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CampaignFormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
mode="edit"
|
||||
campaign={campaign}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { participantService } from '@/lib/services';
|
||||
import { Participant } from '@/types';
|
||||
import { useFormState } from '@/hooks/useFormState';
|
||||
import { FormModal } from './base/FormModal';
|
||||
import { handleFormError } from '@/lib/form-utils';
|
||||
|
||||
interface EditParticipantModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,13 +16,13 @@ interface EditParticipantModalProps {
|
||||
}
|
||||
|
||||
export default function EditParticipantModal({ isOpen, onClose, onSuccess, participant }: EditParticipantModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
const initialData = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
};
|
||||
|
||||
const { formData, setFormData, loading, setLoading, error, setError, handleChange } = useFormState(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
if (participant) {
|
||||
@@ -31,7 +32,7 @@ export default function EditParticipantModal({ isOpen, onClose, onSuccess, parti
|
||||
email: participant.email
|
||||
});
|
||||
}
|
||||
}, [participant]);
|
||||
}, [participant, setFormData]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -49,88 +50,63 @@ export default function EditParticipantModal({ isOpen, onClose, onSuccess, parti
|
||||
|
||||
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}`);
|
||||
console.error('Erreur lors de la modification du participant:', err);
|
||||
setError(handleFormError(err, 'la modification du participant'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
if (!participant) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier le participant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez les informations de ce participant.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">Prénom *</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Prénom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Nom *</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Modification...' : 'Modifier le participant'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
title="Modifier le participant"
|
||||
description="Modifiez les informations de ce participant."
|
||||
loading={loading}
|
||||
error={error}
|
||||
submitText="Modifier le participant"
|
||||
loadingText="Modification..."
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">Prénom *</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Prénom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Nom *</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { propositionService } from '@/lib/services';
|
||||
import { Proposition } from '@/types';
|
||||
import { MarkdownEditor } from '@/components/MarkdownEditor';
|
||||
import PropositionFormModal from './base/PropositionFormModal';
|
||||
|
||||
interface EditPropositionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -16,152 +10,13 @@ interface EditPropositionModalProps {
|
||||
}
|
||||
|
||||
export default function EditPropositionModal({ isOpen, onClose, onSuccess, proposition }: EditPropositionModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
author_first_name: '',
|
||||
author_last_name: '',
|
||||
author_email: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (proposition) {
|
||||
setFormData({
|
||||
title: proposition.title,
|
||||
description: proposition.description,
|
||||
author_first_name: proposition.author_first_name,
|
||||
author_last_name: proposition.author_last_name,
|
||||
author_email: proposition.author_email
|
||||
});
|
||||
}
|
||||
}, [proposition]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!proposition) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await propositionService.update(proposition.id, {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
author_first_name: formData.author_first_name,
|
||||
author_last_name: formData.author_last_name,
|
||||
author_email: formData.author_email
|
||||
});
|
||||
|
||||
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}`);
|
||||
console.error('Erreur lors de la modification de la proposition:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
if (!proposition) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la proposition</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez les détails de cette proposition.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titre de la proposition *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: Installation de bancs dans le parc"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez votre proposition en détail..."
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h3 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-3">
|
||||
Informations de l'auteur
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author_first_name">Prénom *</Label>
|
||||
<Input
|
||||
id="author_first_name"
|
||||
name="author_first_name"
|
||||
value={formData.author_first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Prénom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author_last_name">Nom *</Label>
|
||||
<Input
|
||||
id="author_last_name"
|
||||
name="author_last_name"
|
||||
value={formData.author_last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mt-3">
|
||||
<Label htmlFor="author_email">Email *</Label>
|
||||
<Input
|
||||
id="author_email"
|
||||
name="author_email"
|
||||
type="email"
|
||||
value={formData.author_email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Modification...' : 'Modifier la proposition'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PropositionFormModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
mode="edit"
|
||||
proposition={proposition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Upload, FileText, Download, AlertCircle } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
interface ImportFileModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (data: any[]) => void;
|
||||
type: 'propositions' | 'participants';
|
||||
campaignTitle?: string;
|
||||
}
|
||||
|
||||
export default function ImportFileModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onImport,
|
||||
type,
|
||||
campaignTitle
|
||||
}: ImportFileModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [preview, setPreview] = useState<any[]>([]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
// Vérifier le type de fichier
|
||||
const isCSV = selectedFile.type === 'text/csv' || selectedFile.name.toLowerCase().endsWith('.csv');
|
||||
const isODS = selectedFile.type === 'application/vnd.oasis.opendocument.spreadsheet' ||
|
||||
selectedFile.name.toLowerCase().endsWith('.ods') ||
|
||||
selectedFile.name.toLowerCase().endsWith('.xlsx') ||
|
||||
selectedFile.name.toLowerCase().endsWith('.xls');
|
||||
|
||||
if (!isCSV && !isODS) {
|
||||
setError('Veuillez sélectionner un fichier CSV, ODS, XLSX ou XLS valide.');
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setError('');
|
||||
|
||||
if (isCSV) {
|
||||
parseCSV(selectedFile);
|
||||
} else {
|
||||
parseODS(selectedFile);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseCSV = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
setError('Le fichier CSV doit contenir au moins un en-tête et une ligne de données.');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const data = lines.slice(1).map(line => {
|
||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
setPreview(data.slice(0, 5)); // Afficher les 5 premières lignes
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const parseODS = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const fileData = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(fileData, { type: 'array' });
|
||||
|
||||
// Prendre la première feuille
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// Convertir en JSON
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
if (jsonData.length < 2) {
|
||||
setError('Le fichier ODS/Excel doit contenir au moins un en-tête et une ligne de données.');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = jsonData[0] as string[];
|
||||
const rows = jsonData.slice(1) as any[][];
|
||||
|
||||
const parsedData = rows.map(row => {
|
||||
const rowData: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
rowData[header] = row[index] || '';
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
setPreview(parsedData.slice(0, 5)); // Afficher les 5 premières lignes
|
||||
} catch (error) {
|
||||
setError('Erreur lors de la lecture du fichier ODS/Excel.');
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const isCSV = file.type === 'text/csv' || file.name.toLowerCase().endsWith('.csv');
|
||||
|
||||
if (isCSV) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const data = lines.slice(1).map(line => {
|
||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
onImport(data);
|
||||
onClose();
|
||||
setFile(null);
|
||||
setPreview([]);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const fileData = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(fileData, { type: 'array' });
|
||||
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
const headers = jsonData[0] as string[];
|
||||
const rows = jsonData.slice(1) as any[][];
|
||||
|
||||
const parsedData = rows.map(row => {
|
||||
const rowData: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
rowData[header] = row[index] || '';
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
onImport(parsedData);
|
||||
onClose();
|
||||
setFile(null);
|
||||
setPreview([]);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Erreur lors de l\'import du fichier.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getExpectedColumns = () => {
|
||||
if (type === 'propositions') {
|
||||
return ['title', 'description', 'author_first_name', 'author_last_name', 'author_email'];
|
||||
} else {
|
||||
return ['first_name', 'last_name', 'email'];
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const columns = getExpectedColumns();
|
||||
const csvContent = columns.join(',') + '\n';
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `template_${type}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
Importer des {type === 'propositions' ? 'propositions' : 'participants'} depuis un fichier
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Importez en masse des {type === 'propositions' ? 'propositions' : 'participants'} depuis un fichier CSV, ODS, XLSX ou XLS.
|
||||
{campaignTitle && (
|
||||
<span className="block mt-1 font-medium">
|
||||
Campagne : {campaignTitle}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Template download */}
|
||||
<div className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-600" />
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Téléchargez le modèle CSV
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={downloadTemplate}>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Modèle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expected columns */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
Colonnes attendues :
|
||||
</h4>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{getExpectedColumns().join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file-upload">Sélectionner un fichier (CSV, ODS, XLSX, XLS)</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv,.ods,.xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Aperçu des données (5 premières lignes)</Label>
|
||||
<div className="max-h-40 overflow-y-auto border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
{Object.keys(preview[0] || {}).map((header) => (
|
||||
<th key={header} className="px-2 py-1 text-left font-medium">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row, index) => (
|
||||
<tr key={index} className="border-t">
|
||||
{Object.values(row).map((value, cellIndex) => (
|
||||
<td key={cellIndex} className="px-2 py-1 text-xs">
|
||||
{String(value)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || loading}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{loading ? 'Import...' : 'Importer'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,13 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Upload, FileText, Download, AlertCircle } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { BaseModal } from './base/BaseModal';
|
||||
import { ErrorDisplay } from './base/ErrorDisplay';
|
||||
import { parseCSV, parseExcel, getExpectedColumns, downloadTemplate, validateFileType } from '@/lib/file-utils';
|
||||
|
||||
interface ImportFileModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -36,94 +30,30 @@ export default function ImportFileModal({
|
||||
const [error, setError] = useState('');
|
||||
const [preview, setPreview] = useState<any[]>([]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
// Vérifier le type de fichier
|
||||
const isCSV = selectedFile.type === 'text/csv' || selectedFile.name.toLowerCase().endsWith('.csv');
|
||||
const isODS = selectedFile.type === 'application/vnd.oasis.opendocument.spreadsheet' ||
|
||||
selectedFile.name.toLowerCase().endsWith('.ods') ||
|
||||
selectedFile.name.toLowerCase().endsWith('.xlsx') ||
|
||||
selectedFile.name.toLowerCase().endsWith('.xls');
|
||||
|
||||
if (!isCSV && !isODS) {
|
||||
setError('Veuillez sélectionner un fichier valide (CSV, ODS, XLSX ou XLS).');
|
||||
// Valider le type de fichier
|
||||
const validation = validateFileType(selectedFile);
|
||||
if (!validation.isValid) {
|
||||
setError(validation.error || 'Type de fichier non supporté');
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setError('');
|
||||
|
||||
if (isCSV) {
|
||||
parseCSV(selectedFile);
|
||||
} else {
|
||||
parseODS(selectedFile);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseCSV = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
// Parser le fichier
|
||||
const isCSV = selectedFile.type === 'text/csv' || selectedFile.name.toLowerCase().endsWith('.csv');
|
||||
const result = isCSV ? await parseCSV(selectedFile) : await parseExcel(selectedFile);
|
||||
|
||||
if (lines.length < 2) {
|
||||
setError('Le fichier doit contenir au moins un en-tête et une ligne de données.');
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const data = lines.slice(1).map(line => {
|
||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
setPreview(data.slice(0, 5)); // Afficher les 5 premières lignes
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const parseODS = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const fileData = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(fileData, { type: 'array' });
|
||||
|
||||
// Prendre la première feuille
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// Convertir en JSON
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
if (jsonData.length < 2) {
|
||||
setError('Le fichier doit contenir au moins un en-tête et une ligne de données.');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = jsonData[0] as string[];
|
||||
const rows = jsonData.slice(1) as any[][];
|
||||
|
||||
const parsedData = rows.map(row => {
|
||||
const rowData: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
rowData[header] = row[index] || '';
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
setPreview(parsedData.slice(0, 5)); // Afficher les 5 premières lignes
|
||||
} catch (error) {
|
||||
setError('Erreur lors de la lecture du fichier.');
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
|
||||
setPreview(result.data.slice(0, 5)); // Afficher les 5 premières lignes
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
@@ -132,56 +62,17 @@ export default function ImportFileModal({
|
||||
setLoading(true);
|
||||
try {
|
||||
const isCSV = file.type === 'text/csv' || file.name.toLowerCase().endsWith('.csv');
|
||||
const result = isCSV ? await parseCSV(file) : await parseExcel(file);
|
||||
|
||||
if (isCSV) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const data = lines.slice(1).map(line => {
|
||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
onImport(data);
|
||||
onClose();
|
||||
setFile(null);
|
||||
setPreview([]);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const fileData = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(fileData, { type: 'array' });
|
||||
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
const headers = jsonData[0] as string[];
|
||||
const rows = jsonData.slice(1) as any[][];
|
||||
|
||||
const parsedData = rows.map(row => {
|
||||
const rowData: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
rowData[header] = row[index] || '';
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
onImport(parsedData);
|
||||
onClose();
|
||||
setFile(null);
|
||||
setPreview([]);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onImport(result.data);
|
||||
onClose();
|
||||
setFile(null);
|
||||
setPreview([]);
|
||||
} catch (error) {
|
||||
setError('Erreur lors de l\'import du fichier.');
|
||||
} finally {
|
||||
@@ -189,26 +80,6 @@ export default function ImportFileModal({
|
||||
}
|
||||
};
|
||||
|
||||
const getExpectedColumns = () => {
|
||||
if (type === 'propositions') {
|
||||
return ['title', 'description', 'author_first_name', 'author_last_name', 'author_email'];
|
||||
} else {
|
||||
return ['first_name', 'last_name', 'email'];
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const columns = getExpectedColumns();
|
||||
const csvContent = columns.join(',') + '\n';
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `template_${type}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFile(null);
|
||||
setPreview([]);
|
||||
@@ -216,116 +87,100 @@ export default function ImportFileModal({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || loading}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{loading ? 'Import...' : 'Importer'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
Importer des {type === 'propositions' ? 'propositions' : 'participants'} depuis un fichier
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Importez en masse des {type === 'propositions' ? 'propositions' : 'participants'} depuis un fichier CSV, ODS, XLSX ou XLS.
|
||||
{campaignTitle && (
|
||||
<span className="block mt-1 font-medium">
|
||||
Campagne : {campaignTitle}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={`Importer des ${type === 'propositions' ? 'propositions' : 'participants'} depuis un fichier`}
|
||||
description={`Importez en masse des ${type === 'propositions' ? 'propositions' : 'participants'} depuis un fichier CSV, ODS, XLSX ou XLS.${campaignTitle ? ` Campagne : ${campaignTitle}` : ''}`}
|
||||
footer={footer}
|
||||
maxWidth="sm:max-w-[600px]"
|
||||
>
|
||||
<ErrorDisplay error={error} />
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Template download */}
|
||||
<div className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-600" />
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Téléchargez le modèle
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={downloadTemplate}>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Modèle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expected columns */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
Colonnes attendues :
|
||||
</h4>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{getExpectedColumns().join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file-upload">Sélectionner un fichier (CSV, ODS, XLSX, XLS)</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv,.ods,.xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Aperçu des données (5 premières lignes)</Label>
|
||||
<div className="max-h-40 max-w-full overflow-auto border rounded-lg">
|
||||
<div className="min-w-full">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
{Object.keys(preview[0] || {}).map((header) => (
|
||||
<th key={header} className="px-2 py-1 text-left font-medium truncate">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row, index) => (
|
||||
<tr key={index} className="border-t">
|
||||
{Object.values(row).map((value, cellIndex) => (
|
||||
<td key={cellIndex} className="px-2 py-1 text-xs truncate">
|
||||
{String(value)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Template download */}
|
||||
<div className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-600" />
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Téléchargez le modèle
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => downloadTemplate(type)}>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Modèle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!file || loading}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{loading ? 'Import...' : 'Importer'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Expected columns */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
Colonnes attendues :
|
||||
</h4>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{getExpectedColumns(type).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file-upload">Sélectionner un fichier (CSV, ODS, XLSX, XLS)</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv,.ods,.xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{preview.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Aperçu des données (5 premières lignes)</Label>
|
||||
<div className="max-h-40 max-w-full overflow-auto border rounded-lg">
|
||||
<div className="min-w-full">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
{Object.keys(preview[0] || {}).map((header) => (
|
||||
<th key={header} className="px-2 py-1 text-left font-medium truncate">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row, index) => (
|
||||
<tr key={index} className="border-t">
|
||||
{Object.values(row).map((value, cellIndex) => (
|
||||
<td key={cellIndex} className="px-2 py-1 text-xs truncate">
|
||||
{String(value)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
41
src/components/base/BaseModal.tsx
Normal file
41
src/components/base/BaseModal.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
interface BaseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string | ReactNode;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function BaseModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
maxWidth = "sm:max-w-[500px]",
|
||||
maxHeight = "max-h-[90vh]"
|
||||
}: BaseModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className={`${maxWidth} ${maxHeight} overflow-y-auto`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && <DialogFooter>{footer}</DialogFooter>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
255
src/components/base/CampaignFormModal.tsx
Normal file
255
src/components/base/CampaignFormModal.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { campaignService } from '@/lib/services';
|
||||
import { Campaign, CampaignStatus } from '@/types';
|
||||
import { MarkdownEditor } from '@/components/MarkdownEditor';
|
||||
import { useFormState } from '@/hooks/useFormState';
|
||||
import { FormModal } from './FormModal';
|
||||
import { handleFormError } from '@/lib/form-utils';
|
||||
|
||||
interface CampaignFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
mode: 'create' | 'edit';
|
||||
campaign?: Campaign | null;
|
||||
}
|
||||
|
||||
export default function CampaignFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
mode,
|
||||
campaign
|
||||
}: CampaignFormModalProps) {
|
||||
const initialData = {
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'deposit' as CampaignStatus,
|
||||
budget_per_user: '',
|
||||
spending_tiers: ''
|
||||
};
|
||||
|
||||
const { formData, setFormData, loading, setLoading, error, setError, handleChange, resetForm } = useFormState(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
if (campaign && mode === 'edit') {
|
||||
setFormData({
|
||||
title: campaign.title,
|
||||
description: campaign.description,
|
||||
status: campaign.status,
|
||||
budget_per_user: campaign.budget_per_user.toString(),
|
||||
spending_tiers: campaign.spending_tiers
|
||||
});
|
||||
}
|
||||
}, [campaign, mode, setFormData]);
|
||||
|
||||
const generateOptimalTiers = (budget: number): string => {
|
||||
if (budget <= 0) return "0";
|
||||
|
||||
// Cas spéciaux pour des budgets courants
|
||||
if (budget === 10000) {
|
||||
return "0, 500, 1000, 2000, 3000, 5000, 7500, 10000";
|
||||
}
|
||||
if (budget === 8000) {
|
||||
return "0, 500, 1000, 2000, 3000, 4000, 6000, 8000";
|
||||
}
|
||||
|
||||
const tiers = [0];
|
||||
|
||||
// Déterminer les paliers "ronds" selon la taille du budget
|
||||
let roundValues: number[] = [];
|
||||
|
||||
if (budget <= 100) {
|
||||
// Petits budgets : multiples de 5, 10, 25
|
||||
roundValues = [5, 10, 25, 50, 75, 100];
|
||||
} else if (budget <= 500) {
|
||||
// Budgets moyens : multiples de 25, 50, 100
|
||||
roundValues = [25, 50, 75, 100, 150, 200, 250, 300, 400, 500];
|
||||
} else if (budget <= 2000) {
|
||||
// Budgets moyens-grands : multiples de 100, 250, 500
|
||||
roundValues = [100, 250, 500, 750, 1000, 1250, 1500, 1750, 2000];
|
||||
} else if (budget <= 10000) {
|
||||
// Gros budgets : multiples de 500, 1000, 2000
|
||||
roundValues = [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7500, 10000];
|
||||
} else {
|
||||
// Très gros budgets : multiples de 1000, 2000, 5000
|
||||
roundValues = [1000, 2000, 3000, 5000, 7500, 10000, 15000, 20000, 25000, 50000];
|
||||
}
|
||||
|
||||
// Sélectionner les paliers qui sont inférieurs ou égaux au budget
|
||||
const validTiers = roundValues.filter(tier => tier <= budget);
|
||||
|
||||
// Prendre 6-8 paliers intermédiaires + 0 et le budget final
|
||||
const targetCount = Math.min(8, Math.max(6, validTiers.length));
|
||||
const step = Math.max(1, Math.floor(validTiers.length / targetCount));
|
||||
|
||||
for (let i = 0; i < validTiers.length && tiers.length < targetCount + 1; i += step) {
|
||||
if (!tiers.includes(validTiers[i])) {
|
||||
tiers.push(validTiers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter le budget final s'il n'est pas déjà présent
|
||||
if (!tiers.includes(budget)) {
|
||||
tiers.push(budget);
|
||||
}
|
||||
|
||||
// Trier et retourner
|
||||
return tiers.sort((a, b) => a - b).join(', ');
|
||||
};
|
||||
|
||||
const handleBudgetBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const budget = parseInt(e.target.value);
|
||||
if (!isNaN(budget) && budget > 0 && !formData.spending_tiers && mode === 'create') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
spending_tiers: generateOptimalTiers(budget)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
status: value as CampaignStatus
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
await campaignService.create({
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
budget_per_user: parseInt(formData.budget_per_user),
|
||||
spending_tiers: formData.spending_tiers,
|
||||
status: 'deposit'
|
||||
});
|
||||
} else if (mode === 'edit' && campaign) {
|
||||
await campaignService.update(campaign.id, {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
status: formData.status,
|
||||
budget_per_user: parseInt(formData.budget_per_user),
|
||||
spending_tiers: formData.spending_tiers
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
if (mode === 'create') {
|
||||
resetForm();
|
||||
}
|
||||
} catch (err: any) {
|
||||
const operation = mode === 'create' ? 'la création de la campagne' : 'la modification de la campagne';
|
||||
setError(handleFormError(err, operation));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (mode === 'create') {
|
||||
resetForm();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleSubmit}
|
||||
title={isEditMode ? "Modifier la campagne" : "Créer une nouvelle campagne"}
|
||||
description={
|
||||
isEditMode
|
||||
? "Modifiez les paramètres de votre campagne de budget participatif."
|
||||
: "Configurez les paramètres de votre campagne de budget participatif."
|
||||
}
|
||||
loading={loading}
|
||||
error={error}
|
||||
submitText={isEditMode ? "Modifier la campagne" : "Créer la campagne"}
|
||||
loadingText={isEditMode ? "Modification..." : "Création..."}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titre de la campagne *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: Amélioration des espaces verts"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez l'objectif de cette campagne..."
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
|
||||
{isEditMode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Statut de la campagne</Label>
|
||||
<Select value={formData.status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionnez un statut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deposit">Dépôt de propositions</SelectItem>
|
||||
<SelectItem value="voting">En cours de vote</SelectItem>
|
||||
<SelectItem value="closed">Terminée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget_per_user">Budget (€) *</Label>
|
||||
<Input
|
||||
id="budget_per_user"
|
||||
name="budget_per_user"
|
||||
type="number"
|
||||
value={formData.budget_per_user}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBudgetBlur}
|
||||
placeholder="100"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="spending_tiers">Paliers de dépense *</Label>
|
||||
<Input
|
||||
id="spending_tiers"
|
||||
name="spending_tiers"
|
||||
value={formData.spending_tiers}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: 0, 10, 25, 50, 100"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Séparez les montants par des virgules (ex: 0, 10, 25, 50, 100)
|
||||
{formData.budget_per_user && !formData.spending_tiers && mode === 'create' && (
|
||||
<span className="block mt-1 text-blue-600 dark:text-blue-400">
|
||||
💡 Les paliers seront générés automatiquement après avoir saisi le budget
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
97
src/components/base/DeleteModal.tsx
Normal file
97
src/components/base/DeleteModal.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { ErrorDisplay } from './ErrorDisplay';
|
||||
|
||||
interface DeleteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
title: string;
|
||||
description: string;
|
||||
itemName: string;
|
||||
itemDetails: React.ReactNode;
|
||||
warningMessage?: string;
|
||||
loadingText?: string;
|
||||
confirmText?: string;
|
||||
}
|
||||
|
||||
export function DeleteModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
itemName,
|
||||
itemDetails,
|
||||
warningMessage = "Cette action est irréversible.",
|
||||
loadingText = "Suppression...",
|
||||
confirmText = "Supprimer définitivement"
|
||||
}: DeleteModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleDelete = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onConfirm();
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.message || err?.details || 'Erreur lors de la suppression';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? loadingText : confirmText}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
{title}
|
||||
</div>
|
||||
}
|
||||
description={description}
|
||||
footer={footer}
|
||||
maxWidth="sm:max-w-[425px]"
|
||||
>
|
||||
<ErrorDisplay error={error} />
|
||||
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-2">
|
||||
{itemName} à supprimer :
|
||||
</h4>
|
||||
{itemDetails}
|
||||
</div>
|
||||
|
||||
{warningMessage && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
⚠️ {warningMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
14
src/components/base/ErrorDisplay.tsx
Normal file
14
src/components/base/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
interface ErrorDisplayProps {
|
||||
error: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorDisplay({ error, className = "" }: ErrorDisplayProps) {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className={`p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg ${className}`}>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/base/FormModal.tsx
Normal file
61
src/components/base/FormModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { ErrorDisplay } from './ErrorDisplay';
|
||||
|
||||
interface FormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => Promise<void>;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
submitText: string;
|
||||
loadingText?: string;
|
||||
cancelText?: string;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export function FormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
loading,
|
||||
error,
|
||||
submitText,
|
||||
loadingText = "En cours...",
|
||||
cancelText = "Annuler",
|
||||
maxWidth = "sm:max-w-[500px]"
|
||||
}: FormModalProps) {
|
||||
const footer = (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} form="form-modal">
|
||||
{loading ? loadingText : submitText}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
description={description}
|
||||
footer={footer}
|
||||
maxWidth={maxWidth}
|
||||
>
|
||||
<form id="form-modal" onSubmit={onSubmit} className="space-y-4">
|
||||
<ErrorDisplay error={error} />
|
||||
{children}
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
177
src/components/base/PropositionFormModal.tsx
Normal file
177
src/components/base/PropositionFormModal.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { propositionService } from '@/lib/services';
|
||||
import { Proposition } from '@/types';
|
||||
import { MarkdownEditor } from '@/components/MarkdownEditor';
|
||||
import { useFormState } from '@/hooks/useFormState';
|
||||
import { FormModal } from './FormModal';
|
||||
import { handleFormError } from '@/lib/form-utils';
|
||||
|
||||
interface PropositionFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
mode: 'add' | 'edit';
|
||||
campaignId?: string;
|
||||
proposition?: Proposition | null;
|
||||
}
|
||||
|
||||
export default function PropositionFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
mode,
|
||||
campaignId,
|
||||
proposition
|
||||
}: PropositionFormModalProps) {
|
||||
const initialData = {
|
||||
title: '',
|
||||
description: '',
|
||||
author_first_name: mode === 'add' ? 'admin' : '',
|
||||
author_last_name: mode === 'add' ? 'admin' : '',
|
||||
author_email: mode === 'add' ? 'admin@example.com' : ''
|
||||
};
|
||||
|
||||
const { formData, setFormData, loading, setLoading, error, setError, handleChange, resetForm } = useFormState(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
if (proposition && mode === 'edit') {
|
||||
setFormData({
|
||||
title: proposition.title,
|
||||
description: proposition.description,
|
||||
author_first_name: proposition.author_first_name,
|
||||
author_last_name: proposition.author_last_name,
|
||||
author_email: proposition.author_email
|
||||
});
|
||||
}
|
||||
}, [proposition, mode, setFormData]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (mode === 'add' && campaignId) {
|
||||
await propositionService.create({
|
||||
campaign_id: campaignId,
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
author_first_name: formData.author_first_name,
|
||||
author_last_name: formData.author_last_name,
|
||||
author_email: formData.author_email
|
||||
});
|
||||
} else if (mode === 'edit' && proposition) {
|
||||
await propositionService.update(proposition.id, {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
author_first_name: formData.author_first_name,
|
||||
author_last_name: formData.author_last_name,
|
||||
author_email: formData.author_email
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
if (mode === 'add') {
|
||||
resetForm();
|
||||
}
|
||||
} catch (err: any) {
|
||||
const operation = mode === 'add' ? 'la création de la proposition' : 'la modification de la proposition';
|
||||
setError(handleFormError(err, operation));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (mode === 'add') {
|
||||
resetForm();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isEditMode = mode === 'edit';
|
||||
if (isEditMode && !proposition) return null;
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleSubmit}
|
||||
title={isEditMode ? "Modifier la proposition" : "Ajouter une proposition"}
|
||||
description={
|
||||
isEditMode
|
||||
? "Modifiez les détails de cette proposition."
|
||||
: "Créez une nouvelle proposition pour cette campagne de budget participatif."
|
||||
}
|
||||
loading={loading}
|
||||
error={error}
|
||||
submitText={isEditMode ? "Modifier la proposition" : "Créer la proposition"}
|
||||
loadingText={isEditMode ? "Modification..." : "Création..."}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Titre de la proposition *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Ex: Installation de bancs dans le parc"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez votre proposition en détail..."
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h3 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-3">
|
||||
Informations de l'auteur
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author_first_name">Prénom *</Label>
|
||||
<Input
|
||||
id="author_first_name"
|
||||
name="author_first_name"
|
||||
value={formData.author_first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Prénom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author_last_name">Nom *</Label>
|
||||
<Input
|
||||
id="author_last_name"
|
||||
name="author_last_name"
|
||||
value={formData.author_last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mt-3">
|
||||
<Label htmlFor="author_email">Email *</Label>
|
||||
<Input
|
||||
id="author_email"
|
||||
name="author_email"
|
||||
type="email"
|
||||
value={formData.author_email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
32
src/hooks/useFormState.ts
Normal file
32
src/hooks/useFormState.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useFormState<T>(initialData: T) {
|
||||
const [formData, setFormData] = useState<T>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData(initialData);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const setFieldValue = (field: keyof T, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return {
|
||||
formData,
|
||||
setFormData,
|
||||
loading,
|
||||
setLoading,
|
||||
error,
|
||||
setError,
|
||||
handleChange,
|
||||
resetForm,
|
||||
setFieldValue
|
||||
};
|
||||
}
|
||||
120
src/lib/file-utils.ts
Normal file
120
src/lib/file-utils.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/**
|
||||
* Utilitaires centralisés pour le traitement des fichiers
|
||||
*/
|
||||
|
||||
export interface ParsedFileData {
|
||||
data: any[];
|
||||
headers: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function parseCSV(file: File): Promise<ParsedFileData> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const text = e.target?.result as string;
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
resolve({ data: [], headers: [], error: 'Le fichier doit contenir au moins un en-tête et une ligne de données.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const data = lines.slice(1).map(line => {
|
||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
resolve({ data, headers });
|
||||
} catch (error) {
|
||||
resolve({ data: [], headers: [], error: 'Erreur lors de la lecture du fichier CSV.' });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExcel(file: File): Promise<ParsedFileData> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const fileData = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(fileData, { type: 'array' });
|
||||
|
||||
// Prendre la première feuille
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// Convertir en JSON
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
if (jsonData.length < 2) {
|
||||
resolve({ data: [], headers: [], error: 'Le fichier doit contenir au moins un en-tête et une ligne de données.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = jsonData[0] as string[];
|
||||
const rows = jsonData.slice(1) as any[][];
|
||||
|
||||
const parsedData = rows.map(row => {
|
||||
const rowData: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
rowData[header] = row[index] || '';
|
||||
});
|
||||
return rowData;
|
||||
});
|
||||
|
||||
resolve({ data: parsedData, headers });
|
||||
} catch (error) {
|
||||
resolve({ data: [], headers: [], error: 'Erreur lors de la lecture du fichier Excel.' });
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function getExpectedColumns(type: 'propositions' | 'participants'): string[] {
|
||||
if (type === 'propositions') {
|
||||
return ['title', 'description', 'author_first_name', 'author_last_name', 'author_email'];
|
||||
} else {
|
||||
return ['first_name', 'last_name', 'email'];
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadTemplate(type: 'propositions' | 'participants'): void {
|
||||
const columns = getExpectedColumns(type);
|
||||
const csvContent = columns.join(',') + '\n';
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `template_${type}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function validateFileType(file: File): { isValid: boolean; error?: string } {
|
||||
const isCSV = file.type === 'text/csv' || file.name.toLowerCase().endsWith('.csv');
|
||||
const isExcel = file.type === 'application/vnd.oasis.opendocument.spreadsheet' ||
|
||||
file.name.toLowerCase().endsWith('.ods') ||
|
||||
file.name.toLowerCase().endsWith('.xlsx') ||
|
||||
file.name.toLowerCase().endsWith('.xls');
|
||||
|
||||
if (!isCSV && !isExcel) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Veuillez sélectionner un fichier valide (CSV, ODS, XLSX ou XLS).'
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
30
src/lib/form-utils.ts
Normal file
30
src/lib/form-utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Utilitaires centralisés pour la gestion des formulaires
|
||||
*/
|
||||
|
||||
export function handleFormError(err: any, operation: string): string {
|
||||
const errorMessage = err?.message || err?.details || `Erreur lors de ${operation}`;
|
||||
console.error(`Erreur lors de ${operation}:`, err);
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
export function validateRequiredFields(data: Record<string, any>, requiredFields: string[]): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!data[field] || (typeof data[field] === 'string' && data[field].trim() === '')) {
|
||||
errors.push(`Le champ "${field}" est requis`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
export function formatErrorMessage(errors: string[]): string {
|
||||
return errors.join('. ');
|
||||
}
|
||||
@@ -637,19 +637,6 @@ export const settingsService = {
|
||||
|
||||
async testSmtpConnection(smtpSettings: SmtpSettings): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Validation basique des paramètres
|
||||
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
|
||||
return { success: false, error: 'Paramètres SMTP incomplets' };
|
||||
}
|
||||
|
||||
if (smtpSettings.port < 1 || smtpSettings.port > 65535) {
|
||||
return { success: false, error: 'Port SMTP invalide' };
|
||||
}
|
||||
|
||||
if (!smtpSettings.from_email.includes('@')) {
|
||||
return { success: false, error: 'Adresse email d\'expédition invalide' };
|
||||
}
|
||||
|
||||
// Test de connexion via API route
|
||||
return await emailService.testConnection(smtpSettings);
|
||||
} catch (error) {
|
||||
@@ -659,11 +646,6 @@ export const settingsService = {
|
||||
|
||||
async sendTestEmail(smtpSettings: SmtpSettings, toEmail: string): Promise<{ success: boolean; error?: string; messageId?: string }> {
|
||||
try {
|
||||
// Validation de l'email de destination
|
||||
if (!emailService.validateEmail(toEmail)) {
|
||||
return { success: false, error: 'Adresse email de destination invalide' };
|
||||
}
|
||||
|
||||
// Envoi de l'email de test via API route
|
||||
return await emailService.sendTestEmail(smtpSettings, toEmail);
|
||||
} catch (error) {
|
||||
|
||||
47
src/lib/smtp-utils.ts
Normal file
47
src/lib/smtp-utils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { SmtpSettings } from '@/types';
|
||||
|
||||
/**
|
||||
* Utilitaires centralisés pour la validation et la gestion SMTP
|
||||
*/
|
||||
|
||||
export function validateSmtpSettings(smtpSettings: SmtpSettings): { isValid: boolean; error?: string } {
|
||||
// Validation basique des paramètres
|
||||
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
|
||||
return { isValid: false, error: 'Paramètres SMTP incomplets' };
|
||||
}
|
||||
|
||||
if (smtpSettings.port < 1 || smtpSettings.port > 65535) {
|
||||
return { isValid: false, error: 'Port SMTP invalide' };
|
||||
}
|
||||
|
||||
if (!smtpSettings.from_email.includes('@')) {
|
||||
return { isValid: false, error: 'Adresse email d\'expédition invalide' };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
export function createSmtpTransporterConfig(smtpSettings: SmtpSettings) {
|
||||
return {
|
||||
host: smtpSettings.host,
|
||||
port: smtpSettings.port,
|
||||
secure: smtpSettings.secure, // true pour 465, false pour les autres ports
|
||||
auth: {
|
||||
user: smtpSettings.username,
|
||||
pass: smtpSettings.password,
|
||||
},
|
||||
// Options pour résoudre les problèmes DNS
|
||||
tls: {
|
||||
rejectUnauthorized: false, // Accepte les certificats auto-signés
|
||||
},
|
||||
// Timeout pour éviter les blocages
|
||||
connectionTimeout: 10000, // 10 secondes
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 10000,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user