deploy: notesfrais — 2026-04-29 09:57:19
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* 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 api from '../api/client';
|
||||
import type { Company, Category, SmtpConfig, AppSettings } 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'),
|
||||
});
|
||||
|
||||
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>
|
||||
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
|
||||
{save.isPending ? 'Sauvegarde…' : 'Sauvegarder'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</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 />
|
||||
<GraphSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user