fix: conformité skills - branding Login (logos Itinova/Santinova), import CSV/Excel utilisateurs
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
BIN
frontend/public/logo-itinova.jpg
Normal file
BIN
frontend/public/logo-itinova.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 357 KiB |
BIN
frontend/public/logo-santinova.webp
Normal file
BIN
frontend/public/logo-santinova.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user