575 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|