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) */}
+
+
-
);
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
-
-
-
-
- Nouvel utilisateur
-
+
+
{ setImportFile(null); setImportResult(null); setShowImportModal(true); }}
+ className="btn-secondary flex items-center gap-2"
+ >
+
+
+
+ Importer CSV / Excel
+
+
+
+
+
+ Nouvel utilisateur
+
+
@@ -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'}
@@ -189,6 +224,105 @@ export default function UserList() {
)}
+
+ {/* Modale Import CSV / Excel */}
+ {showImportModal && (
+
+
+
+
Importer des utilisateurs
+
setShowImportModal(false)} className="text-gray-400 hover:text-gray-600">
+
+
+
+
+
+
+
+
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é.
+
+
+
+
+
+
+
+ Télécharger le modèle CSV
+
+
+
+
Fichier à importer (.csv ou .xlsx)
+
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} )}
+
+ )}
+
+ )}
+
+
+
setShowImportModal(false)} className="btn-secondary">Fermer
+
+ {importing ? (
+ <>
+
+
+
+
+ Import en cours...
+ >
+ ) : (
+ <>
+
+
+
+ Lancer l'import
+ >
+ )}
+
+
+
+
+ )}
);
}
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