feat-contacts-import
This commit is contained in:
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user