fix: conformité skills - branding Login (logos Itinova/Santinova), import CSV/Excel utilisateurs

This commit is contained in:
Manus Agent
2026-04-23 05:45:45 -04:00
parent 6ab833945c
commit cceac3d35b
6 changed files with 401 additions and 98 deletions

View File

@@ -1,9 +1,30 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import multer from 'multer';
import * as XLSX from 'xlsx';
import { getPool } from '../config/database'; import { getPool } from '../config/database';
import { authenticate, AuthRequest, authorize } from '../middleware/auth'; 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(); const router = Router();
// POST /api/auth/login // 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; export default router;

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -25,19 +25,58 @@ export default function Login() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-santinova-900 via-santinova-800 to-santinova-700 px-4"> <div className="min-h-screen flex">
{/* Panneau gauche — fond dégradé SANTINOVA */}
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-santinova-900 via-santinova-800 to-santinova-700 flex-col items-center justify-center p-12 relative overflow-hidden">
{/* Cercles décoratifs */}
<div className="absolute top-0 left-0 w-64 h-64 bg-white opacity-5 rounded-full -translate-x-1/2 -translate-y-1/2" />
<div className="absolute bottom-0 right-0 w-96 h-96 bg-white opacity-5 rounded-full translate-x-1/3 translate-y-1/3" />
<div className="relative z-10 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-white rounded-2xl shadow-2xl mb-6">
<span className="text-santinova-900 font-black text-3xl">F</span>
</div>
<h1 className="text-4xl font-bold text-white mb-3">Facturation</h1>
<p className="text-santinova-200 text-lg">Gestion intelligente de vos factures fournisseurs</p>
<div className="mt-12 grid grid-cols-1 gap-4 text-left">
{[
{ 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) => (
<div key={item.label} className="flex items-start gap-3 bg-white bg-opacity-10 rounded-xl p-4">
<span className="text-2xl">{item.icon}</span>
<div>
<p className="text-white font-semibold text-sm">{item.label}</p>
<p className="text-santinova-300 text-xs mt-0.5">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Panneau droit — formulaire + logos Itinova / Santinova */}
<div className="w-full lg:w-1/2 flex flex-col items-center justify-between bg-gray-50 px-8 py-10 min-h-screen">
{/* Logo Itinova en haut (skill : haut de page) */}
<div className="w-full flex justify-center lg:justify-start">
<img
src="/logo-itinova.jpg"
alt="Itinova"
className="h-16 object-contain"
/>
</div>
{/* Formulaire centré */}
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="text-center mb-8"> <div className="mb-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-white rounded-2xl shadow-lg mb-4"> <h2 className="text-2xl font-bold text-gray-900">Connexion</h2>
<span className="text-santinova-900 font-bold text-2xl">S</span> <p className="text-gray-500 mt-1 text-sm">Accédez à votre espace de facturation</p>
</div>
<h1 className="text-3xl font-bold text-white">SANTINOVA</h1>
<p className="text-santinova-200 mt-1">Gestion de Facturation</p>
</div> </div>
<div className="bg-white rounded-2xl shadow-xl p-8"> <div className="bg-white rounded-2xl shadow-lg p-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6">Connexion</h2>
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
@@ -84,10 +123,17 @@ export default function Login() {
</button> </button>
</form> </form>
</div> </div>
</div>
<p className="text-center text-santinova-300 text-sm mt-6"> {/* Logo Santinova "powered by" en bas (skill : bas de page) */}
Facturation SANTINOVA v1.0 <div className="w-full flex flex-col items-center lg:items-end gap-1">
</p> <span className="text-xs text-gray-400 font-medium tracking-wide uppercase">powered by</span>
<img
src="/logo-santinova.webp"
alt="Santinova Soft"
className="h-8 object-contain opacity-80"
/>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,21 +1,32 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { authAPI } from '../services/api'; import { authAPI } from '../services/api';
import { User } from '../types'; import { User } from '../types';
import { roleLabels } from '../utils/helpers'; import { roleLabels } from '../utils/helpers';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
interface ImportResult {
message: string;
created: number;
updated: number;
errors: string[];
}
export default function UserList() { export default function UserList() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null); const [editingUser, setEditingUser] = useState<User | null>(null);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importing, setImporting] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
email: '', password: '', firstName: '', lastName: '', role: 'operateur', email: '', password: '', firstName: '', lastName: '', role: 'operateur',
}); });
useEffect(() => { useEffect(() => { loadUsers(); }, []);
loadUsers();
}, []);
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -36,34 +47,18 @@ export default function UserList() {
const openEdit = (user: User) => { const openEdit = (user: User) => {
setEditingUser(user); setEditingUser(user);
setForm({ setForm({ email: user.email, password: '', firstName: user.firstName, lastName: user.lastName, role: user.role });
email: user.email,
password: '',
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
});
setShowModal(true); setShowModal(true);
}; };
const handleSave = async () => { const handleSave = async () => {
if (!form.email || !form.firstName || !form.lastName) { if (!form.email || !form.firstName || !form.lastName) { toast.error('Tous les champs sont requis'); return; }
toast.error('Tous les champs sont requis'); if (!editingUser && !form.password) { toast.error('Le mot de passe est requis'); return; }
return;
}
if (!editingUser && !form.password) {
toast.error('Le mot de passe est requis');
return;
}
try { try {
if (editingUser) { if (editingUser) {
await authAPI.updateUser(editingUser.id, { await authAPI.updateUser(editingUser.id, {
email: form.email, email: form.email, firstName: form.firstName, lastName: form.lastName,
firstName: form.firstName, role: form.role, isActive: true, password: form.password || undefined,
lastName: form.lastName,
role: form.role,
isActive: true,
password: form.password || undefined,
}); });
toast.success('Utilisateur mis à jour'); toast.success('Utilisateur mis à jour');
} else { } else {
@@ -83,22 +78,62 @@ export default function UserList() {
await authAPI.deleteUser(id); await authAPI.deleteUser(id);
toast.success('Utilisateur désactivé'); toast.success('Utilisateur désactivé');
loadUsers(); loadUsers();
} catch (error) { } catch { toast.error('Erreur'); }
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<HTMLInputElement>) => {
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-gray-900">Gestion des utilisateurs</h1> <h1 className="text-2xl font-bold text-gray-900">Gestion des utilisateurs</h1>
<button onClick={openCreate} className="btn-primary"> <div className="flex items-center gap-3">
<button
onClick={() => { setImportFile(null); setImportResult(null); setShowImportModal(true); }}
className="btn-secondary flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
Importer CSV / Excel
</button>
<button onClick={openCreate} className="btn-primary flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor"> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
Nouvel utilisateur Nouvel utilisateur
</button> </button>
</div> </div>
</div>
<div className="card overflow-hidden p-0"> <div className="card overflow-hidden p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -107,7 +142,7 @@ export default function UserList() {
<tr> <tr>
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Nom</th> <th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Nom</th>
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Email</th> <th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Email</th>
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Rôle</th> <th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Role</th>
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th> <th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th> <th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th>
</tr> </tr>
@@ -115,6 +150,8 @@ export default function UserList() {
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200">
{loading ? ( {loading ? (
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr> <tr><td colSpan={5} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-400">Aucun utilisateur</td></tr>
) : users.map((u) => ( ) : users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50"> <tr key={u.id} className="hover:bg-gray-50">
<td className="px-6 py-4"> <td className="px-6 py-4">
@@ -145,12 +182,12 @@ export default function UserList() {
</div> </div>
</div> </div>
{/* Modal */} {/* Modale Créer / Modifier */}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg"> <div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">{editingUser ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur'}</h2> <h2 className="text-lg font-semibold">{editingUser ? "Modifier l'utilisateur" : 'Nouvel utilisateur'}</h2>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -174,11 +211,9 @@ export default function UserList() {
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="input-field" /> <input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="input-field" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rôle</label> <label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className="input-field"> <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className="input-field">
{Object.entries(roleLabels).map(([k, v]) => ( {Object.entries(roleLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
<option key={k} value={k}>{v}</option>
))}
</select> </select>
</div> </div>
</div> </div>
@@ -189,6 +224,105 @@ export default function UserList() {
</div> </div>
</div> </div>
)} )}
{/* Modale Import CSV / Excel */}
{showImportModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold">Importer des utilisateurs</h2>
<button onClick={() => setShowImportModal(false)} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-5">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<p className="font-semibold mb-1">Format attendu (colonnes) :</p>
<code className="text-xs bg-blue-100 px-2 py-1 rounded">email, prenom, nom, role</code>
<p className="mt-2 text-xs">
Roles : <strong>admin</strong>, <strong>approbateur</strong>, <strong>validateur</strong>, <strong>operateur</strong> (défaut si vide).
</p>
<p className="text-xs mt-1">
Utilisateurs existants (même email) : mis à jour. Nouveaux : mot de passe temporaire généré.
</p>
</div>
<button onClick={handleDownloadTemplate} className="flex items-center gap-2 text-sm text-santinova-600 hover:text-santinova-700 font-medium">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Télécharger le modèle CSV
</button>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Fichier à importer (.csv ou .xlsx)</label>
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-santinova-400 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
{importFile ? (
<div className="flex items-center justify-center gap-2 text-sm text-gray-700">
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">{importFile.name}</span>
<span className="text-gray-400">({(importFile.size / 1024).toFixed(1)} Ko)</span>
</div>
) : (
<div className="text-gray-400">
<svg className="w-8 h-8 mx-auto mb-2" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m6.75 12H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<p className="text-sm">Cliquez pour sélectionner un fichier</p>
<p className="text-xs mt-1">CSV ou Excel (.xlsx)</p>
</div>
)}
</div>
<input ref={fileInputRef} type="file" accept=".csv,.xlsx,.xls" className="hidden" onChange={handleImportFileChange} />
</div>
{importResult && (
<div className={`rounded-lg p-4 text-sm ${importResult.errors.length > 0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'}`}>
<p className="font-semibold mb-2">{importResult.message}</p>
<div className="flex gap-4 text-xs mb-2">
<span className="text-green-700">OK {importResult.created} créé(s)</span>
<span className="text-blue-700">MAJ {importResult.updated} mis à jour</span>
{importResult.errors.length > 0 && <span className="text-red-700">ERR {importResult.errors.length} erreur(s)</span>}
</div>
{importResult.errors.length > 0 && (
<ul className="text-xs text-red-600 space-y-1 mt-2 max-h-24 overflow-y-auto">
{importResult.errors.map((err, i) => <li key={i}>- {err}</li>)}
</ul>
)}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button onClick={() => setShowImportModal(false)} className="btn-secondary">Fermer</button>
<button onClick={handleImport} disabled={!importFile || importing} className="btn-primary flex items-center gap-2">
{importing ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Import en cours...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
Lancer l'import
</>
)}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -43,6 +43,11 @@ export const authAPI = {
createUser: (data: any) => api.post('/auth/users', data), createUser: (data: any) => api.post('/auth/users', data),
updateUser: (id: number, data: any) => api.put(`/auth/users/${id}`, data), updateUser: (id: number, data: any) => api.put(`/auth/users/${id}`, data),
deleteUser: (id: number) => api.delete(`/auth/users/${id}`), 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 // Invoices