fix: conformité skills - branding Login (logos Itinova/Santinova), import CSV/Excel utilisateurs
This commit is contained in:
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,69 +25,115 @@ export default function Login() {
|
||||
};
|
||||
|
||||
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="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-white rounded-2xl shadow-lg mb-4">
|
||||
<span className="text-santinova-900 font-bold text-2xl">S</span>
|
||||
<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-3xl font-bold text-white">SANTINOVA</h1>
|
||||
<p className="text-santinova-200 mt-1">Gestion de Facturation</p>
|
||||
<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>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Connexion</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Adresse email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="votre@email.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{/* Formulaire centré */}
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Connexion</h2>
|
||||
<p className="text-gray-500 mt-1 text-sm">Accédez à votre espace de facturation</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Adresse email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="votre@email.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center py-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-5 w-5" 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>
|
||||
) : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center py-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-5 w-5" 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>
|
||||
) : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-santinova-300 text-sm mt-6">
|
||||
Facturation SANTINOVA v1.0
|
||||
</p>
|
||||
{/* Logo Santinova "powered by" en bas (skill : bas de page) */}
|
||||
<div className="w-full flex flex-col items-center lg:items-end gap-1">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
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({
|
||||
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<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 (
|
||||
<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>
|
||||
<button onClick={openCreate} className="btn-primary">
|
||||
<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" />
|
||||
</svg>
|
||||
Nouvel utilisateur
|
||||
</button>
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Nouvel utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden p-0">
|
||||
@@ -107,7 +142,7 @@ export default function UserList() {
|
||||
<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">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-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
@@ -115,6 +150,8 @@ export default function UserList() {
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<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) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
@@ -145,12 +182,12 @@ export default function UserList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{/* Modale Créer / Modifier */}
|
||||
{showModal && (
|
||||
<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">
|
||||
<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 className="p-6 space-y-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" />
|
||||
</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">
|
||||
{Object.entries(roleLabels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
{Object.entries(roleLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,6 +224,105 @@ export default function UserList() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user