Added few functions to the platform
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import AlertModal from './AlertModal';
|
||||||
|
|
||||||
interface Adherent {
|
interface Adherent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,6 +27,12 @@ interface AdherentFormProps {
|
|||||||
|
|
||||||
export default function AdherentForm({ adherent, onClose }: AdherentFormProps) {
|
export default function AdherentForm({ adherent, onClose }: AdherentFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [alertModal, setAlertModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
const [options, setOptions] = useState<{
|
const [options, setOptions] = useState<{
|
||||||
situation: Array<{ id: string; value: string }>;
|
situation: Array<{ id: string; value: string }>;
|
||||||
prescripteur: Array<{ id: string; value: string }>;
|
prescripteur: Array<{ id: string; value: string }>;
|
||||||
@@ -126,11 +133,21 @@ export default function AdherentForm({ adherent, onClose }: AdherentFormProps) {
|
|||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Une erreur est survenue');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: error.error || 'Une erreur est survenue',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
alert('Une erreur est survenue');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Une erreur est survenue',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -492,6 +509,17 @@ export default function AdherentForm({ adherent, onClose }: AdherentFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal d'alerte */}
|
||||||
|
{alertModal && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.show}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
onClose={() => setAlertModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import AdherentForm from './AdherentForm';
|
import AdherentForm from './AdherentForm';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
interface Adherent {
|
interface Adherent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +15,7 @@ interface Adherent {
|
|||||||
situation?: string | null;
|
situation?: string | null;
|
||||||
prescripteur?: string | null;
|
prescripteur?: string | null;
|
||||||
facturation?: string | null;
|
facturation?: string | null;
|
||||||
|
forfait?: string | null;
|
||||||
commentaire?: string | null;
|
commentaire?: string | null;
|
||||||
telephoneSecondaire?: string | null;
|
telephoneSecondaire?: string | null;
|
||||||
instructions?: string | null;
|
instructions?: string | null;
|
||||||
@@ -26,6 +28,19 @@ export default function AdherentsTable() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingAdherent, setEditingAdherent] = useState<Adherent | null>(null);
|
const [editingAdherent, setEditingAdherent] = useState<Adherent | null>(null);
|
||||||
const [viewingAdherent, setViewingAdherent] = useState<Adherent | null>(null);
|
const [viewingAdherent, setViewingAdherent] = useState<Adherent | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [resultModal, setResultModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
details?: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmDeleteModal, setConfirmDeleteModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
id: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const fetchAdherents = async (searchTerm: string = '') => {
|
const fetchAdherents = async (searchTerm: string = '') => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -58,23 +73,46 @@ export default function AdherentsTable() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet adhérent ?')) {
|
setConfirmDeleteModal({
|
||||||
return;
|
show: true,
|
||||||
}
|
id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!confirmDeleteModal?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/adherents/${id}`, {
|
const response = await fetch(`/api/adherents/${confirmDeleteModal.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
fetchAdherents(search);
|
fetchAdherents(search);
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'success',
|
||||||
|
title: 'Suppression réussie',
|
||||||
|
message: 'L\'adhérent a été supprimé avec succès',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
alert('Erreur lors de la suppression');
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de la suppression',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la suppression:', error);
|
console.error('Erreur lors de la suppression:', error);
|
||||||
alert('Erreur lors de la suppression');
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de la suppression',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setConfirmDeleteModal(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,6 +148,556 @@ export default function AdherentsTable() {
|
|||||||
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(new Set(adherents.map(a => a.id)));
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectOne = (id: string, checked: boolean) => {
|
||||||
|
const newSelected = new Set(selectedIds);
|
||||||
|
if (checked) {
|
||||||
|
newSelected.add(id);
|
||||||
|
} else {
|
||||||
|
newSelected.delete(id);
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'info',
|
||||||
|
title: 'Aucune sélection',
|
||||||
|
message: 'Veuillez sélectionner au moins un adhérent à exporter',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedAdherents = adherents.filter(a => selectedIds.has(a.id));
|
||||||
|
|
||||||
|
// Créer les en-têtes CSV avec tous les champs disponibles
|
||||||
|
const headers = [
|
||||||
|
'Nom',
|
||||||
|
'Prénom',
|
||||||
|
'Date de naissance',
|
||||||
|
'Adresse',
|
||||||
|
'Email',
|
||||||
|
'Téléphone',
|
||||||
|
'Téléphone secondaire',
|
||||||
|
'Situation',
|
||||||
|
'Prescripteur',
|
||||||
|
'Facturation',
|
||||||
|
'Forfait',
|
||||||
|
'Commentaire',
|
||||||
|
'Instructions'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Créer les lignes CSV
|
||||||
|
const rows = selectedAdherents.map(adherent => {
|
||||||
|
const formatDateForCSV = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
adherent.nom || '',
|
||||||
|
adherent.prenom || '',
|
||||||
|
formatDateForCSV(adherent.dateNaissance),
|
||||||
|
adherent.adresse || '',
|
||||||
|
adherent.email || '',
|
||||||
|
adherent.telephone || '',
|
||||||
|
adherent.telephoneSecondaire || '',
|
||||||
|
adherent.situation || '',
|
||||||
|
adherent.prescripteur || '',
|
||||||
|
adherent.facturation || '',
|
||||||
|
adherent.forfait || '',
|
||||||
|
(adherent.commentaire || '').replace(/"/g, '""'), // Échapper les guillemets
|
||||||
|
(adherent.instructions || '').replace(/"/g, '""') // Échapper les guillemets
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer le contenu CSV
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Créer le blob et télécharger
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }); // BOM pour Excel
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `adherents_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Réinitialiser la sélection
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTemplate = () => {
|
||||||
|
const headers = [
|
||||||
|
'Nom',
|
||||||
|
'Prénom',
|
||||||
|
'Date de naissance',
|
||||||
|
'Adresse',
|
||||||
|
'Email',
|
||||||
|
'Téléphone',
|
||||||
|
'Téléphone secondaire',
|
||||||
|
'Situation',
|
||||||
|
'Prescripteur',
|
||||||
|
'Facturation',
|
||||||
|
'Forfait',
|
||||||
|
'Commentaire',
|
||||||
|
'Instructions'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ligne d'exemple
|
||||||
|
const exampleRow = [
|
||||||
|
'Dupont',
|
||||||
|
'Jean',
|
||||||
|
'15/03/1980',
|
||||||
|
'123 Rue de la Paix, 75001 Paris',
|
||||||
|
'jean.dupont@example.com',
|
||||||
|
'0123456789',
|
||||||
|
'0987654321',
|
||||||
|
'Aucun',
|
||||||
|
'Aucun',
|
||||||
|
'Aucun',
|
||||||
|
'Aucun',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
exampleRow.map(cell => `"${cell}"`).join(',')
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', 'modele_import_adherents.csv');
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fichier invalide',
|
||||||
|
message: 'Le fichier CSV doit contenir au moins une ligne d\'en-tête et une ligne de données',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les options existantes
|
||||||
|
let existingOptions: {
|
||||||
|
situation: Array<{ id: string; value: string }>;
|
||||||
|
prescripteur: Array<{ id: string; value: string }>;
|
||||||
|
facturation: Array<{ id: string; value: string }>;
|
||||||
|
forfait: Array<{ id: string; value: string }>;
|
||||||
|
} = {
|
||||||
|
situation: [],
|
||||||
|
prescripteur: [],
|
||||||
|
facturation: [],
|
||||||
|
forfait: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const optionsResponse = await fetch('/api/settings/adherent-options');
|
||||||
|
if (optionsResponse.ok) {
|
||||||
|
const optionsData = await optionsResponse.json();
|
||||||
|
existingOptions = {
|
||||||
|
situation: optionsData.situation || [],
|
||||||
|
prescripteur: optionsData.prescripteur || [],
|
||||||
|
facturation: optionsData.facturation || [],
|
||||||
|
forfait: optionsData.forfait || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des options:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour créer une nouvelle option si elle n'existe pas
|
||||||
|
const ensureOptionExists = async (type: 'situation' | 'prescripteur' | 'facturation' | 'forfait', value: string): Promise<boolean> => {
|
||||||
|
if (!value || value.trim() === '') return true; // Valeur vide, pas besoin de créer
|
||||||
|
|
||||||
|
const existing = existingOptions[type].find(opt => opt.value.toLowerCase() === value.toLowerCase());
|
||||||
|
if (existing) return true; // L'option existe déjà
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/adherent-options', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type,
|
||||||
|
value: value.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const newOption = await response.json();
|
||||||
|
existingOptions[type].push(newOption);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Si l'option existe déjà (erreur 400), c'est OK
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.error?.includes('existe déjà')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur lors de la création de l'option ${type}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Détecter le séparateur (virgule ou point-virgule)
|
||||||
|
const detectSeparator = (firstLine: string): string => {
|
||||||
|
const commaCount = (firstLine.match(/,/g) || []).length;
|
||||||
|
const semicolonCount = (firstLine.match(/;/g) || []).length;
|
||||||
|
// Utiliser le séparateur le plus fréquent, ou point-virgule par défaut pour les fichiers français
|
||||||
|
return semicolonCount >= commaCount ? ';' : ',';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parser le CSV (gestion simple des guillemets avec détection automatique du séparateur)
|
||||||
|
const parseCSVLine = (line: string, separator: string): string[] => {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === separator && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Détecter le séparateur depuis la première ligne
|
||||||
|
const separator = detectSeparator(lines[0]);
|
||||||
|
|
||||||
|
const headers = parseCSVLine(lines[0], separator);
|
||||||
|
const dataLines = lines.slice(1);
|
||||||
|
|
||||||
|
// Fonction pour normaliser les noms de colonnes (enlever accents, espaces, mettre en minuscule)
|
||||||
|
const normalizeHeader = (header: string): string => {
|
||||||
|
return header
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // Enlever les accents
|
||||||
|
.replace(/\s+/g, '') // Enlever les espaces
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour trouver une colonne avec plusieurs variantes possibles
|
||||||
|
const findColumnIndex = (patterns: string[], excludePatterns: string[] = []): number => {
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const normalized = normalizeHeader(headers[i]);
|
||||||
|
const matchesPattern = patterns.some(pattern => normalized.includes(pattern));
|
||||||
|
const matchesExclude = excludePatterns.some(pattern => normalized.includes(pattern));
|
||||||
|
|
||||||
|
if (matchesPattern && !matchesExclude) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mapping des colonnes avec leurs noms d'affichage
|
||||||
|
const nomIndex = findColumnIndex(['nom'], ['prenom', 'prénom']);
|
||||||
|
const nomHeader = nomIndex >= 0 ? headers[nomIndex] : 'Nom';
|
||||||
|
const prenomIndex = findColumnIndex(['prenom', 'prénom']);
|
||||||
|
const prenomHeader = prenomIndex >= 0 ? headers[prenomIndex] : 'Prénom';
|
||||||
|
|
||||||
|
// Pour la date de naissance, chercher une colonne qui contient "date" ET "naissance" ou des variantes
|
||||||
|
const dateNaissanceIndex = (() => {
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const normalized = normalizeHeader(headers[i]);
|
||||||
|
if ((normalized.includes('date') || normalized.includes('naissance') || normalized.includes('birth') || normalized.includes('dob')) &&
|
||||||
|
(normalized.includes('naissance') || normalized.includes('birth') || normalized.includes('dob') || normalized.includes('date'))) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
})();
|
||||||
|
const dateNaissanceHeader = dateNaissanceIndex >= 0 ? headers[dateNaissanceIndex] : 'Date de naissance';
|
||||||
|
const adresseIndex = findColumnIndex(['adresse']);
|
||||||
|
const adresseHeader = adresseIndex >= 0 ? headers[adresseIndex] : 'Adresse';
|
||||||
|
const emailIndex = findColumnIndex(['email', 'mail', 'courriel']);
|
||||||
|
const emailHeader = emailIndex >= 0 ? headers[emailIndex] : 'Email';
|
||||||
|
const telephoneIndex = findColumnIndex(['telephone', 'téléphone', 'tel', 'phone'], ['secondaire', 'sec']);
|
||||||
|
const telephoneHeader = telephoneIndex >= 0 ? headers[telephoneIndex] : 'Téléphone';
|
||||||
|
const telephoneSecondaireIndex = findColumnIndex(['telephone', 'téléphone', 'tel', 'phone'], []); // Trouver celui qui contient "secondaire"
|
||||||
|
const telephoneSecondaireHeader = (() => {
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const normalized = normalizeHeader(headers[i]);
|
||||||
|
if ((normalized.includes('telephone') || normalized.includes('tel') || normalized.includes('phone')) &&
|
||||||
|
(normalized.includes('secondaire') || normalized.includes('sec') || normalized.includes('2'))) {
|
||||||
|
return headers[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Téléphone secondaire';
|
||||||
|
})();
|
||||||
|
const situationIndex = findColumnIndex(['situation']);
|
||||||
|
const situationHeader = situationIndex >= 0 ? headers[situationIndex] : 'Situation';
|
||||||
|
const prescripteurIndex = findColumnIndex(['prescripteur']);
|
||||||
|
const prescripteurHeader = prescripteurIndex >= 0 ? headers[prescripteurIndex] : 'Prescripteur';
|
||||||
|
const facturationIndex = findColumnIndex(['facturation']);
|
||||||
|
const facturationHeader = facturationIndex >= 0 ? headers[facturationIndex] : 'Facturation';
|
||||||
|
const forfaitIndex = findColumnIndex(['forfait']);
|
||||||
|
const forfaitHeader = forfaitIndex >= 0 ? headers[forfaitIndex] : 'Forfait';
|
||||||
|
const commentaireIndex = findColumnIndex(['commentaire', 'comment']);
|
||||||
|
const commentaireHeader = commentaireIndex >= 0 ? headers[commentaireIndex] : 'Commentaire';
|
||||||
|
const instructionsIndex = findColumnIndex(['instructions', 'instruction']);
|
||||||
|
const instructionsHeader = instructionsIndex >= 0 ? headers[instructionsIndex] : 'Instructions';
|
||||||
|
|
||||||
|
// Vérifier que les colonnes obligatoires sont présentes (après le mapping)
|
||||||
|
const missingRequiredColumns: string[] = [];
|
||||||
|
if (nomIndex === -1) missingRequiredColumns.push('Nom');
|
||||||
|
if (prenomIndex === -1) missingRequiredColumns.push('Prénom');
|
||||||
|
if (dateNaissanceIndex === -1) missingRequiredColumns.push('Date de naissance');
|
||||||
|
if (adresseIndex === -1) missingRequiredColumns.push('Adresse');
|
||||||
|
if (emailIndex === -1) missingRequiredColumns.push('Email');
|
||||||
|
if (telephoneIndex === -1) missingRequiredColumns.push('Téléphone');
|
||||||
|
|
||||||
|
if (missingRequiredColumns.length > 0) {
|
||||||
|
// Afficher les colonnes disponibles pour aider au débogage
|
||||||
|
const availableColumns = headers.length > 0 ? headers : ['Aucune colonne détectée'];
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Colonnes manquantes',
|
||||||
|
message: `Le fichier CSV ne contient pas les colonnes obligatoires suivantes : ${missingRequiredColumns.join(', ')}`,
|
||||||
|
details: [
|
||||||
|
'Colonnes détectées dans le fichier :',
|
||||||
|
...availableColumns.map(col => ` • ${col}`),
|
||||||
|
'',
|
||||||
|
'Conseil : Vérifiez que les noms de colonnes correspondent exactement au modèle (les accents et la casse sont importants).'
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
const newOptionsCreated: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < dataLines.length; i++) {
|
||||||
|
const row = parseCSVLine(dataLines[i], separator);
|
||||||
|
if (row.length === 0) continue;
|
||||||
|
|
||||||
|
const nom = nomIndex >= 0 ? (row[nomIndex] || '').trim() : '';
|
||||||
|
const prenom = prenomIndex >= 0 ? (row[prenomIndex] || '').trim() : '';
|
||||||
|
const dateNaissance = dateNaissanceIndex >= 0 ? (row[dateNaissanceIndex] || '').trim() : '';
|
||||||
|
const adresse = adresseIndex >= 0 ? (row[adresseIndex] || '').trim() : '';
|
||||||
|
const email = emailIndex >= 0 ? (row[emailIndex] || '').trim() : '';
|
||||||
|
const telephone = telephoneIndex >= 0 ? (row[telephoneIndex] || '').trim() : '';
|
||||||
|
|
||||||
|
// Validation des champs obligatoires avec identification précise des colonnes manquantes
|
||||||
|
const missingFields: string[] = [];
|
||||||
|
if (!nom) missingFields.push(nomHeader);
|
||||||
|
if (!prenom) missingFields.push(prenomHeader);
|
||||||
|
if (!dateNaissance) missingFields.push(dateNaissanceHeader);
|
||||||
|
if (!adresse) missingFields.push(adresseHeader);
|
||||||
|
if (!email) missingFields.push(emailHeader);
|
||||||
|
if (!telephone) missingFields.push(telephoneHeader);
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
errorCount++;
|
||||||
|
const fieldsList = missingFields.join(', ');
|
||||||
|
errors.push(`Ligne ${i + 2}: Colonnes manquantes ou vides : ${fieldsList}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir la date au format ISO
|
||||||
|
let dateISO = '';
|
||||||
|
try {
|
||||||
|
const dateParts = dateNaissance.split('/');
|
||||||
|
if (dateParts.length === 3) {
|
||||||
|
dateISO = `${dateParts[2]}-${dateParts[1]}-${dateParts[0]}`;
|
||||||
|
} else {
|
||||||
|
dateISO = new Date(dateNaissance).toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorCount++;
|
||||||
|
errors.push(`Ligne ${i + 2}: Colonne "${dateNaissanceHeader}" - Format de date invalide (attendu: JJ/MM/AAAA)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les valeurs des champs optionnels
|
||||||
|
const situationValue = situationIndex >= 0 ? row[situationIndex]?.trim() : '';
|
||||||
|
const prescripteurValue = prescripteurIndex >= 0 ? row[prescripteurIndex]?.trim() : '';
|
||||||
|
const facturationValue = facturationIndex >= 0 ? row[facturationIndex]?.trim() : '';
|
||||||
|
const forfaitValue = forfaitIndex >= 0 ? row[forfaitIndex]?.trim() : '';
|
||||||
|
|
||||||
|
// Créer les options manquantes avant de créer l'adhérent
|
||||||
|
if (situationValue) {
|
||||||
|
const existed = existingOptions.situation.find(opt => opt.value.toLowerCase() === situationValue.toLowerCase());
|
||||||
|
if (!existed) {
|
||||||
|
const created = await ensureOptionExists('situation', situationValue);
|
||||||
|
if (created) {
|
||||||
|
newOptionsCreated.push(`Situation: "${situationValue}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prescripteurValue) {
|
||||||
|
const existed = existingOptions.prescripteur.find(opt => opt.value.toLowerCase() === prescripteurValue.toLowerCase());
|
||||||
|
if (!existed) {
|
||||||
|
const created = await ensureOptionExists('prescripteur', prescripteurValue);
|
||||||
|
if (created) {
|
||||||
|
newOptionsCreated.push(`Prescripteur: "${prescripteurValue}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (facturationValue) {
|
||||||
|
const existed = existingOptions.facturation.find(opt => opt.value.toLowerCase() === facturationValue.toLowerCase());
|
||||||
|
if (!existed) {
|
||||||
|
const created = await ensureOptionExists('facturation', facturationValue);
|
||||||
|
if (created) {
|
||||||
|
newOptionsCreated.push(`Facturation: "${facturationValue}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forfaitValue) {
|
||||||
|
const existed = existingOptions.forfait.find(opt => opt.value.toLowerCase() === forfaitValue.toLowerCase());
|
||||||
|
if (!existed) {
|
||||||
|
const created = await ensureOptionExists('forfait', forfaitValue);
|
||||||
|
if (created) {
|
||||||
|
newOptionsCreated.push(`Forfait: "${forfaitValue}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/adherents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
nom,
|
||||||
|
prenom,
|
||||||
|
dateNaissance: dateISO,
|
||||||
|
adresse,
|
||||||
|
email,
|
||||||
|
telephone,
|
||||||
|
telephoneSecondaire: telephoneSecondaireIndex >= 0 ? row[telephoneSecondaireIndex]?.trim() || null : null,
|
||||||
|
situation: situationValue || null,
|
||||||
|
prescripteur: prescripteurValue || null,
|
||||||
|
facturation: facturationValue || null,
|
||||||
|
forfait: forfaitValue || null,
|
||||||
|
commentaire: commentaireIndex >= 0 ? row[commentaireIndex]?.trim() || null : null,
|
||||||
|
instructions: instructionsIndex >= 0 ? row[instructionsIndex]?.trim() || null : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
// Essayer d'identifier les champs problématiques depuis le message d'erreur
|
||||||
|
let errorMsg = errorData.error || 'Erreur lors de l\'import';
|
||||||
|
if (errorMsg.includes('champs obligatoires')) {
|
||||||
|
// Si l'erreur vient du serveur, on peut ajouter plus de contexte
|
||||||
|
errorMsg = `Erreur de validation : ${errorMsg}`;
|
||||||
|
}
|
||||||
|
errors.push(`Ligne ${i + 2}: ${errorMsg}`);
|
||||||
|
} catch {
|
||||||
|
errors.push(`Ligne ${i + 2}: Erreur serveur lors de l'import`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
errors.push(`Ligne ${i + 2}: Erreur réseau - ${error instanceof Error ? error.message : 'Connexion impossible'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher les résultats dans une modale
|
||||||
|
const title = errorCount > 0
|
||||||
|
? `Import terminé avec ${errorCount} erreur(s)`
|
||||||
|
: 'Import réussi';
|
||||||
|
|
||||||
|
let message = errorCount === 0
|
||||||
|
? `${successCount} adhérent(s) importé(s) avec succès`
|
||||||
|
: `${successCount} adhérent(s) importé(s) avec succès, ${errorCount} erreur(s)`;
|
||||||
|
|
||||||
|
// Ajouter les informations sur les nouvelles options créées
|
||||||
|
if (newOptionsCreated.length > 0) {
|
||||||
|
message += `\n\n${newOptionsCreated.length} nouvelle(s) option(s) ajoutée(s) aux sélecteurs.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combiner les erreurs et les nouvelles options dans les détails
|
||||||
|
const details: string[] = [];
|
||||||
|
if (newOptionsCreated.length > 0) {
|
||||||
|
details.push('Nouvelles options créées :');
|
||||||
|
details.push(...newOptionsCreated);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
details.push('');
|
||||||
|
details.push('Erreurs :');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
details.push(...errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: errorCount === 0 ? 'success' : 'error',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
details: details.length > 0 ? details : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rafraîchir la liste
|
||||||
|
fetchAdherents(search);
|
||||||
|
setShowImportModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Barre de recherche et actions */}
|
{/* Barre de recherche et actions */}
|
||||||
@@ -147,17 +735,24 @@ export default function AdherentsTable() {
|
|||||||
</svg>
|
</svg>
|
||||||
Nouvel adhérent
|
Nouvel adhérent
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors">
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Importer
|
Importer
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-lorange text-white rounded-lg hover:bg-dorange transition-colors">
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-lorange text-white rounded-lg hover:bg-dorange transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4-4m0 0l-4-4m4 4V4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4-4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Exporter
|
Exporter {selectedIds.size > 0 && `(${selectedIds.size})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,6 +769,14 @@ export default function AdherentsTable() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={adherents.length > 0 && selectedIds.size === adherents.length}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">NOM</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">NOM</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CONTACT</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CONTACT</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ADRESSE</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ADRESSE</th>
|
||||||
@@ -185,6 +788,14 @@ export default function AdherentsTable() {
|
|||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{adherents.map((adherent) => (
|
{adherents.map((adherent) => (
|
||||||
<tr key={adherent.id} className="hover:bg-gray-50">
|
<tr key={adherent.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(adherent.id)}
|
||||||
|
onChange={(e) => handleSelectOne(adherent.id, e.target.checked)}
|
||||||
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-lgreen flex items-center justify-center text-white font-semibold">
|
<div className="w-10 h-10 rounded-full bg-lgreen flex items-center justify-center text-white font-semibold">
|
||||||
@@ -285,6 +896,242 @@ export default function AdherentsTable() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal import */}
|
||||||
|
{showImportModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full animate-slideUp border border-gray-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 px-6 py-5 bg-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
Importer des adhérents
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Téléchargez le modèle d'exemple, remplissez-le et importez-le ici
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1.5 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Télécharger le modèle */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-blue-900 mb-1">
|
||||||
|
Télécharger le modèle d'exemple
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-700 mb-3">
|
||||||
|
Téléchargez le fichier CSV modèle pour voir le format attendu et remplir vos données.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Télécharger le modèle CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload fichier */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Sélectionner le fichier CSV à importer
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:border-lblue transition-colors">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||||
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex text-sm text-gray-600">
|
||||||
|
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-lblue hover:text-dblue focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-lblue">
|
||||||
|
<span>Cliquez pour sélectionner un fichier</span>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
name="file-upload"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="pl-1">ou glissez-déposez</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">CSV jusqu'à 10MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
|
Instructions :
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1 list-disc list-inside">
|
||||||
|
<li>Les champs Nom, Prénom, Date de naissance, Adresse, Email et Téléphone sont obligatoires</li>
|
||||||
|
<li>Le format de date attendu est JJ/MM/AAAA</li>
|
||||||
|
<li>Les autres champs sont optionnels</li>
|
||||||
|
<li>Assurez-vous que le fichier utilise l'encodage UTF-8</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50/50">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal résultat */}
|
||||||
|
{resultModal && resultModal.show && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[60] p-4 animate-fadeIn"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg shadow-xl max-w-lg w-full animate-slideUp border border-gray-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`border-b border-gray-200 px-6 py-5 ${
|
||||||
|
resultModal.type === 'success' ? 'bg-green-50' :
|
||||||
|
resultModal.type === 'error' ? 'bg-red-50' :
|
||||||
|
'bg-blue-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{resultModal.type === 'success' && (
|
||||||
|
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{resultModal.type === 'error' && (
|
||||||
|
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{resultModal.type === 'info' && (
|
||||||
|
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<h2 className={`text-xl font-semibold ${
|
||||||
|
resultModal.type === 'success' ? 'text-green-900' :
|
||||||
|
resultModal.type === 'error' ? 'text-red-900' :
|
||||||
|
'text-blue-900'
|
||||||
|
}`}>
|
||||||
|
{resultModal.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1.5 hover:bg-white/50 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<p className={`text-sm font-medium mb-4 ${
|
||||||
|
resultModal.type === 'success' ? 'text-green-800' :
|
||||||
|
resultModal.type === 'error' ? 'text-red-800' :
|
||||||
|
'text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{resultModal.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{resultModal.details && resultModal.details.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
|
Erreurs :
|
||||||
|
</h3>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{resultModal.details.slice(0, 10).map((error, index) => (
|
||||||
|
<li key={index} className="text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{resultModal.details.length > 10 && (
|
||||||
|
<li className="text-sm text-red-600 italic">
|
||||||
|
... et {resultModal.details.length - 10} autre(s) erreur(s)
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50/50">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
className={`px-6 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
resultModal.type === 'success'
|
||||||
|
? 'bg-green-600 text-white hover:bg-green-700' :
|
||||||
|
resultModal.type === 'error'
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700' :
|
||||||
|
'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de confirmation de suppression */}
|
||||||
|
{confirmDeleteModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteModal.show}
|
||||||
|
title="Supprimer l'adhérent"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer cet adhérent ?"
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setConfirmDeleteModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modal vue détaillée */}
|
{/* Modal vue détaillée */}
|
||||||
{viewingAdherent && (
|
{viewingAdherent && (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
||||||
@@ -418,6 +1265,17 @@ export default function AdherentsTable() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{viewingAdherent.forfait && (
|
||||||
|
<div className="flex items-center py-3 border-b border-gray-100">
|
||||||
|
<div className="w-32 flex-shrink-0">
|
||||||
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Forfait</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm text-gray-900 font-medium">{viewingAdherent.forfait}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{viewingAdherent.telephoneSecondaire && (
|
{viewingAdherent.telephoneSecondaire && (
|
||||||
<div className="flex items-center py-3 border-b border-gray-100">
|
<div className="flex items-center py-3 border-b border-gray-100">
|
||||||
<div className="w-32 flex-shrink-0">
|
<div className="w-32 flex-shrink-0">
|
||||||
|
|||||||
127
components/AlertModal.tsx
Normal file
127
components/AlertModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface AlertModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertModal({
|
||||||
|
isOpen,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onClose,
|
||||||
|
}: AlertModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getStyles = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
bg: 'bg-green-50',
|
||||||
|
titleColor: 'text-green-900',
|
||||||
|
messageColor: 'text-green-800',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
button: 'bg-green-600 text-white hover:bg-green-700',
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
bg: 'bg-red-50',
|
||||||
|
titleColor: 'text-red-900',
|
||||||
|
messageColor: 'text-red-800',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
button: 'bg-red-600 text-white hover:bg-red-700',
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
bg: 'bg-orange-50',
|
||||||
|
titleColor: 'text-orange-900',
|
||||||
|
messageColor: 'text-orange-800',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
button: 'bg-orange-600 text-white hover:bg-orange-700',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
bg: 'bg-blue-50',
|
||||||
|
titleColor: 'text-blue-900',
|
||||||
|
messageColor: 'text-blue-800',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
button: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = getStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[60] p-4 animate-fadeIn"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg shadow-xl max-w-md w-full animate-slideUp border border-gray-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`border-b border-gray-200 px-6 py-5 ${styles.bg}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{styles.icon}
|
||||||
|
<h2 className={`text-xl font-semibold ${styles.titleColor}`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1.5 hover:bg-white/50 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<p className={`text-sm font-medium ${styles.messageColor}`}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50/50">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className={`px-6 py-2 text-sm font-medium rounded-lg transition-colors ${styles.button}`}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import AlertModal from './AlertModal';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -61,6 +62,12 @@ export default function ChatWindow({
|
|||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
const [typingUsers, setTypingUsers] = useState<User[]>([]);
|
const [typingUsers, setTypingUsers] = useState<User[]>([]);
|
||||||
const [readReceipts, setReadReceipts] = useState<Record<string, Date | null>>({});
|
const [readReceipts, setReadReceipts] = useState<Record<string, Date | null>>({});
|
||||||
|
const [alertModal, setAlertModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -198,11 +205,21 @@ export default function ChatWindow({
|
|||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Erreur lors de l\'envoi du message');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: error.error || 'Erreur lors de l\'envoi du message',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
alert('Erreur lors de l\'envoi du message');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de l\'envoi du message',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
@@ -584,6 +601,17 @@ export default function ChatWindow({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal d'alerte */}
|
||||||
|
{alertModal && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.show}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
onClose={() => setAlertModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import AlertModal from './AlertModal';
|
||||||
|
|
||||||
interface Chauffeur {
|
interface Chauffeur {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,6 +25,12 @@ interface ChauffeurFormProps {
|
|||||||
|
|
||||||
export default function ChauffeurForm({ chauffeur, onClose }: ChauffeurFormProps) {
|
export default function ChauffeurForm({ chauffeur, onClose }: ChauffeurFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [alertModal, setAlertModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
nom: '',
|
nom: '',
|
||||||
prenom: '',
|
prenom: '',
|
||||||
@@ -83,11 +90,21 @@ export default function ChauffeurForm({ chauffeur, onClose }: ChauffeurFormProps
|
|||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Une erreur est survenue');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: error.error || 'Une erreur est survenue',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
alert('Une erreur est survenue');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Une erreur est survenue',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -355,6 +372,17 @@ export default function ChauffeurForm({ chauffeur, onClose }: ChauffeurFormProps
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal d'alerte */}
|
||||||
|
{alertModal && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.show}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
onClose={() => setAlertModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import ChauffeurForm from './ChauffeurForm';
|
import ChauffeurForm from './ChauffeurForm';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
interface Chauffeur {
|
interface Chauffeur {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +26,19 @@ export default function ChauffeursTable() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingChauffeur, setEditingChauffeur] = useState<Chauffeur | null>(null);
|
const [editingChauffeur, setEditingChauffeur] = useState<Chauffeur | null>(null);
|
||||||
const [viewingChauffeur, setViewingChauffeur] = useState<Chauffeur | null>(null);
|
const [viewingChauffeur, setViewingChauffeur] = useState<Chauffeur | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [resultModal, setResultModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
details?: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmDeleteModal, setConfirmDeleteModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
id: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const fetchChauffeurs = async (searchTerm: string = '') => {
|
const fetchChauffeurs = async (searchTerm: string = '') => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -57,23 +71,46 @@ export default function ChauffeursTable() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce chauffeur ?')) {
|
setConfirmDeleteModal({
|
||||||
return;
|
show: true,
|
||||||
}
|
id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!confirmDeleteModal?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/chauffeurs/${id}`, {
|
const response = await fetch(`/api/chauffeurs/${confirmDeleteModal.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
fetchChauffeurs(search);
|
fetchChauffeurs(search);
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'success',
|
||||||
|
title: 'Suppression réussie',
|
||||||
|
message: 'Le chauffeur a été supprimé avec succès',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
alert('Erreur lors de la suppression');
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de la suppression',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la suppression:', error);
|
console.error('Erreur lors de la suppression:', error);
|
||||||
alert('Erreur lors de la suppression');
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de la suppression',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setConfirmDeleteModal(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,6 +164,435 @@ export default function ChauffeursTable() {
|
|||||||
return ((total - restantes) / total) * 100;
|
return ((total - restantes) / total) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(new Set(chauffeurs.map(c => c.id)));
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectOne = (id: string, checked: boolean) => {
|
||||||
|
const newSelected = new Set(selectedIds);
|
||||||
|
if (checked) {
|
||||||
|
newSelected.add(id);
|
||||||
|
} else {
|
||||||
|
newSelected.delete(id);
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'info',
|
||||||
|
title: 'Aucune sélection',
|
||||||
|
message: 'Veuillez sélectionner au moins un chauffeur à exporter',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedChauffeurs = chauffeurs.filter(c => selectedIds.has(c.id));
|
||||||
|
|
||||||
|
// Créer les en-têtes CSV avec tous les champs disponibles
|
||||||
|
const headers = [
|
||||||
|
'Nom',
|
||||||
|
'Prénom',
|
||||||
|
'Date de naissance',
|
||||||
|
'Téléphone',
|
||||||
|
'Email',
|
||||||
|
'Adresse',
|
||||||
|
'Heures contrat',
|
||||||
|
'Date début contrat',
|
||||||
|
'Date fin contrat',
|
||||||
|
'Status'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Créer les lignes CSV
|
||||||
|
const rows = selectedChauffeurs.map(chauffeur => {
|
||||||
|
const formatDateForCSV = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
chauffeur.nom || '',
|
||||||
|
chauffeur.prenom || '',
|
||||||
|
formatDateForCSV(chauffeur.dateNaissance),
|
||||||
|
chauffeur.telephone || '',
|
||||||
|
chauffeur.email || '',
|
||||||
|
chauffeur.adresse || '',
|
||||||
|
chauffeur.heuresContrat?.toString() || '',
|
||||||
|
formatDateForCSV(chauffeur.dateDebutContrat),
|
||||||
|
chauffeur.dateFinContrat ? formatDateForCSV(chauffeur.dateFinContrat) : '',
|
||||||
|
chauffeur.status || 'Disponible'
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer le contenu CSV
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Créer le blob et télécharger
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }); // BOM pour Excel
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `chauffeurs_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Réinitialiser la sélection
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTemplate = () => {
|
||||||
|
const headers = [
|
||||||
|
'Nom',
|
||||||
|
'Prénom',
|
||||||
|
'Date de naissance',
|
||||||
|
'Téléphone',
|
||||||
|
'Email',
|
||||||
|
'Adresse',
|
||||||
|
'Heures contrat',
|
||||||
|
'Date début contrat',
|
||||||
|
'Date fin contrat',
|
||||||
|
'Status'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ligne d'exemple
|
||||||
|
const exampleRow = [
|
||||||
|
'Dupont',
|
||||||
|
'Jean',
|
||||||
|
'15/03/1980',
|
||||||
|
'0123456789',
|
||||||
|
'jean.dupont@example.com',
|
||||||
|
'123 Rue de la Paix, 75001 Paris',
|
||||||
|
'35',
|
||||||
|
'01/01/2024',
|
||||||
|
'',
|
||||||
|
'Disponible'
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
exampleRow.map(cell => `"${cell}"`).join(',')
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', 'modele_import_chauffeurs.csv');
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fichier invalide',
|
||||||
|
message: 'Le fichier CSV doit contenir au moins une ligne d\'en-tête et une ligne de données',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détecter le séparateur (virgule ou point-virgule)
|
||||||
|
const detectSeparator = (firstLine: string): string => {
|
||||||
|
const commaCount = (firstLine.match(/,/g) || []).length;
|
||||||
|
const semicolonCount = (firstLine.match(/;/g) || []).length;
|
||||||
|
return semicolonCount >= commaCount ? ';' : ',';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parser le CSV (gestion simple des guillemets avec détection automatique du séparateur)
|
||||||
|
const parseCSVLine = (line: string, separator: string): string[] => {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === separator && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour normaliser les noms de colonnes
|
||||||
|
const normalizeHeader = (header: string): string => {
|
||||||
|
return header
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour trouver une colonne avec plusieurs variantes possibles
|
||||||
|
const findColumnIndex = (patterns: string[], excludePatterns: string[] = []): number => {
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const normalized = normalizeHeader(headers[i]);
|
||||||
|
const matchesPattern = patterns.some(pattern => normalized.includes(pattern));
|
||||||
|
const matchesExclude = excludePatterns.some(pattern => normalized.includes(pattern));
|
||||||
|
|
||||||
|
if (matchesPattern && !matchesExclude) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Détecter le séparateur depuis la première ligne
|
||||||
|
const separator = detectSeparator(lines[0]);
|
||||||
|
|
||||||
|
const headers = parseCSVLine(lines[0], separator);
|
||||||
|
const dataLines = lines.slice(1);
|
||||||
|
|
||||||
|
// Mapping des colonnes avec leurs noms d'affichage
|
||||||
|
const nomIndex = findColumnIndex(['nom'], ['prenom', 'prénom']);
|
||||||
|
const nomHeader = nomIndex >= 0 ? headers[nomIndex] : 'Nom';
|
||||||
|
const prenomIndex = findColumnIndex(['prenom', 'prénom']);
|
||||||
|
const prenomHeader = prenomIndex >= 0 ? headers[prenomIndex] : 'Prénom';
|
||||||
|
|
||||||
|
// Pour la date de naissance
|
||||||
|
const dateNaissanceIndex = (() => {
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const normalized = normalizeHeader(headers[i]);
|
||||||
|
if ((normalized.includes('date') || normalized.includes('naissance') || normalized.includes('birth') || normalized.includes('dob')) &&
|
||||||
|
(normalized.includes('naissance') || normalized.includes('birth') || normalized.includes('dob') || normalized.includes('date'))) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
})();
|
||||||
|
const dateNaissanceHeader = dateNaissanceIndex >= 0 ? headers[dateNaissanceIndex] : 'Date de naissance';
|
||||||
|
|
||||||
|
const telephoneIndex = findColumnIndex(['telephone', 'téléphone', 'tel', 'phone']);
|
||||||
|
const telephoneHeader = telephoneIndex >= 0 ? headers[telephoneIndex] : 'Téléphone';
|
||||||
|
const emailIndex = findColumnIndex(['email', 'mail', 'courriel']);
|
||||||
|
const emailHeader = emailIndex >= 0 ? headers[emailIndex] : 'Email';
|
||||||
|
const adresseIndex = findColumnIndex(['adresse']);
|
||||||
|
const adresseHeader = adresseIndex >= 0 ? headers[adresseIndex] : 'Adresse';
|
||||||
|
const heuresContratIndex = findColumnIndex(['heures', 'contrat'], ['debut', 'début', 'fin']);
|
||||||
|
const heuresContratHeader = heuresContratIndex >= 0 ? headers[heuresContratIndex] : 'Heures contrat';
|
||||||
|
const dateDebutContratIndex = findColumnIndex(['debut', 'début'], ['fin']);
|
||||||
|
const dateDebutContratHeader = dateDebutContratIndex >= 0 ? headers[dateDebutContratIndex] : 'Date début contrat';
|
||||||
|
const dateFinContratIndex = findColumnIndex(['fin'], ['debut', 'début']);
|
||||||
|
const dateFinContratHeader = dateFinContratIndex >= 0 ? headers[dateFinContratIndex] : 'Date fin contrat';
|
||||||
|
const statusIndex = findColumnIndex(['status', 'statut']);
|
||||||
|
const statusHeader = statusIndex >= 0 ? headers[statusIndex] : 'Status';
|
||||||
|
|
||||||
|
// Vérifier que les colonnes obligatoires sont présentes
|
||||||
|
const missingRequiredColumns: string[] = [];
|
||||||
|
if (nomIndex === -1) missingRequiredColumns.push('Nom');
|
||||||
|
if (prenomIndex === -1) missingRequiredColumns.push('Prénom');
|
||||||
|
if (dateNaissanceIndex === -1) missingRequiredColumns.push('Date de naissance');
|
||||||
|
if (telephoneIndex === -1) missingRequiredColumns.push('Téléphone');
|
||||||
|
if (emailIndex === -1) missingRequiredColumns.push('Email');
|
||||||
|
if (adresseIndex === -1) missingRequiredColumns.push('Adresse');
|
||||||
|
if (heuresContratIndex === -1) missingRequiredColumns.push('Heures contrat');
|
||||||
|
if (dateDebutContratIndex === -1) missingRequiredColumns.push('Date début contrat');
|
||||||
|
|
||||||
|
if (missingRequiredColumns.length > 0) {
|
||||||
|
const availableColumns = headers.length > 0 ? headers : ['Aucune colonne détectée'];
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Colonnes manquantes',
|
||||||
|
message: `Le fichier CSV ne contient pas les colonnes obligatoires suivantes : ${missingRequiredColumns.join(', ')}`,
|
||||||
|
details: [
|
||||||
|
'Colonnes détectées dans le fichier :',
|
||||||
|
...availableColumns.map(col => ` • ${col}`),
|
||||||
|
'',
|
||||||
|
'Conseil : Vérifiez que les noms de colonnes correspondent exactement au modèle (les accents et la casse sont importants).'
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < dataLines.length; i++) {
|
||||||
|
const row = parseCSVLine(dataLines[i], separator);
|
||||||
|
if (row.length === 0) continue;
|
||||||
|
|
||||||
|
const nom = nomIndex >= 0 ? (row[nomIndex] || '').trim() : '';
|
||||||
|
const prenom = prenomIndex >= 0 ? (row[prenomIndex] || '').trim() : '';
|
||||||
|
const dateNaissance = dateNaissanceIndex >= 0 ? (row[dateNaissanceIndex] || '').trim() : '';
|
||||||
|
const telephone = telephoneIndex >= 0 ? (row[telephoneIndex] || '').trim() : '';
|
||||||
|
const email = emailIndex >= 0 ? (row[emailIndex] || '').trim() : '';
|
||||||
|
const adresse = adresseIndex >= 0 ? (row[adresseIndex] || '').trim() : '';
|
||||||
|
const heuresContrat = heuresContratIndex >= 0 ? (row[heuresContratIndex] || '').trim() : '';
|
||||||
|
const dateDebutContrat = dateDebutContratIndex >= 0 ? (row[dateDebutContratIndex] || '').trim() : '';
|
||||||
|
const dateFinContrat = dateFinContratIndex >= 0 ? (row[dateFinContratIndex] || '').trim() : '';
|
||||||
|
const status = statusIndex >= 0 ? (row[statusIndex] || '').trim() : 'Disponible';
|
||||||
|
|
||||||
|
// Validation des champs obligatoires avec identification précise des colonnes manquantes
|
||||||
|
const missingFields: string[] = [];
|
||||||
|
if (!nom) missingFields.push(nomHeader);
|
||||||
|
if (!prenom) missingFields.push(prenomHeader);
|
||||||
|
if (!dateNaissance) missingFields.push(dateNaissanceHeader);
|
||||||
|
if (!telephone) missingFields.push(telephoneHeader);
|
||||||
|
if (!email) missingFields.push(emailHeader);
|
||||||
|
if (!adresse) missingFields.push(adresseHeader);
|
||||||
|
if (!heuresContrat) missingFields.push(heuresContratHeader);
|
||||||
|
if (!dateDebutContrat) missingFields.push(dateDebutContratHeader);
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
errorCount++;
|
||||||
|
const fieldsList = missingFields.join(', ');
|
||||||
|
errors.push(`Ligne ${i + 2}: Colonnes manquantes ou vides : ${fieldsList}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir les dates au format ISO
|
||||||
|
let dateNaissanceISO = '';
|
||||||
|
let dateDebutContratISO = '';
|
||||||
|
let dateFinContratISO: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dateParts = dateNaissance.split('/');
|
||||||
|
if (dateParts.length === 3) {
|
||||||
|
dateNaissanceISO = `${dateParts[2]}-${dateParts[1]}-${dateParts[0]}`;
|
||||||
|
} else {
|
||||||
|
dateNaissanceISO = new Date(dateNaissance).toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorCount++;
|
||||||
|
errors.push(`Ligne ${i + 2}: Colonne "${dateNaissanceHeader}" - Format de date invalide (attendu: JJ/MM/AAAA)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dateParts = dateDebutContrat.split('/');
|
||||||
|
if (dateParts.length === 3) {
|
||||||
|
dateDebutContratISO = `${dateParts[2]}-${dateParts[1]}-${dateParts[0]}`;
|
||||||
|
} else {
|
||||||
|
dateDebutContratISO = new Date(dateDebutContrat).toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorCount++;
|
||||||
|
errors.push(`Ligne ${i + 2}: Colonne "${dateDebutContratHeader}" - Format de date invalide (attendu: JJ/MM/AAAA)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFinContrat) {
|
||||||
|
try {
|
||||||
|
const dateParts = dateFinContrat.split('/');
|
||||||
|
if (dateParts.length === 3) {
|
||||||
|
dateFinContratISO = `${dateParts[2]}-${dateParts[1]}-${dateParts[0]}`;
|
||||||
|
} else {
|
||||||
|
dateFinContratISO = new Date(dateFinContrat).toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Si la date de fin est invalide, on continue sans elle (elle est optionnelle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valider les heures contrat
|
||||||
|
const heuresContratNum = parseInt(heuresContrat, 10);
|
||||||
|
if (isNaN(heuresContratNum) || heuresContratNum <= 0) {
|
||||||
|
errorCount++;
|
||||||
|
errors.push(`Ligne ${i + 2}: Colonne "${heuresContratHeader}" - Doit être un nombre positif`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/chauffeurs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
nom,
|
||||||
|
prenom,
|
||||||
|
dateNaissance: dateNaissanceISO,
|
||||||
|
telephone,
|
||||||
|
email,
|
||||||
|
adresse,
|
||||||
|
heuresContrat: heuresContratNum,
|
||||||
|
dateDebutContrat: dateDebutContratISO,
|
||||||
|
dateFinContrat: dateFinContratISO,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
let errorMsg = errorData.error || 'Erreur lors de l\'import';
|
||||||
|
if (errorMsg.includes('champs obligatoires')) {
|
||||||
|
errorMsg = `Erreur de validation : ${errorMsg}`;
|
||||||
|
}
|
||||||
|
errors.push(`Ligne ${i + 2}: ${errorMsg}`);
|
||||||
|
} catch {
|
||||||
|
errors.push(`Ligne ${i + 2}: Erreur serveur lors de l'import`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
errors.push(`Ligne ${i + 2}: Erreur réseau - ${error instanceof Error ? error.message : 'Connexion impossible'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher les résultats dans une modale
|
||||||
|
const title = errorCount > 0
|
||||||
|
? `Import terminé avec ${errorCount} erreur(s)`
|
||||||
|
: 'Import réussi';
|
||||||
|
|
||||||
|
const message = errorCount === 0
|
||||||
|
? `${successCount} chauffeur(s) importé(s) avec succès`
|
||||||
|
: `${successCount} chauffeur(s) importé(s) avec succès, ${errorCount} erreur(s)`;
|
||||||
|
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: errorCount === 0 ? 'success' : 'error',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
details: errors.length > 0 ? errors : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rafraîchir la liste
|
||||||
|
fetchChauffeurs(search);
|
||||||
|
setShowImportModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Barre de recherche et actions */}
|
{/* Barre de recherche et actions */}
|
||||||
@@ -164,17 +630,24 @@ export default function ChauffeursTable() {
|
|||||||
</svg>
|
</svg>
|
||||||
Nouveau chauffeur
|
Nouveau chauffeur
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors">
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Importer
|
Importer
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-lorange text-white rounded-lg hover:bg-dorange transition-colors">
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-lorange text-white rounded-lg hover:bg-dorange transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4-4m0 0l-4-4m4 4V4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4-4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Exporter
|
Exporter {selectedIds.size > 0 && `(${selectedIds.size})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,6 +664,14 @@ export default function ChauffeursTable() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chauffeurs.length > 0 && selectedIds.size === chauffeurs.length}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">NOM</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">NOM</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CONTACT</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CONTACT</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ADRESSE</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ADRESSE</th>
|
||||||
@@ -202,6 +683,14 @@ export default function ChauffeursTable() {
|
|||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{chauffeurs.map((chauffeur) => (
|
{chauffeurs.map((chauffeur) => (
|
||||||
<tr key={chauffeur.id} className="hover:bg-gray-50">
|
<tr key={chauffeur.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(chauffeur.id)}
|
||||||
|
onChange={(e) => handleSelectOne(chauffeur.id, e.target.checked)}
|
||||||
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-lorange flex items-center justify-center text-white font-semibold">
|
<div className="w-10 h-10 rounded-full bg-lorange flex items-center justify-center text-white font-semibold">
|
||||||
@@ -294,6 +783,241 @@ export default function ChauffeursTable() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal import */}
|
||||||
|
{showImportModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full animate-slideUp border border-gray-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 px-6 py-5 bg-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
Importer des chauffeurs
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Téléchargez le modèle d'exemple, remplissez-le et importez-le ici
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1.5 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Télécharger le modèle */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-blue-900 mb-1">
|
||||||
|
Télécharger le modèle d'exemple
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-700 mb-3">
|
||||||
|
Téléchargez le fichier CSV modèle pour voir le format attendu et remplir vos données.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Télécharger le modèle CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload fichier */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Sélectionner le fichier CSV à importer
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:border-lblue transition-colors">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||||
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex text-sm text-gray-600">
|
||||||
|
<label htmlFor="file-upload-chauffeur" className="relative cursor-pointer bg-white rounded-md font-medium text-lblue hover:text-dblue focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-lblue">
|
||||||
|
<span>Cliquez pour sélectionner un fichier</span>
|
||||||
|
<input
|
||||||
|
id="file-upload-chauffeur"
|
||||||
|
name="file-upload-chauffeur"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="pl-1">ou glissez-déposez</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">CSV jusqu'à 10MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
|
Instructions :
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1 list-disc list-inside">
|
||||||
|
<li>Les champs Nom, Prénom, Date de naissance, Téléphone, Email, Adresse, Heures contrat et Date début contrat sont obligatoires</li>
|
||||||
|
<li>Le format de date attendu est JJ/MM/AAAA</li>
|
||||||
|
<li>Les champs Date fin contrat et Status sont optionnels</li>
|
||||||
|
<li>Assurez-vous que le fichier utilise l'encodage UTF-8</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50/50">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal résultat */}
|
||||||
|
{resultModal && resultModal.show && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[60] p-4 animate-fadeIn"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg shadow-xl max-w-lg w-full animate-slideUp border border-gray-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`border-b border-gray-200 px-6 py-5 ${
|
||||||
|
resultModal.type === 'success' ? 'bg-green-50' :
|
||||||
|
resultModal.type === 'error' ? 'bg-red-50' :
|
||||||
|
'bg-blue-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{resultModal.type === 'success' && (
|
||||||
|
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{resultModal.type === 'error' && (
|
||||||
|
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{resultModal.type === 'info' && (
|
||||||
|
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<h2 className={`text-xl font-semibold ${
|
||||||
|
resultModal.type === 'success' ? 'text-green-900' :
|
||||||
|
resultModal.type === 'error' ? 'text-red-900' :
|
||||||
|
'text-blue-900'
|
||||||
|
}`}>
|
||||||
|
{resultModal.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1.5 hover:bg-white/50 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<p className={`text-sm font-medium mb-4 ${
|
||||||
|
resultModal.type === 'success' ? 'text-green-800' :
|
||||||
|
resultModal.type === 'error' ? 'text-red-800' :
|
||||||
|
'text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{resultModal.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{resultModal.details && resultModal.details.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
|
{resultModal.details[0].includes('Colonnes détectées') ? 'Détails :' : 'Erreurs :'}
|
||||||
|
</h3>
|
||||||
|
<div className={`border rounded-lg p-4 max-h-64 overflow-y-auto ${
|
||||||
|
resultModal.type === 'error' ? 'bg-red-50 border-red-200' : 'bg-gray-50 border-gray-200'
|
||||||
|
}`}>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{resultModal.details.map((detail, index) => (
|
||||||
|
<li key={index} className={`text-sm ${
|
||||||
|
resultModal.type === 'error' ? 'text-red-800' : 'text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{detail}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50/50">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
className={`px-6 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
resultModal.type === 'success'
|
||||||
|
? 'bg-green-600 text-white hover:bg-green-700' :
|
||||||
|
resultModal.type === 'error'
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700' :
|
||||||
|
'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de confirmation de suppression */}
|
||||||
|
{confirmDeleteModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteModal.show}
|
||||||
|
title="Supprimer le chauffeur"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer ce chauffeur ?"
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setConfirmDeleteModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modal vue détaillée */}
|
{/* Modal vue détaillée */}
|
||||||
{viewingChauffeur && (
|
{viewingChauffeur && (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, memo } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useNotification } from './NotificationProvider';
|
import { useNotification } from './NotificationProvider';
|
||||||
import { AVAILABLE_PAGES } from '@/lib/pages';
|
import { AVAILABLE_PAGES } from '@/lib/pages';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
interface AdherentOption {
|
interface AdherentOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -185,6 +186,18 @@ export default function ConfigurationContent() {
|
|||||||
facturation: '',
|
facturation: '',
|
||||||
forfait: '',
|
forfait: '',
|
||||||
});
|
});
|
||||||
|
const [confirmDeleteOptionModal, setConfirmDeleteOptionModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
id: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmDeleteUserModal, setConfirmDeleteUserModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
userId: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmDeleteRoleModal, setConfirmDeleteRoleModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
roleId: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const fetchOptions = useCallback(async () => {
|
const fetchOptions = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -286,13 +299,20 @@ export default function ConfigurationContent() {
|
|||||||
setEditingValue('');
|
setEditingValue('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = useCallback(async (id: string) => {
|
const handleDelete = useCallback((id: string) => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cette option ?')) {
|
setConfirmDeleteOptionModal({
|
||||||
return;
|
show: true,
|
||||||
}
|
id,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDeleteOption = useCallback(async () => {
|
||||||
|
if (!confirmDeleteOptionModal?.id) return;
|
||||||
|
|
||||||
|
const idToDelete = confirmDeleteOptionModal.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/settings/adherent-options/${id}`, {
|
const response = await fetch(`/api/settings/adherent-options/${idToDelete}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,8 +326,10 @@ export default function ConfigurationContent() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
showNotification('Erreur lors de la suppression', 'error');
|
showNotification('Erreur lors de la suppression', 'error');
|
||||||
|
} finally {
|
||||||
|
setConfirmDeleteOptionModal(null);
|
||||||
}
|
}
|
||||||
}, [showNotification, fetchOptions]);
|
}, [confirmDeleteOptionModal, fetchOptions, showNotification]);
|
||||||
|
|
||||||
const handleNewValueChange = useCallback((type: 'situation' | 'prescripteur' | 'facturation' | 'forfait', value: string) => {
|
const handleNewValueChange = useCallback((type: 'situation' | 'prescripteur' | 'facturation' | 'forfait', value: string) => {
|
||||||
setNewValue((prev) => ({ ...prev, [type]: value }));
|
setNewValue((prev) => ({ ...prev, [type]: value }));
|
||||||
@@ -375,13 +397,18 @@ export default function ConfigurationContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteUser = async (userId: string) => {
|
const handleDeleteUser = (userId: string) => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')) {
|
setConfirmDeleteUserModal({
|
||||||
return;
|
show: true,
|
||||||
}
|
userId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteUser = async () => {
|
||||||
|
if (!confirmDeleteUserModal?.userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/users/${userId}`, {
|
const response = await fetch(`/api/users/${confirmDeleteUserModal.userId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -599,6 +626,20 @@ export default function ConfigurationContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal de confirmation de suppression utilisateur */}
|
||||||
|
{confirmDeleteUserModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteUserModal.show}
|
||||||
|
title="Supprimer l'utilisateur"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible."
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDeleteUser}
|
||||||
|
onCancel={() => setConfirmDeleteUserModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -800,13 +841,18 @@ export default function ConfigurationContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRole = async (roleId: string) => {
|
const handleDeleteRole = (roleId: string) => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce rôle ?')) {
|
setConfirmDeleteRoleModal({
|
||||||
return;
|
show: true,
|
||||||
}
|
roleId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteRole = async () => {
|
||||||
|
if (!confirmDeleteRoleModal?.roleId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/roles/${roleId}`, {
|
const response = await fetch(`/api/roles/${confirmDeleteRoleModal.roleId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -820,6 +866,8 @@ export default function ConfigurationContent() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
showNotification('Erreur lors de la suppression', 'error');
|
showNotification('Erreur lors de la suppression', 'error');
|
||||||
|
} finally {
|
||||||
|
setConfirmDeleteRoleModal(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1023,6 +1071,19 @@ export default function ConfigurationContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal de confirmation de suppression rôle */}
|
||||||
|
{confirmDeleteRoleModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteRoleModal.show}
|
||||||
|
title="Supprimer le rôle"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer ce rôle ?"
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDeleteRole}
|
||||||
|
onCancel={() => setConfirmDeleteRoleModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1216,6 +1277,46 @@ export default function ConfigurationContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modales de confirmation */}
|
||||||
|
{confirmDeleteOptionModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteOptionModal.show}
|
||||||
|
title="Supprimer l'option"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer cette option ?"
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDeleteOption}
|
||||||
|
onCancel={() => setConfirmDeleteOptionModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDeleteUserModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteUserModal.show}
|
||||||
|
title="Supprimer l'utilisateur"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible."
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDeleteUser}
|
||||||
|
onCancel={() => setConfirmDeleteUserModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDeleteRoleModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteRoleModal.show}
|
||||||
|
title="Supprimer le rôle"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer ce rôle ?"
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDeleteRole}
|
||||||
|
onCancel={() => setConfirmDeleteRoleModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,14 @@ export default function ConfirmModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
<div
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full animate-slideUp border border-gray-200">
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg shadow-xl max-w-md w-full animate-slideUp border border-gray-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-gray-200 px-6 py-5 bg-white">
|
<div className="border-b border-gray-200 px-6 py-5 bg-white">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import AlertModal from './AlertModal';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,6 +43,16 @@ export default function GroupSettingsModal({
|
|||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [alertModal, setAlertModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmModal, setConfirmModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
userId: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const { data: users, error } = useSWR<User[]>(
|
const { data: users, error } = useSWR<User[]>(
|
||||||
search ? `/api/users?search=${encodeURIComponent(search)}` : null,
|
search ? `/api/users?search=${encodeURIComponent(search)}` : null,
|
||||||
@@ -51,7 +63,12 @@ export default function GroupSettingsModal({
|
|||||||
|
|
||||||
const handleUpdateName = async () => {
|
const handleUpdateName = async () => {
|
||||||
if (!groupName.trim()) {
|
if (!groupName.trim()) {
|
||||||
alert('Le nom du groupe ne peut pas être vide');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Nom requis',
|
||||||
|
message: 'Le nom du groupe ne peut pas être vide',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +87,21 @@ export default function GroupSettingsModal({
|
|||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Erreur lors de la mise à jour');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: error.error || 'Erreur lors de la mise à jour',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
alert('Erreur lors de la mise à jour');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de la mise à jour',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
}
|
}
|
||||||
@@ -82,7 +109,12 @@ export default function GroupSettingsModal({
|
|||||||
|
|
||||||
const handleAddParticipants = async () => {
|
const handleAddParticipants = async () => {
|
||||||
if (selectedUsers.length === 0) {
|
if (selectedUsers.length === 0) {
|
||||||
alert('Veuillez sélectionner au moins un utilisateur');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Sélection requise',
|
||||||
|
message: 'Veuillez sélectionner au moins un utilisateur',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,24 +134,39 @@ export default function GroupSettingsModal({
|
|||||||
onUpdate();
|
onUpdate();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Erreur lors de l\'ajout des participants');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: error.error || 'Erreur lors de l\'ajout des participants',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
alert('Erreur lors de l\'ajout des participants');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de l\'ajout des participants',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveParticipant = async (userId: string) => {
|
const handleRemoveParticipant = async (userId: string) => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir retirer cet utilisateur du groupe ?')) {
|
setConfirmModal({
|
||||||
return;
|
show: true,
|
||||||
}
|
userId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRemoveParticipant = async () => {
|
||||||
|
if (!confirmModal?.userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/conversations/${conversation.id}/participants?userId=${userId}`,
|
`/api/conversations/${conversation.id}/participants?userId=${confirmModal.userId}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}
|
}
|
||||||
@@ -127,13 +174,26 @@ export default function GroupSettingsModal({
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
|
setConfirmModal(null);
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Erreur lors du retrait du participant');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: error.error || 'Erreur lors du retrait du participant',
|
||||||
|
});
|
||||||
|
setConfirmModal(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
alert('Erreur lors du retrait du participant');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors du retrait du participant',
|
||||||
|
});
|
||||||
|
setConfirmModal(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -295,6 +355,31 @@ export default function GroupSettingsModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal d'alerte */}
|
||||||
|
{alertModal && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.show}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
onClose={() => setAlertModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de confirmation */}
|
||||||
|
{confirmModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmModal.show}
|
||||||
|
title="Retirer un participant"
|
||||||
|
message="Êtes-vous sûr de vouloir retirer cet utilisateur du groupe ?"
|
||||||
|
confirmText="Retirer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmRemoveParticipant}
|
||||||
|
onCancel={() => setConfirmModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import AlertModal from './AlertModal';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +26,12 @@ export default function NewConversationModal({
|
|||||||
const [conversationType, setConversationType] = useState<'direct' | 'group'>('direct');
|
const [conversationType, setConversationType] = useState<'direct' | 'group'>('direct');
|
||||||
const [groupName, setGroupName] = useState('');
|
const [groupName, setGroupName] = useState('');
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [alertModal, setAlertModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Récupérer l'utilisateur actuel
|
// Récupérer l'utilisateur actuel
|
||||||
const { data: currentUser } = useSWR<User>('/api/auth/me', fetcher);
|
const { data: currentUser } = useSWR<User>('/api/auth/me', fetcher);
|
||||||
@@ -47,12 +54,22 @@ export default function NewConversationModal({
|
|||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (selectedUsers.length === 0) {
|
if (selectedUsers.length === 0) {
|
||||||
alert('Veuillez sélectionner au moins un utilisateur');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Sélection requise',
|
||||||
|
message: 'Veuillez sélectionner au moins un utilisateur',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conversationType === 'group' && !groupName.trim()) {
|
if (conversationType === 'group' && !groupName.trim()) {
|
||||||
alert('Veuillez entrer un nom pour le groupe');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Nom requis',
|
||||||
|
message: 'Veuillez entrer un nom pour le groupe',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +92,21 @@ export default function NewConversationModal({
|
|||||||
onConversationCreated(conversation.id);
|
onConversationCreated(conversation.id);
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Erreur lors de la création de la conversation');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: error.error || 'Erreur lors de la création de la conversation',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
alert('Erreur lors de la création de la conversation');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de la création de la conversation',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
@@ -234,6 +261,17 @@ export default function NewConversationModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal d'alerte */}
|
||||||
|
{alertModal && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.show}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
onClose={() => setAlertModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useNotification } from './NotificationProvider';
|
import { useNotification } from './NotificationProvider';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,6 +50,10 @@ export default function ParametresContent() {
|
|||||||
prescripteur: '',
|
prescripteur: '',
|
||||||
facturation: '',
|
facturation: '',
|
||||||
});
|
});
|
||||||
|
const [confirmDeleteModal, setConfirmDeleteModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
id: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchOptions();
|
fetchOptions();
|
||||||
@@ -148,13 +153,18 @@ export default function ParametresContent() {
|
|||||||
setEditingValue('');
|
setEditingValue('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cette option ?')) {
|
setConfirmDeleteModal({
|
||||||
return;
|
show: true,
|
||||||
}
|
id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!confirmDeleteModal?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/settings/adherent-options/${id}`, {
|
const response = await fetch(`/api/settings/adherent-options/${confirmDeleteModal.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,6 +178,8 @@ export default function ParametresContent() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
showNotification('Erreur lors de la suppression', 'error');
|
showNotification('Erreur lors de la suppression', 'error');
|
||||||
|
} finally {
|
||||||
|
setConfirmDeleteModal(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -366,6 +378,20 @@ export default function ParametresContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de confirmation de suppression */}
|
||||||
|
{confirmDeleteModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteModal.show}
|
||||||
|
title="Supprimer l'option"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer cette option ?"
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setConfirmDeleteModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import AlertModal from './AlertModal';
|
||||||
|
|
||||||
interface UniversPro {
|
interface UniversPro {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +20,12 @@ interface UniversProFormProps {
|
|||||||
|
|
||||||
export default function UniversProForm({ contact, onClose }: UniversProFormProps) {
|
export default function UniversProForm({ contact, onClose }: UniversProFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [alertModal, setAlertModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
nom: '',
|
nom: '',
|
||||||
prenom: '',
|
prenom: '',
|
||||||
@@ -61,11 +68,21 @@ export default function UniversProForm({ contact, onClose }: UniversProFormProps
|
|||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Une erreur est survenue');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: error.error || 'Une erreur est survenue',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur:', error);
|
console.error('Erreur:', error);
|
||||||
alert('Une erreur est survenue');
|
setAlertModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Une erreur est survenue',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -241,6 +258,17 @@ export default function UniversProForm({ contact, onClose }: UniversProFormProps
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal d'alerte */}
|
||||||
|
{alertModal && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.show}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
onClose={() => setAlertModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import UniversProForm from './UniversProForm';
|
import UniversProForm from './UniversProForm';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
interface UniversPro {
|
interface UniversPro {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +21,19 @@ export default function UniversProTable() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingContact, setEditingContact] = useState<UniversPro | null>(null);
|
const [editingContact, setEditingContact] = useState<UniversPro | null>(null);
|
||||||
const [viewingContact, setViewingContact] = useState<UniversPro | null>(null);
|
const [viewingContact, setViewingContact] = useState<UniversPro | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [resultModal, setResultModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
type: 'success' | 'error' | 'info';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
details?: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const [confirmDeleteModal, setConfirmDeleteModal] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
id: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const fetchContacts = async (searchTerm: string = '') => {
|
const fetchContacts = async (searchTerm: string = '') => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -52,23 +66,46 @@ export default function UniversProTable() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce contact ?')) {
|
setConfirmDeleteModal({
|
||||||
return;
|
show: true,
|
||||||
}
|
id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!confirmDeleteModal?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/univers-pro/${id}`, {
|
const response = await fetch(`/api/univers-pro/${confirmDeleteModal.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
fetchContacts(search);
|
fetchContacts(search);
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'success',
|
||||||
|
title: 'Suppression réussie',
|
||||||
|
message: 'Le contact a été supprimé avec succès',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
alert('Erreur lors de la suppression');
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de la suppression',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la suppression:', error);
|
console.error('Erreur lors de la suppression:', error);
|
||||||
alert('Erreur lors de la suppression');
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Erreur lors de la suppression',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setConfirmDeleteModal(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,6 +136,331 @@ export default function UniversProTable() {
|
|||||||
return `${prenom.charAt(0)}${nom.charAt(0)}`.toUpperCase();
|
return `${prenom.charAt(0)}${nom.charAt(0)}`.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(new Set(contacts.map(c => c.id)));
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectOne = (id: string, checked: boolean) => {
|
||||||
|
const newSelected = new Set(selectedIds);
|
||||||
|
if (checked) {
|
||||||
|
newSelected.add(id);
|
||||||
|
} else {
|
||||||
|
newSelected.delete(id);
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'info',
|
||||||
|
title: 'Aucune sélection',
|
||||||
|
message: 'Veuillez sélectionner au moins un contact à exporter',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedContacts = contacts.filter(c => selectedIds.has(c.id));
|
||||||
|
|
||||||
|
// Créer les en-têtes CSV avec tous les champs disponibles
|
||||||
|
const headers = [
|
||||||
|
'Nom',
|
||||||
|
'Prénom',
|
||||||
|
'Téléphone',
|
||||||
|
'Email',
|
||||||
|
'Adresse',
|
||||||
|
'Nom entreprise'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Créer les lignes CSV
|
||||||
|
const rows = selectedContacts.map(contact => {
|
||||||
|
return [
|
||||||
|
contact.nom || '',
|
||||||
|
contact.prenom || '',
|
||||||
|
contact.telephone || '',
|
||||||
|
contact.email || '',
|
||||||
|
contact.adresse || '',
|
||||||
|
contact.nomEntreprise || ''
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer le contenu CSV
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Créer le blob et télécharger
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }); // BOM pour Excel
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `contacts_pro_export_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Réinitialiser la sélection
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTemplate = () => {
|
||||||
|
const headers = [
|
||||||
|
'Nom',
|
||||||
|
'Prénom',
|
||||||
|
'Téléphone',
|
||||||
|
'Email',
|
||||||
|
'Adresse',
|
||||||
|
'Nom entreprise'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ligne d'exemple
|
||||||
|
const exampleRow = [
|
||||||
|
'Dupont',
|
||||||
|
'Jean',
|
||||||
|
'0123456789',
|
||||||
|
'jean.dupont@example.com',
|
||||||
|
'123 Rue de la Paix, 75001 Paris',
|
||||||
|
'BNP Paribas'
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
exampleRow.map(cell => `"${cell}"`).join(',')
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', 'modele_import_contacts_pro.csv');
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fichier invalide',
|
||||||
|
message: 'Le fichier CSV doit contenir au moins une ligne d\'en-tête et une ligne de données',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détecter le séparateur (virgule ou point-virgule)
|
||||||
|
const detectSeparator = (firstLine: string): string => {
|
||||||
|
const commaCount = (firstLine.match(/,/g) || []).length;
|
||||||
|
const semicolonCount = (firstLine.match(/;/g) || []).length;
|
||||||
|
return semicolonCount >= commaCount ? ';' : ',';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parser le CSV (gestion simple des guillemets avec détection automatique du séparateur)
|
||||||
|
const parseCSVLine = (line: string, separator: string): string[] => {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === separator && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour normaliser les noms de colonnes
|
||||||
|
const normalizeHeader = (header: string): string => {
|
||||||
|
return header
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour trouver une colonne avec plusieurs variantes possibles
|
||||||
|
const findColumnIndex = (patterns: string[], excludePatterns: string[] = []): number => {
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const normalized = normalizeHeader(headers[i]);
|
||||||
|
const matchesPattern = patterns.some(pattern => normalized.includes(pattern));
|
||||||
|
const matchesExclude = excludePatterns.some(pattern => normalized.includes(pattern));
|
||||||
|
|
||||||
|
if (matchesPattern && !matchesExclude) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Détecter le séparateur depuis la première ligne
|
||||||
|
const separator = detectSeparator(lines[0]);
|
||||||
|
|
||||||
|
const headers = parseCSVLine(lines[0], separator);
|
||||||
|
const dataLines = lines.slice(1);
|
||||||
|
|
||||||
|
// Mapping des colonnes avec leurs noms d'affichage
|
||||||
|
const nomIndex = findColumnIndex(['nom'], ['prenom', 'prénom']);
|
||||||
|
const nomHeader = nomIndex >= 0 ? headers[nomIndex] : 'Nom';
|
||||||
|
const prenomIndex = findColumnIndex(['prenom', 'prénom']);
|
||||||
|
const prenomHeader = prenomIndex >= 0 ? headers[prenomIndex] : 'Prénom';
|
||||||
|
const telephoneIndex = findColumnIndex(['telephone', 'téléphone', 'tel', 'phone']);
|
||||||
|
const telephoneHeader = telephoneIndex >= 0 ? headers[telephoneIndex] : 'Téléphone';
|
||||||
|
const emailIndex = findColumnIndex(['email', 'mail', 'courriel']);
|
||||||
|
const emailHeader = emailIndex >= 0 ? headers[emailIndex] : 'Email';
|
||||||
|
const adresseIndex = findColumnIndex(['adresse']);
|
||||||
|
const adresseHeader = adresseIndex >= 0 ? headers[adresseIndex] : 'Adresse';
|
||||||
|
const nomEntrepriseIndex = findColumnIndex(['entreprise', 'company', 'societe', 'société']);
|
||||||
|
const nomEntrepriseHeader = nomEntrepriseIndex >= 0 ? headers[nomEntrepriseIndex] : 'Nom entreprise';
|
||||||
|
|
||||||
|
// Vérifier que les colonnes obligatoires sont présentes
|
||||||
|
const missingRequiredColumns: string[] = [];
|
||||||
|
if (nomIndex === -1) missingRequiredColumns.push('Nom');
|
||||||
|
if (prenomIndex === -1) missingRequiredColumns.push('Prénom');
|
||||||
|
if (telephoneIndex === -1) missingRequiredColumns.push('Téléphone');
|
||||||
|
if (emailIndex === -1) missingRequiredColumns.push('Email');
|
||||||
|
if (adresseIndex === -1) missingRequiredColumns.push('Adresse');
|
||||||
|
if (nomEntrepriseIndex === -1) missingRequiredColumns.push('Nom entreprise');
|
||||||
|
|
||||||
|
if (missingRequiredColumns.length > 0) {
|
||||||
|
const availableColumns = headers.length > 0 ? headers : ['Aucune colonne détectée'];
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: 'error',
|
||||||
|
title: 'Colonnes manquantes',
|
||||||
|
message: `Le fichier CSV ne contient pas les colonnes obligatoires suivantes : ${missingRequiredColumns.join(', ')}`,
|
||||||
|
details: [
|
||||||
|
'Colonnes détectées dans le fichier :',
|
||||||
|
...availableColumns.map(col => ` • ${col}`),
|
||||||
|
'',
|
||||||
|
'Conseil : Vérifiez que les noms de colonnes correspondent exactement au modèle (les accents et la casse sont importants).'
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < dataLines.length; i++) {
|
||||||
|
const row = parseCSVLine(dataLines[i], separator);
|
||||||
|
if (row.length === 0) continue;
|
||||||
|
|
||||||
|
const nom = nomIndex >= 0 ? (row[nomIndex] || '').trim() : '';
|
||||||
|
const prenom = prenomIndex >= 0 ? (row[prenomIndex] || '').trim() : '';
|
||||||
|
const telephone = telephoneIndex >= 0 ? (row[telephoneIndex] || '').trim() : '';
|
||||||
|
const email = emailIndex >= 0 ? (row[emailIndex] || '').trim() : '';
|
||||||
|
const adresse = adresseIndex >= 0 ? (row[adresseIndex] || '').trim() : '';
|
||||||
|
const nomEntreprise = nomEntrepriseIndex >= 0 ? (row[nomEntrepriseIndex] || '').trim() : '';
|
||||||
|
|
||||||
|
// Validation des champs obligatoires avec identification précise des colonnes manquantes
|
||||||
|
const missingFields: string[] = [];
|
||||||
|
if (!nom) missingFields.push(nomHeader);
|
||||||
|
if (!prenom) missingFields.push(prenomHeader);
|
||||||
|
if (!telephone) missingFields.push(telephoneHeader);
|
||||||
|
if (!email) missingFields.push(emailHeader);
|
||||||
|
if (!adresse) missingFields.push(adresseHeader);
|
||||||
|
if (!nomEntreprise) missingFields.push(nomEntrepriseHeader);
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
errorCount++;
|
||||||
|
const fieldsList = missingFields.join(', ');
|
||||||
|
errors.push(`Ligne ${i + 2}: Colonnes manquantes ou vides : ${fieldsList}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/univers-pro', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
nom,
|
||||||
|
prenom,
|
||||||
|
telephone,
|
||||||
|
email,
|
||||||
|
adresse,
|
||||||
|
nomEntreprise,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
let errorMsg = errorData.error || 'Erreur lors de l\'import';
|
||||||
|
if (errorMsg.includes('champs obligatoires')) {
|
||||||
|
errorMsg = `Erreur de validation : ${errorMsg}`;
|
||||||
|
}
|
||||||
|
errors.push(`Ligne ${i + 2}: ${errorMsg}`);
|
||||||
|
} catch {
|
||||||
|
errors.push(`Ligne ${i + 2}: Erreur serveur lors de l'import`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
errors.push(`Ligne ${i + 2}: Erreur réseau - ${error instanceof Error ? error.message : 'Connexion impossible'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher les résultats dans une modale
|
||||||
|
const title = errorCount > 0
|
||||||
|
? `Import terminé avec ${errorCount} erreur(s)`
|
||||||
|
: 'Import réussi';
|
||||||
|
|
||||||
|
const message = errorCount === 0
|
||||||
|
? `${successCount} contact(s) importé(s) avec succès`
|
||||||
|
: `${successCount} contact(s) importé(s) avec succès, ${errorCount} erreur(s)`;
|
||||||
|
|
||||||
|
setResultModal({
|
||||||
|
show: true,
|
||||||
|
type: errorCount === 0 ? 'success' : 'error',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
details: errors.length > 0 ? errors : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rafraîchir la liste
|
||||||
|
fetchContacts(search);
|
||||||
|
setShowImportModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Barre de recherche et actions */}
|
{/* Barre de recherche et actions */}
|
||||||
@@ -136,17 +498,24 @@ export default function UniversProTable() {
|
|||||||
</svg>
|
</svg>
|
||||||
Nouveau contact
|
Nouveau contact
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors">
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Importer
|
Importer
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-lorange text-white rounded-lg hover:bg-dorange transition-colors">
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-lorange text-white rounded-lg hover:bg-dorange transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4-4m0 0l-4-4m4 4V4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4-4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Exporter
|
Exporter {selectedIds.size > 0 && `(${selectedIds.size})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,6 +532,14 @@ export default function UniversProTable() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={contacts.length > 0 && selectedIds.size === contacts.length}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">NOM</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">NOM</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CONTACT</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CONTACT</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ADRESSE</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ADRESSE</th>
|
||||||
@@ -173,6 +550,14 @@ export default function UniversProTable() {
|
|||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{contacts.map((contact) => (
|
{contacts.map((contact) => (
|
||||||
<tr key={contact.id} className="hover:bg-gray-50">
|
<tr key={contact.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(contact.id)}
|
||||||
|
onChange={(e) => handleSelectOne(contact.id, e.target.checked)}
|
||||||
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-lblue flex items-center justify-center text-white font-semibold">
|
<div className="w-10 h-10 rounded-full bg-lblue flex items-center justify-center text-white font-semibold">
|
||||||
@@ -243,6 +628,240 @@ export default function UniversProTable() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal import */}
|
||||||
|
{showImportModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full animate-slideUp border border-gray-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 px-6 py-5 bg-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
Importer des contacts
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Téléchargez le modèle d'exemple, remplissez-le et importez-le ici
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1.5 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Télécharger le modèle */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-blue-900 mb-1">
|
||||||
|
Télécharger le modèle d'exemple
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-700 mb-3">
|
||||||
|
Téléchargez le fichier CSV modèle pour voir le format attendu et remplir vos données.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Télécharger le modèle CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload fichier */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Sélectionner le fichier CSV à importer
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:border-lblue transition-colors">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||||
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex text-sm text-gray-600">
|
||||||
|
<label htmlFor="file-upload-pro" className="relative cursor-pointer bg-white rounded-md font-medium text-lblue hover:text-dblue focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-lblue">
|
||||||
|
<span>Cliquez pour sélectionner un fichier</span>
|
||||||
|
<input
|
||||||
|
id="file-upload-pro"
|
||||||
|
name="file-upload-pro"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="pl-1">ou glissez-déposez</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">CSV jusqu'à 10MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
|
Instructions :
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1 list-disc list-inside">
|
||||||
|
<li>Tous les champs sont obligatoires : Nom, Prénom, Téléphone, Email, Adresse, Nom entreprise</li>
|
||||||
|
<li>Assurez-vous que le fichier utilise l'encodage UTF-8</li>
|
||||||
|
<li>Le séparateur peut être une virgule (,) ou un point-virgule (;)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50/50">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal résultat */}
|
||||||
|
{resultModal && resultModal.show && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[60] p-4 animate-fadeIn"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg shadow-xl max-w-lg w-full animate-slideUp border border-gray-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`border-b border-gray-200 px-6 py-5 ${
|
||||||
|
resultModal.type === 'success' ? 'bg-green-50' :
|
||||||
|
resultModal.type === 'error' ? 'bg-red-50' :
|
||||||
|
'bg-blue-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{resultModal.type === 'success' && (
|
||||||
|
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{resultModal.type === 'error' && (
|
||||||
|
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{resultModal.type === 'info' && (
|
||||||
|
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<h2 className={`text-xl font-semibold ${
|
||||||
|
resultModal.type === 'success' ? 'text-green-900' :
|
||||||
|
resultModal.type === 'error' ? 'text-red-900' :
|
||||||
|
'text-blue-900'
|
||||||
|
}`}>
|
||||||
|
{resultModal.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1.5 hover:bg-white/50 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<p className={`text-sm font-medium mb-4 ${
|
||||||
|
resultModal.type === 'success' ? 'text-green-800' :
|
||||||
|
resultModal.type === 'error' ? 'text-red-800' :
|
||||||
|
'text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{resultModal.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{resultModal.details && resultModal.details.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||||
|
{resultModal.details[0].includes('Colonnes détectées') ? 'Détails :' : 'Erreurs :'}
|
||||||
|
</h3>
|
||||||
|
<div className={`border rounded-lg p-4 max-h-64 overflow-y-auto ${
|
||||||
|
resultModal.type === 'error' ? 'bg-red-50 border-red-200' : 'bg-gray-50 border-gray-200'
|
||||||
|
}`}>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{resultModal.details.map((detail, index) => (
|
||||||
|
<li key={index} className={`text-sm ${
|
||||||
|
resultModal.type === 'error' ? 'text-red-800' : 'text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{detail}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50/50">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResultModal(null)}
|
||||||
|
className={`px-6 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
resultModal.type === 'success'
|
||||||
|
? 'bg-green-600 text-white hover:bg-green-700' :
|
||||||
|
resultModal.type === 'error'
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700' :
|
||||||
|
'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de confirmation de suppression */}
|
||||||
|
{confirmDeleteModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmDeleteModal.show}
|
||||||
|
title="Supprimer le contact"
|
||||||
|
message="Êtes-vous sûr de vouloir supprimer ce contact ?"
|
||||||
|
confirmText="Supprimer"
|
||||||
|
cancelText="Annuler"
|
||||||
|
confirmColor="danger"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setConfirmDeleteModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modal vue détaillée */}
|
{/* Modal vue détaillée */}
|
||||||
{viewingContact && (
|
{viewingContact && (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn">
|
||||||
|
|||||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
Reference in New Issue
Block a user