Files
notesfrais/frontend/src/pages/Settings.tsx
T
2026-04-30 18:08:42 +02:00

575 lines
28 KiB
TypeScript

/**
* Page Paramètres — 4 sections :
* 1. SMTP (config email par utilisateur)
* 2. Sociétés (nom + email de remboursement)
* 3. Catégories
* 4. Microsoft 365 / SharePoint (Azure App Registration + fichier Excel commun)
*/
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, Contact } from '../types';
// ─── Section SMTP ────────────────────────────────────────────
function SmtpSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<SmtpConfig>({
queryKey: ['smtp'],
queryFn: () => api.get('/settings/smtp').then((r) => r.data),
});
const [form, setForm] = useState<Partial<SmtpConfig> & { smtp_pass?: string }>({});
const [open, setOpen] = useState(false);
const save = useMutation({
mutationFn: (payload: object) => api.put('/settings/smtp', payload),
onSuccess: () => { toast.success('SMTP sauvegardé'); qc.invalidateQueries({ queryKey: ['smtp'] }); setOpen(false); },
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
const test = useMutation({
mutationFn: () => api.post('/settings/smtp/test'),
onSuccess: () => toast.success('Email de test envoyé !'),
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Échec SMTP'),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const merged = { ...data, ...form, smtp_port: Number(form.smtp_port ?? data?.smtp_port ?? 587) };
save.mutate(merged);
}
const cfg = { ...data, ...form };
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-blue-50 flex items-center justify-center">
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Email (SMTP)</p>
<p className="text-xs text-gray-400">{data?.smtp_from_email ?? 'Non configuré'}</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 && !isLoading && (
<form onSubmit={handleSubmit} className="px-4 pb-4 space-y-3 border-t border-gray-50 pt-3">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="form-label">Hôte SMTP</label>
<input className="form-input" placeholder="smtp.gmail.com"
value={cfg.smtp_host ?? ''} onChange={e => setForm(f => ({ ...f, smtp_host: e.target.value }))} />
</div>
<div>
<label className="form-label">Port</label>
<input className="form-input" type="number" placeholder="587"
value={cfg.smtp_port ?? 587} onChange={e => setForm(f => ({ ...f, smtp_port: Number(e.target.value) }))} />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" className="w-4 h-4 accent-indigo-600"
checked={!!cfg.smtp_secure} onChange={e => setForm(f => ({ ...f, smtp_secure: e.target.checked }))} />
<span className="text-sm text-gray-700">SSL/TLS</span>
</label>
</div>
<div className="col-span-2">
<label className="form-label">Utilisateur</label>
<input className="form-input" placeholder="votre@email.com"
value={cfg.smtp_user ?? ''} onChange={e => setForm(f => ({ ...f, smtp_user: e.target.value }))} />
</div>
<div className="col-span-2">
<label className="form-label">Mot de passe {data?.has_password && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input" type="password" placeholder={data?.has_password ? '••••••• (laisser vide = conserver)' : 'Mot de passe'}
value={form.smtp_pass ?? ''} onChange={e => setForm(f => ({ ...f, smtp_pass: e.target.value }))} />
</div>
<div>
<label className="form-label">Nom affiché</label>
<input className="form-input" placeholder="Mon Nom"
value={cfg.smtp_from_name ?? ''} onChange={e => setForm(f => ({ ...f, smtp_from_name: e.target.value }))} />
</div>
<div>
<label className="form-label">Email expéditeur</label>
<input className="form-input" type="email" placeholder="moi@email.com"
value={cfg.smtp_from_email ?? ''} onChange={e => setForm(f => ({ ...f, smtp_from_email: e.target.value }))} />
</div>
</div>
<div className="flex gap-2 pt-1">
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
{save.isPending ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" onClick={() => test.mutate()} disabled={test.isPending || !data?.has_password}
className="btn-secondary py-2 text-sm">
{test.isPending ? 'Envoi…' : 'Tester'}
</button>
</div>
</form>
)}
</div>
);
}
// ─── Section Sociétés ────────────────────────────────────────
function CompaniesSection() {
const qc = useQueryClient();
const { data: companies = [] } = useQuery<Company[]>({
queryKey: ['companies'],
queryFn: () => api.get('/companies').then(r => r.data),
});
const [editing, setEditing] = useState<Partial<Company> | null>(null);
const [open, setOpen] = useState(false);
const save = useMutation({
mutationFn: (c: Partial<Company>) => c.id
? api.put(`/companies/${c.id}`, c)
: api.post('/companies', c),
onSuccess: () => { toast.success('Société sauvegardée'); qc.invalidateQueries({ queryKey: ['companies'] }); setEditing(null); },
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete(`/companies/${id}`),
onSuccess: () => { toast.success('Société supprimée'); qc.invalidateQueries({ queryKey: ['companies'] }); },
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (editing) save.mutate(editing);
}
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-emerald-50 flex items-center justify-center">
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Sociétés</p>
<p className="text-xs text-gray-400">{companies.length} société{companies.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">
{/* Liste */}
{companies.map(c => (
<div key={c.id} className="flex items-center gap-3 px-4 py-3 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
<p className="text-xs text-gray-400 truncate">{c.email}</p>
</div>
<button onClick={() => setEditing(c)} className="p-1.5 text-gray-300 hover:text-indigo-500">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button onClick={() => { if (confirm(`Supprimer ${c.name} ?`)) del.mutate(c.id); }}
className="p-1.5 text-gray-300 hover:text-red-500">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
))}
{/* Formulaire ajout/modif */}
{editing !== null ? (
<form onSubmit={handleSubmit} className="p-4 bg-gray-50 space-y-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{editing.id ? 'Modifier la société' : 'Nouvelle société'}
</p>
<input className="form-input" placeholder="Nom *" required
value={editing.name ?? ''} onChange={e => setEditing(p => ({ ...p, name: e.target.value }))} />
<input className="form-input" type="email" placeholder="Email destinataire *" required
value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} />
<div className="flex gap-2">
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
{save.isPending ? '…' : 'Sauvegarder'}
</button>
<button type="button" onClick={() => setEditing(null)} className="btn-secondary py-2 text-sm">Annuler</button>
</div>
</form>
) : (
<div className="p-4">
<button onClick={() => setEditing({})} className="btn-secondary py-2 text-sm">
+ Ajouter une société
</button>
</div>
)}
</div>
)}
</div>
);
}
// ─── Section Catégories ──────────────────────────────────────
function CategoriesSection() {
const qc = useQueryClient();
const { data: categories = [] } = useQuery<Category[]>({
queryKey: ['categories'],
queryFn: () => api.get('/categories').then(r => r.data),
});
const [newName, setNewName] = useState('');
const [open, setOpen] = useState(false);
const add = useMutation({
mutationFn: (name: string) => api.post('/categories', { name }),
onSuccess: () => { toast.success('Catégorie ajoutée'); qc.invalidateQueries({ queryKey: ['categories'] }); setNewName(''); },
});
const del = useMutation({
mutationFn: (id: number) => api.delete(`/categories/${id}`),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['categories'] }); },
});
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-amber-50 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a2 2 0 012-2z"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Catégories</p>
<p className="text-xs text-gray-400">{categories.length} catégorie{categories.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 p-4 space-y-2">
{categories.map(c => (
<div key={c.id} className="flex items-center gap-2">
<span className="flex-1 text-sm text-gray-700">{c.name}</span>
<button onClick={() => del.mutate(c.id)} className="p-1 text-gray-300 hover:text-red-400">
<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 className="flex gap-2 pt-2">
<input className="form-input text-sm py-2 flex-1" placeholder="Nouvelle catégorie"
value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); if (newName.trim()) add.mutate(newName.trim()); }}} />
<button onClick={() => { if (newName.trim()) add.mutate(newName.trim()); }}
disabled={!newName.trim()}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl disabled:opacity-40">
+
</button>
</div>
</div>
)}
</div>
);
}
// ─── Section Microsoft Graph ─────────────────────────────────
function GraphSection() {
const qc = useQueryClient();
const { data } = useQuery<AppSettings>({
queryKey: ['app-settings'],
queryFn: () => api.get('/settings/app').then(r => r.data),
});
const [form, setForm] = useState<{
tenant?: string; client?: string; secret?: string;
siteId?: string; itemId?: string; sheetName?: string;
}>({});
const [open, setOpen] = useState(false);
const save = useMutation({
mutationFn: (payload: object) => api.put('/settings/app', payload),
onSuccess: () => { toast.success('Configuration Graph sauvegardée'); qc.invalidateQueries({ queryKey: ['app-settings'] }); setOpen(false); },
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
const testSp = useMutation({
mutationFn: () => api.post('/settings/sharepoint/test'),
onSuccess: (r: any) => toast.success(r.data?.message ?? 'SharePoint OK'),
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur SharePoint', { duration: 8000 }),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
save.mutate({
graph_tenant_id: form.tenant || undefined,
graph_client_id: form.client || undefined,
graph_client_secret: form.secret || undefined,
sharepoint_site_id: form.siteId || undefined,
sharepoint_item_id: form.itemId || undefined,
sharepoint_sheet_name: form.sheetName || undefined,
});
}
const isConfigured =
data?.graph_tenant_id && data?.graph_client_id && data?.has_secret === 'true' &&
data?.sharepoint_site_id && data?.sharepoint_item_id;
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-sky-50 flex items-center justify-center">
<svg className="w-5 h-5 text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Microsoft 365 / SharePoint</p>
<p className={`text-xs ${isConfigured ? 'text-green-500' : 'text-gray-400'}`}>
{isConfigured ? '✓ Connecté' : 'Non configuré'}
</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 && (
<form onSubmit={handleSubmit} className="px-4 pb-4 space-y-3 border-t border-gray-50 pt-3">
<div className="bg-sky-50 rounded-xl p-3 text-xs text-sky-700 space-y-1">
<p className="font-semibold">Configuration Azure App Registration</p>
<p>Voir le README pour créer l'App Registration et obtenir ces valeurs.</p>
</div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide pt-1">Azure App Registration</p>
{([
{ key: 'tenant', label: 'Tenant ID', ph: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', cur: data?.graph_tenant_id },
{ key: 'client', label: 'Client ID', ph: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', cur: data?.graph_client_id },
] as { key: string; label: string; ph: string; cur?: string }[]).map(({ key, label, ph, cur }) => (
<div key={key}>
<label className="form-label">{label} {cur && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input font-mono text-xs" placeholder={cur ? '(conserver existant)' : ph}
value={(form as any)[key] ?? ''} onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))} />
</div>
))}
<div>
<label className="form-label">Client Secret {data?.has_secret === 'true' && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input" type="password" placeholder={data?.has_secret === 'true' ? '(laisser vide = conserver)' : 'Client Secret'}
value={form.secret ?? ''} onChange={e => setForm(f => ({ ...f, secret: e.target.value }))} />
</div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide pt-2">Fichier Excel SharePoint (partagé entre toutes les sociétés)</p>
<div>
<label className="form-label">Site ID {data?.sharepoint_site_id && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input font-mono text-xs"
placeholder={data?.sharepoint_site_id ? '(conserver existant)' : 'contoso.sharepoint.com,xxxxxxxx,yyyyyyyy'}
value={form.siteId ?? ''} onChange={e => setForm(f => ({ ...f, siteId: e.target.value }))} />
</div>
<div>
<label className="form-label">Item ID du fichier Excel {data?.sharepoint_item_id && <span className="text-green-500 text-xs">(défini)</span>}</label>
<input className="form-input font-mono text-xs"
placeholder={data?.sharepoint_item_id ? '(conserver existant)' : 'ID de l\'élément (GET .../drive/root:/fichier.xlsx)'}
value={form.itemId ?? ''} onChange={e => setForm(f => ({ ...f, itemId: e.target.value }))} />
</div>
<div>
<label className="form-label">
Nom de la feuille {data?.sharepoint_sheet_name && <span className="text-green-500 text-xs">({data.sharepoint_sheet_name})</span>}
</label>
<input className="form-input text-sm"
placeholder={data?.sharepoint_sheet_name ?? 'Feuil1'}
value={form.sheetName ?? ''} onChange={e => setForm(f => ({ ...f, sheetName: e.target.value }))} />
</div>
<div className="flex gap-2">
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
{save.isPending ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" onClick={() => testSp.mutate()}
disabled={testSp.isPending || !isConfigured}
className="btn-secondary py-2 text-sm">
{testSp.isPending ? 'Test…' : 'Tester'}
</button>
</div>
</form>
)}
</div>
);
}
// ─── 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() {
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-gray-900">Paramètres</h2>
<SmtpSection />
<CompaniesSection />
<CategoriesSection />
<ContactsSection />
<GraphSection />
</div>
);
}