diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 6cc4ce6..1b7bbde 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,9 +1,30 @@ import { Router, Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; +import multer from 'multer'; +import * as XLSX from 'xlsx'; import { getPool } from '../config/database'; import { authenticate, AuthRequest, authorize } from '../middleware/auth'; +// Multer en mémoire pour l'import CSV/XLSX +const uploadMemory = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 Mo max + fileFilter: (_req, file, cb) => { + const allowed = [ + 'text/csv', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'text/plain', + ]; + if (allowed.includes(file.mimetype) || file.originalname.match(/\.(csv|xlsx|xls)$/i)) { + cb(null, true); + } else { + cb(new Error('Format non supporté. Utilisez .csv ou .xlsx')); + } + }, +}); + const router = Router(); // POST /api/auth/login @@ -195,4 +216,101 @@ router.delete('/users/:id', authenticate, authorize('admin'), async (req: AuthRe } }); +// GET /api/auth/users/import-template - Télécharger le modèle CSV +router.get('/users/import-template', authenticate, authorize('admin'), (_req: AuthRequest, res: Response) => { + const csvContent = 'email,prenom,nom,role\nadmin@exemple.com,Jean,Dupont,admin\nutilisateur@exemple.com,Marie,Martin,operateur\n'; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="modele_import_utilisateurs.csv"'); + res.send('\uFEFF' + csvContent); // BOM UTF-8 pour Excel +}); + +// POST /api/auth/users/import - Import CSV/Excel utilisateurs (admin) +router.post('/users/import', authenticate, authorize('admin'), uploadMemory.single('file'), async (req: AuthRequest, res: Response) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'Aucun fichier fourni' }); + } + + const pool = getPool(); + const validRoles = ['admin', 'approbateur', 'validateur', 'operateur']; + const results = { created: 0, updated: 0, errors: [] as string[] }; + + // Parsing CSV ou Excel + let rows: any[][] = []; + const ext = req.file.originalname.toLowerCase(); + + if (ext.endsWith('.csv') || req.file.mimetype === 'text/csv' || req.file.mimetype === 'text/plain') { + // Parsing CSV + const text = req.file.buffer.toString('utf-8').replace(/^\uFEFF/, ''); // supprimer BOM + const lines = text.split(/\r?\n/).filter((l) => l.trim()); + rows = lines.map((line) => line.split(/[,;]/)); + } else { + // Parsing Excel + const workbook = XLSX.read(req.file.buffer, { type: 'buffer' }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) as any[][]; + } + + if (rows.length < 2) { + return res.status(400).json({ error: 'Le fichier est vide ou ne contient que l\'en-tête' }); + } + + // Ignorer la ligne d'en-tête + const dataRows = rows.slice(1); + + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const lineNum = i + 2; + + const email = String(row[0] || '').trim().toLowerCase(); + const prenom = String(row[1] || '').trim(); + const nom = String(row[2] || '').trim(); + const role = String(row[3] || 'operateur').trim().toLowerCase(); + + // Validation + if (!email || !email.includes('@')) { + results.errors.push(`Ligne ${lineNum} : email invalide ("${email}")`); + continue; + } + if (!prenom || !nom) { + results.errors.push(`Ligne ${lineNum} : prénom ou nom manquant`); + continue; + } + const finalRole = validRoles.includes(role) ? role : 'operateur'; + + // Vérifier si l'utilisateur existe + const [existing]: any = await pool.execute( + 'SELECT id FROM users WHERE email = ?', + [email] + ); + + if (existing.length > 0) { + // Mise à jour + await pool.execute( + 'UPDATE users SET first_name = ?, last_name = ?, role = ?, is_active = TRUE WHERE email = ?', + [prenom, nom, finalRole, email] + ); + results.updated++; + } else { + // Création avec mot de passe temporaire + const tempPassword = Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-4).toUpperCase() + '!'; + const hashedPassword = await bcrypt.hash(tempPassword, 12); + await pool.execute( + 'INSERT INTO users (email, password, first_name, last_name, role, is_active) VALUES (?, ?, ?, ?, ?, TRUE)', + [email, hashedPassword, prenom, nom, finalRole] + ); + results.created++; + } + } + + res.json({ + message: `Import terminé : ${results.created} créé(s), ${results.updated} mis à jour, ${results.errors.length} erreur(s)`, + ...results, + }); + } catch (error: any) { + console.error('Erreur import users:', error); + res.status(500).json({ error: error.message || 'Erreur serveur lors de l\'import' }); + } +}); + export default router; diff --git a/frontend/public/logo-itinova.jpg b/frontend/public/logo-itinova.jpg new file mode 100644 index 0000000..c0b35de Binary files /dev/null and b/frontend/public/logo-itinova.jpg differ diff --git a/frontend/public/logo-santinova.webp b/frontend/public/logo-santinova.webp new file mode 100644 index 0000000..98127dc Binary files /dev/null and b/frontend/public/logo-santinova.webp differ diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 54cb98a..ff16682 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -25,69 +25,115 @@ export default function Login() { }; return ( -
-
-
-
- S +
+ {/* Panneau gauche — fond dégradé SANTINOVA */} +
+ {/* Cercles décoratifs */} +
+
+ +
+
+ F
-

SANTINOVA

-

Gestion de Facturation

+

Facturation

+

Gestion intelligente de vos factures fournisseurs

+ +
+ {[ + { icon: '📄', label: 'Réception multi-canal', desc: 'Upload PDF, email, portail' }, + { icon: '🤖', label: 'Extraction IA', desc: 'OCR automatique par intelligence artificielle' }, + { icon: '✅', label: 'Workflow de validation', desc: 'Suivi complet du cycle de vie' }, + ].map((item) => ( +
+ {item.icon} +
+

{item.label}

+

{item.desc}

+
+
+ ))} +
+
+
+ + {/* Panneau droit — formulaire + logos Itinova / Santinova */} +
+ + {/* Logo Itinova en haut (skill : haut de page) */} +
+ Itinova
-
-

Connexion

- -
-
- - setEmail(e.target.value)} - className="input-field" - placeholder="votre@email.com" - required - autoFocus - /> -
+ {/* Formulaire centré */} +
+
+

Connexion

+

Accédez à votre espace de facturation

+
-
- - setPassword(e.target.value)} - className="input-field" - placeholder="••••••••" - required - /> -
+
+ +
+ + setEmail(e.target.value)} + className="input-field" + placeholder="votre@email.com" + required + autoFocus + /> +
- - +
+ + setPassword(e.target.value)} + className="input-field" + placeholder="••••••••" + required + /> +
+ + + +
-

- Facturation SANTINOVA v1.0 -

+ {/* Logo Santinova "powered by" en bas (skill : bas de page) */} +
+ powered by + Santinova Soft +
); diff --git a/frontend/src/pages/UserList.tsx b/frontend/src/pages/UserList.tsx index 7b2682b..2b7fb31 100644 --- a/frontend/src/pages/UserList.tsx +++ b/frontend/src/pages/UserList.tsx @@ -1,21 +1,32 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { authAPI } from '../services/api'; import { User } from '../types'; import { roleLabels } from '../utils/helpers'; import toast from 'react-hot-toast'; +interface ImportResult { + message: string; + created: number; + updated: number; + errors: string[]; +} + export default function UserList() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [showModal, setShowModal] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); const [editingUser, setEditingUser] = useState(null); + const [importResult, setImportResult] = useState(null); + const [importing, setImporting] = useState(false); + const [importFile, setImportFile] = useState(null); + const fileInputRef = useRef(null); + const [form, setForm] = useState({ email: '', password: '', firstName: '', lastName: '', role: 'operateur', }); - useEffect(() => { - loadUsers(); - }, []); + useEffect(() => { loadUsers(); }, []); const loadUsers = async () => { try { @@ -36,34 +47,18 @@ export default function UserList() { const openEdit = (user: User) => { setEditingUser(user); - setForm({ - email: user.email, - password: '', - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - }); + setForm({ email: user.email, password: '', firstName: user.firstName, lastName: user.lastName, role: user.role }); setShowModal(true); }; const handleSave = async () => { - if (!form.email || !form.firstName || !form.lastName) { - toast.error('Tous les champs sont requis'); - return; - } - if (!editingUser && !form.password) { - toast.error('Le mot de passe est requis'); - return; - } + if (!form.email || !form.firstName || !form.lastName) { toast.error('Tous les champs sont requis'); return; } + if (!editingUser && !form.password) { toast.error('Le mot de passe est requis'); return; } try { if (editingUser) { await authAPI.updateUser(editingUser.id, { - email: form.email, - firstName: form.firstName, - lastName: form.lastName, - role: form.role, - isActive: true, - password: form.password || undefined, + email: form.email, firstName: form.firstName, lastName: form.lastName, + role: form.role, isActive: true, password: form.password || undefined, }); toast.success('Utilisateur mis à jour'); } else { @@ -83,21 +78,61 @@ export default function UserList() { await authAPI.deleteUser(id); toast.success('Utilisateur désactivé'); loadUsers(); - } catch (error) { - toast.error('Erreur'); - } + } catch { toast.error('Erreur'); } + }; + + const handleDownloadTemplate = async () => { + try { + const res = await authAPI.getUserImportTemplate(); + const blob = new Blob([res.data], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'modele_import_utilisateurs.csv'; a.click(); + URL.revokeObjectURL(url); + } catch { toast.error('Impossible de télécharger le modèle'); } + }; + + const handleImportFileChange = (e: React.ChangeEvent) => { + setImportFile(e.target.files?.[0] || null); + setImportResult(null); + }; + + const handleImport = async () => { + if (!importFile) { toast.error('Veuillez sélectionner un fichier'); return; } + setImporting(true); setImportResult(null); + try { + const formData = new FormData(); + formData.append('file', importFile); + const res = await authAPI.importUsers(formData); + setImportResult(res.data); + toast.success(res.data.message); + loadUsers(); + } catch (error: any) { + toast.error(error.response?.data?.error || "Erreur lors de l'import"); + } finally { setImporting(false); } }; return (
-
+

Gestion des utilisateurs

- +
+ + +
@@ -107,7 +142,7 @@ export default function UserList() { Nom Email - Rôle + Role Statut Actions @@ -115,6 +150,8 @@ export default function UserList() { {loading ? ( Chargement... + ) : users.length === 0 ? ( + Aucun utilisateur ) : users.map((u) => ( @@ -145,12 +182,12 @@ export default function UserList() {
- {/* Modal */} + {/* Modale Créer / Modifier */} {showModal && (
-

{editingUser ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur'}

+

{editingUser ? "Modifier l'utilisateur" : 'Nouvel utilisateur'}

@@ -174,11 +211,9 @@ export default function UserList() { setForm({ ...form, password: e.target.value })} className="input-field" />
- +
@@ -189,6 +224,105 @@ export default function UserList() {
)} + + {/* Modale Import CSV / Excel */} + {showImportModal && ( +
+
+
+

Importer des utilisateurs

+ +
+
+
+

Format attendu (colonnes) :

+ email, prenom, nom, role +

+ Roles : admin, approbateur, validateur, operateur (défaut si vide). +

+

+ Utilisateurs existants (même email) : mis à jour. Nouveaux : mot de passe temporaire généré. +

+
+ + + +
+ +
fileInputRef.current?.click()} + > + {importFile ? ( +
+ + + + {importFile.name} + ({(importFile.size / 1024).toFixed(1)} Ko) +
+ ) : ( +
+ + + +

Cliquez pour sélectionner un fichier

+

CSV ou Excel (.xlsx)

+
+ )} +
+ +
+ + {importResult && ( +
0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'}`}> +

{importResult.message}

+
+ OK {importResult.created} créé(s) + MAJ {importResult.updated} mis à jour + {importResult.errors.length > 0 && ERR {importResult.errors.length} erreur(s)} +
+ {importResult.errors.length > 0 && ( +
    + {importResult.errors.map((err, i) =>
  • - {err}
  • )} +
+ )} +
+ )} +
+
+ + +
+
+
+ )}
); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 99ebb44..f76f6e1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -43,6 +43,11 @@ export const authAPI = { createUser: (data: any) => api.post('/auth/users', data), updateUser: (id: number, data: any) => api.put(`/auth/users/${id}`, data), deleteUser: (id: number) => api.delete(`/auth/users/${id}`), + // Import CSV/Excel utilisateurs (skill itinova-user-management) + getUserImportTemplate: () => api.get('/auth/users/import-template', { responseType: 'blob' }), + importUsers: (formData: FormData) => api.post('/auth/users/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), }; // Invoices