feat-contacts-import
This commit is contained in:
@@ -8,8 +8,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useRef } from 'react';
|
||||
import api from '../api/client';
|
||||
import type { Company, Category, SmtpConfig, AppSettings } from '../types';
|
||||
import type { Company, Category, SmtpConfig, AppSettings, Contact } from '../types';
|
||||
|
||||
// ─── Section SMTP ────────────────────────────────────────────
|
||||
|
||||
@@ -421,6 +422,142 @@ function GraphSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Contacts ────────────────────────────────────────
|
||||
|
||||
function ContactsSection() {
|
||||
const qc = useQueryClient();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCompany, setNewCompany] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const { data: contacts = [] } = useQuery<Contact[]>({
|
||||
queryKey: ['contacts'],
|
||||
queryFn: () => api.get('/contacts').then(r => r.data),
|
||||
});
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (payload: { name: string; company: string }) =>
|
||||
api.post('/contacts', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Contact ajouté');
|
||||
qc.invalidateQueries({ queryKey: ['contacts'] });
|
||||
setNewName(''); setNewCompany('');
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/contacts/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
|
||||
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
|
||||
});
|
||||
|
||||
async function handleFileImport(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
setImporting(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const r = await api.post('/contacts/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const { inserted, skipped, total } = r.data;
|
||||
toast.success(`${inserted} contact${inserted !== 1 ? 's' : ''} importé${inserted !== 1 ? 's' : ''}${skipped > 0 ? ` (${skipped} doublon${skipped !== 1 ? 's' : ''} ignoré${skipped !== 1 ? 's' : ''})` : ''} sur ${total}`);
|
||||
qc.invalidateQueries({ queryKey: ['contacts'] });
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error ?? 'Erreur lors de l\'import');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-violet-50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-sm text-gray-900">Contacts / Invités</p>
|
||||
<p className="text-xs text-gray-400">{contacts.length} contact{contacts.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-gray-50">
|
||||
{/* Bouton import */}
|
||||
<div className="px-4 pt-3 pb-2 flex gap-2">
|
||||
<input ref={fileRef} type="file" accept=".csv,.xls,.xlsx"
|
||||
onChange={handleFileImport} className="hidden" />
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={importing}
|
||||
className="btn-secondary py-2 text-sm flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
{importing ? 'Import en cours…' : 'Importer CSV / Excel'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 self-center">Colonnes : Nom, Société (optionnel)</p>
|
||||
</div>
|
||||
|
||||
{/* Liste des contacts */}
|
||||
{contacts.length > 0 && (
|
||||
<div className="px-4 pb-2 max-h-64 overflow-y-auto space-y-1">
|
||||
{contacts.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-3 py-1.5 border-b border-gray-50 last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{c.name}</span>
|
||||
{c.company && <span className="ml-2 text-xs text-gray-400">{c.company}</span>}
|
||||
</div>
|
||||
<button onClick={() => del.mutate(c.id)}
|
||||
className="p-1 text-gray-300 hover:text-red-400 shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ajout manuel */}
|
||||
<div className="px-4 pb-4 pt-2 space-y-2 border-t border-gray-50">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">Ajouter manuellement</p>
|
||||
<div className="flex gap-2">
|
||||
<input className="form-input text-sm py-2 flex-1" placeholder="Nom *"
|
||||
value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} />
|
||||
<input className="form-input text-sm py-2 flex-1" placeholder="Société (optionnel)"
|
||||
value={newCompany} onChange={e => setNewCompany(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} />
|
||||
<button
|
||||
onClick={() => { if (newName.trim()) add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}
|
||||
disabled={!newName.trim() || add.isPending}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl disabled:opacity-40">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page principale ─────────────────────────────────────────
|
||||
|
||||
export default function Settings() {
|
||||
@@ -430,6 +567,7 @@ export default function Settings() {
|
||||
<SmtpSection />
|
||||
<CompaniesSection />
|
||||
<CategoriesSection />
|
||||
<ContactsSection />
|
||||
<GraphSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user