feat-contacts-import

This commit is contained in:
deploy
2026-04-30 18:08:34 +02:00
parent 72bc349429
commit 8b8a145d52
8 changed files with 547 additions and 51 deletions
+226 -43
View File
@@ -1,9 +1,13 @@
/**
* Gestion de la liste d'invités pour une facture.
* Ajout / suppression d'invités (nom + entreprise).
* - Autocomplete : suggestions depuis le répertoire contacts au fur et à mesure de la frappe
* - Panneau de sélection multiple : bouton "Choisir dans le répertoire" → liste cochable
* - Saisie libre toujours possible (invité hors répertoire)
*/
import { useState, useImperativeHandle, forwardRef } from 'react';
import type { Guest } from '../types';
import { useState, useImperativeHandle, forwardRef, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '../api/client';
import type { Guest, Contact } from '../types';
export interface GuestManagerHandle {
/**
@@ -15,14 +19,55 @@ export interface GuestManagerHandle {
}
interface Props {
guests: Guest[];
onChange: (guests: Guest[]) => void;
guests: Guest[];
onChange: (guests: Guest[]) => void;
}
const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager({ guests, onChange }, ref) {
const [name, setName] = useState('');
const [company, setCompany] = useState('');
const [name, setName] = useState('');
const [company, setCompany] = useState('');
const [showAC, setShowAC] = useState(false); // autocomplete dropdown visible
const [showPanel, setShowPanel] = useState(false); // panneau de sélection multiple
const [panelSearch, setPanelSearch] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const acRef = useRef<HTMLDivElement>(null);
// ── Chargement du répertoire ─────────────────────────────────
const { data: allContacts = [] } = useQuery<Contact[]>({
queryKey: ['contacts'],
queryFn: () => api.get('/contacts').then(r => r.data),
staleTime: 5 * 60 * 1000,
});
// ── Autocomplete ─────────────────────────────────────────────
const acSuggestions: Contact[] = name.trim().length >= 1
? allContacts.filter(c =>
c.name.toLowerCase().includes(name.toLowerCase()) &&
!guests.some(g => g.name.toLowerCase() === c.name.toLowerCase())
).slice(0, 6)
: [];
// Ferme le dropdown si clic dehors
useEffect(() => {
function handleClick(e: MouseEvent) {
if (acRef.current && !acRef.current.contains(e.target as Node)) {
setShowAC(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// ── Panel : contacts déjà dans la liste (pour l'état coché) ──
const alreadyAdded = new Set(guests.map(g => g.name.toLowerCase()));
const panelFiltered = panelSearch.trim()
? allContacts.filter(c =>
c.name.toLowerCase().includes(panelSearch.toLowerCase()) ||
(c.company ?? '').toLowerCase().includes(panelSearch.toLowerCase())
)
: allContacts;
// ── Implémentation de flushPending (ref parent) ───────────────
useImperativeHandle(ref, () => ({
flushPending() {
const trimmed = name.trim();
@@ -38,15 +83,21 @@ const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager
},
}));
function addGuest() {
const trimmed = name.trim();
if (!trimmed) return;
// ── Actions ──────────────────────────────────────────────────
function addGuest(overrideName?: string, overrideCompany?: string | null) {
const n = (overrideName ?? name).trim();
if (!n) return;
if (guests.some(g => g.name.toLowerCase() === n.toLowerCase())) {
setName(''); setCompany('');
return;
}
onChange([
...guests,
{ name: trimmed, company: company.trim() || null, sort_order: guests.length },
{ name: n, company: overrideCompany !== undefined ? overrideCompany : company.trim() || null, sort_order: guests.length },
]);
setName('');
setCompany('');
setShowAC(false);
}
function removeGuest(index: number) {
@@ -55,29 +106,44 @@ const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); addGuest(); }
if (e.key === 'Escape') setShowAC(false);
}
function selectFromAC(c: Contact) {
addGuest(c.name, c.company ?? null);
nameInputRef.current?.focus();
}
function toggleFromPanel(c: Contact) {
const lower = c.name.toLowerCase();
if (alreadyAdded.has(lower)) {
// retirer
onChange(guests.filter(g => g.name.toLowerCase() !== lower));
} else {
// ajouter
onChange([
...guests,
{ name: c.name, company: c.company ?? null, sort_order: guests.length },
]);
}
}
return (
<div className="space-y-3">
{/* Liste des invités ajoutés */}
{/* Invités déjà ajoutés */}
{guests.length > 0 && (
<ul className="space-y-2">
{guests.map((g, i) => (
<li key={i} className="flex items-center gap-3 bg-indigo-50 rounded-xl px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{g.name}</p>
{g.company && (
<p className="text-xs text-gray-500 truncate">{g.company}</p>
)}
{g.company && <p className="text-xs text-gray-500 truncate">{g.company}</p>}
</div>
<button
type="button"
onClick={() => removeGuest(i)}
className="p-1 text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
<button type="button" onClick={() => removeGuest(i)}
className="p-1 text-gray-400 hover:text-red-500 transition-colors 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" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</li>
@@ -85,37 +151,154 @@ const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager
</ul>
)}
{/* Formulaire ajout */}
{/* Formulaire + autocomplete */}
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nom de l'invité *"
className="form-input text-sm py-2"
/>
{/* Champ nom avec autocomplete */}
<div className="relative" ref={acRef}>
<input
ref={nameInputRef}
type="text"
value={name}
onChange={e => { setName(e.target.value); setShowAC(true); }}
onFocus={() => setShowAC(true)}
onKeyDown={handleKeyDown}
placeholder="Nom de l'invité *"
className="form-input text-sm py-2"
autoComplete="off"
/>
{/* Dropdown autocomplete */}
{showAC && acSuggestions.length > 0 && (
<div className="absolute z-40 left-0 right-0 top-full mt-1 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
{acSuggestions.map(c => (
<button
key={c.id}
type="button"
onMouseDown={e => { e.preventDefault(); selectFromAC(c); }}
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 transition-colors flex items-center gap-3">
<div className="w-7 h-7 rounded-full bg-indigo-100 flex items-center justify-center text-xs font-bold text-indigo-600 shrink-0">
{c.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
{c.company && <p className="text-xs text-gray-400 truncate">{c.company}</p>}
</div>
</button>
))}
</div>
)}
</div>
<input
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
onChange={e => setCompany(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Entreprise (optionnel)"
className="form-input text-sm py-2"
/>
<button
type="button"
onClick={addGuest}
disabled={!name.trim()}
className="flex items-center gap-2 text-sm font-semibold text-indigo-600
disabled:text-gray-300 hover:text-indigo-700 transition-colors py-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Ajouter l'invité
</button>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => addGuest()}
disabled={!name.trim()}
className="flex items-center gap-2 text-sm font-semibold text-indigo-600
disabled:text-gray-300 hover:text-indigo-700 transition-colors py-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4"/>
</svg>
Ajouter l'invité
</button>
{allContacts.length > 0 && (
<button
type="button"
onClick={() => { setShowPanel(true); setPanelSearch(''); }}
className="ml-auto flex items-center gap-1.5 text-xs font-medium text-violet-600 hover:text-violet-800 transition-colors py-1 px-2 bg-violet-50 rounded-lg">
<svg className="w-3.5 h-3.5" 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>
Répertoire ({allContacts.length})
</button>
)}
</div>
</div>
{/* Panneau de sélection multiple (overlay) */}
{showPanel && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/40"
onClick={e => { if (e.target === e.currentTarget) setShowPanel(false); }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm flex flex-col max-h-[80vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<p className="font-semibold text-gray-900">Choisir des invités</p>
<button type="button" onClick={() => setShowPanel(false)}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded-lg">
<svg className="w-5 h-5" 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>
{/* Recherche */}
<div className="px-4 py-2">
<input
type="text"
autoFocus
value={panelSearch}
onChange={e => setPanelSearch(e.target.value)}
placeholder="Rechercher…"
className="form-input text-sm py-2"
/>
</div>
{/* Liste */}
<div className="flex-1 overflow-y-auto px-2 pb-2">
{panelFiltered.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">Aucun contact trouvé</p>
) : (
panelFiltered.map(c => {
const checked = alreadyAdded.has(c.name.toLowerCase());
return (
<button
key={c.id}
type="button"
onClick={() => toggleFromPanel(c)}
className={`w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors mb-0.5
${checked ? 'bg-indigo-50' : 'hover:bg-gray-50'}`}>
{/* Checkbox visuelle */}
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors
${checked ? 'bg-indigo-600 border-indigo-600' : 'border-gray-300'}`}>
{checked && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
{c.company && <p className="text-xs text-gray-400 truncate">{c.company}</p>}
</div>
</button>
);
})
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100">
<button
type="button"
onClick={() => setShowPanel(false)}
className="btn-primary w-full py-2.5">
Valider ({guests.length} invité{guests.length !== 1 ? 's' : ''})
</button>
</div>
</div>
</div>
)}
</div>
);
});
+139 -1
View File
@@ -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>
);
+8
View File
@@ -116,6 +116,14 @@ export interface SmtpConfig {
has_password: boolean;
}
// ─── Contact (répertoire d'invités) ─────────────────────────
export interface Contact {
id: number;
name: string;
company: string | null;
sort_order: number;
}
// ─── Config Graph + SharePoint ───────────────────────────────
export interface AppSettings {
graph_tenant_id?: string;