feat-contacts-import

This commit is contained in:
deploy
2026-04-30 18:08:34 +02:00
parent 72bc349429
commit 8b8a145d52
8 changed files with 547 additions and 51 deletions
+3 -1
View File
@@ -20,7 +20,9 @@
"pdf-lib": "^1.17.1",
"pg": "^8.11.5",
"uuid": "^9.0.1",
"zod": "^3.23.8"
"zod": "^3.23.8",
"csv-parse": "^5.5.6",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
+2
View File
@@ -14,6 +14,7 @@ import invoicesRoutes from './routes/invoices';
import companiesRoutes from './routes/companies';
import categoriesRoutes from './routes/categories';
import settingsRoutes from './routes/settings';
import contactsRoutes from './routes/contacts';
const app = express();
@@ -33,6 +34,7 @@ app.use('/api/invoices', invoicesRoutes);
app.use('/api/companies', companiesRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/contacts', contactsRoutes);
// ─── Health check (accessible via /health ET /api/health) ──────
app.get('/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0' }));
+13
View File
@@ -116,6 +116,18 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================
-- CONTACTS (répertoire d'invités réutilisables)
-- Utilisés comme suggestions dans le formulaire de facture
-- =============================================================
CREATE TABLE IF NOT EXISTS contacts (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
company VARCHAR(255),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================
-- INDEX
-- =============================================================
@@ -128,6 +140,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_sent_at ON invoices(sent_at);
CREATE INDEX IF NOT EXISTS idx_guests_invoice_id ON guests(invoice_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
-- =============================================================
-- TRIGGER : mise à jour automatique de updated_at
+150
View File
@@ -0,0 +1,150 @@
import { Router, RequestHandler } from 'express';
import multer from 'multer';
import { db } from '../db';
import { requireAuth } from '../middleware/auth';
import { parse as csvParse } from 'csv-parse/sync';
import * as XLSX from 'xlsx';
const router = Router();
router.use(requireAuth);
// Multer en mémoire (CSV/XLSX, max 5 Mo)
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ok = [
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/octet-stream',
].includes(file.mimetype) || file.originalname.match(/\.(csv|xls|xlsx)$/i);
cb(null, !!ok);
},
});
function wrap(fn: RequestHandler): RequestHandler {
return (req, res, next) => (fn(req, res, next) as any).catch(next);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Normalise une ligne brute en { name, company } */
function normalizeRow(row: Record<string, any>): { name: string; company: string | null } | null {
// Cherche les colonnes "nom"/"name" et "société"/"company" indépendamment de la casse
const keys = Object.keys(row);
const nameKey = keys.find((k) =>
/^(nom|name|prénom|prenom|contact)$/i.test(k.trim())
);
const companyKey = keys.find((k) =>
/^(soci[eé]t[eé]|company|entreprise|organisation|organization|employeur|employer)$/i.test(k.trim())
);
// Fallback : première colonne = nom, deuxième = société
const name = (nameKey ? row[nameKey] : row[keys[0]] ?? '').toString().trim();
const company = companyKey
? (row[companyKey] ?? '').toString().trim() || null
: keys[1]
? (row[keys[1]] ?? '').toString().trim() || null
: null;
if (!name) return null;
return { name, company };
}
/** Parse un buffer CSV → tableau de lignes normalisées */
function parseCSV(buf: Buffer): Array<{ name: string; company: string | null }> {
const rows = csvParse(buf, {
columns: true,
skip_empty_lines: true,
trim: true,
bom: true,
}) as Record<string, any>[];
return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>;
}
/** Parse un buffer XLSX → tableau de lignes normalisées */
function parseXLSX(buf: Buffer): Array<{ name: string; company: string | null }> {
const wb = XLSX.read(buf, { type: 'buffer' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: '' });
return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>;
}
// ── Routes ───────────────────────────────────────────────────────────────────
/** GET /api/contacts — liste tous les contacts triés */
router.get('/', wrap(async (_req, res) => {
const { rows } = await db.query(
'SELECT id, name, company, sort_order FROM contacts ORDER BY name ASC'
);
res.json(rows);
}));
/** POST /api/contacts — ajoute un contact manuel */
router.post('/', wrap(async (req, res) => {
const { name, company } = req.body as { name?: string; company?: string };
if (!name?.trim()) {
return res.status(400).json({ error: 'Le nom est requis.' });
}
const { rows } = await db.query(
'INSERT INTO contacts (name, company) VALUES ($1, $2) RETURNING id, name, company, sort_order',
[name.trim(), company?.trim() || null]
);
res.status(201).json(rows[0]);
}));
/** POST /api/contacts/import — import CSV ou XLSX */
router.post('/import', upload.single('file'), wrap(async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Aucun fichier fourni.' });
}
const isXLSX =
req.file.originalname.match(/\.(xls|xlsx)$/i) ||
req.file.mimetype.includes('spreadsheet') ||
req.file.mimetype.includes('ms-excel');
let contacts: Array<{ name: string; company: string | null }>;
try {
contacts = isXLSX ? parseXLSX(req.file.buffer) : parseCSV(req.file.buffer);
} catch (err: any) {
return res.status(422).json({ error: `Impossible de lire le fichier : ${err.message}` });
}
if (contacts.length === 0) {
return res.status(422).json({ error: 'Aucun contact trouvé dans le fichier. Vérifiez les colonnes (Nom, Société).' });
}
// Upsert : si (name, company) existe déjà, on ne duplique pas
let inserted = 0;
let skipped = 0;
for (const c of contacts) {
const exists = await db.query(
'SELECT id FROM contacts WHERE LOWER(name) = LOWER($1) AND (company IS NULL AND $2::TEXT IS NULL OR LOWER(company) = LOWER($2))',
[c.name, c.company]
);
if (exists.rowCount && exists.rowCount > 0) {
skipped++;
} else {
await db.query(
'INSERT INTO contacts (name, company) VALUES ($1, $2)',
[c.name, c.company]
);
inserted++;
}
}
res.json({ inserted, skipped, total: contacts.length });
}));
/** DELETE /api/contacts/:id */
router.delete('/:id', wrap(async (req, res) => {
const { rowCount } = await db.query('DELETE FROM contacts WHERE id = $1', [req.params.id]);
if (!rowCount) return res.status(404).json({ error: 'Contact introuvable.' });
res.json({ success: true });
}));
export default router;
+226 -43
View File
@@ -1,9 +1,13 @@
/**
* Gestion de la liste d'invités pour une facture.
* Ajout / suppression d'invités (nom + entreprise).
* - Autocomplete : suggestions depuis le répertoire contacts au fur et à mesure de la frappe
* - Panneau de sélection multiple : bouton "Choisir dans le répertoire" → liste cochable
* - Saisie libre toujours possible (invité hors répertoire)
*/
import { useState, useImperativeHandle, forwardRef } from 'react';
import type { Guest } from '../types';
import { useState, useImperativeHandle, forwardRef, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '../api/client';
import type { Guest, Contact } from '../types';
export interface GuestManagerHandle {
/**
@@ -15,14 +19,55 @@ export interface GuestManagerHandle {
}
interface Props {
guests: Guest[];
onChange: (guests: Guest[]) => void;
guests: Guest[];
onChange: (guests: Guest[]) => void;
}
const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager({ guests, onChange }, ref) {
const [name, setName] = useState('');
const [company, setCompany] = useState('');
const [name, setName] = useState('');
const [company, setCompany] = useState('');
const [showAC, setShowAC] = useState(false); // autocomplete dropdown visible
const [showPanel, setShowPanel] = useState(false); // panneau de sélection multiple
const [panelSearch, setPanelSearch] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const acRef = useRef<HTMLDivElement>(null);
// ── Chargement du répertoire ─────────────────────────────────
const { data: allContacts = [] } = useQuery<Contact[]>({
queryKey: ['contacts'],
queryFn: () => api.get('/contacts').then(r => r.data),
staleTime: 5 * 60 * 1000,
});
// ── Autocomplete ─────────────────────────────────────────────
const acSuggestions: Contact[] = name.trim().length >= 1
? allContacts.filter(c =>
c.name.toLowerCase().includes(name.toLowerCase()) &&
!guests.some(g => g.name.toLowerCase() === c.name.toLowerCase())
).slice(0, 6)
: [];
// Ferme le dropdown si clic dehors
useEffect(() => {
function handleClick(e: MouseEvent) {
if (acRef.current && !acRef.current.contains(e.target as Node)) {
setShowAC(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// ── Panel : contacts déjà dans la liste (pour l'état coché) ──
const alreadyAdded = new Set(guests.map(g => g.name.toLowerCase()));
const panelFiltered = panelSearch.trim()
? allContacts.filter(c =>
c.name.toLowerCase().includes(panelSearch.toLowerCase()) ||
(c.company ?? '').toLowerCase().includes(panelSearch.toLowerCase())
)
: allContacts;
// ── Implémentation de flushPending (ref parent) ───────────────
useImperativeHandle(ref, () => ({
flushPending() {
const trimmed = name.trim();
@@ -38,15 +83,21 @@ const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager
},
}));
function addGuest() {
const trimmed = name.trim();
if (!trimmed) return;
// ── Actions ──────────────────────────────────────────────────
function addGuest(overrideName?: string, overrideCompany?: string | null) {
const n = (overrideName ?? name).trim();
if (!n) return;
if (guests.some(g => g.name.toLowerCase() === n.toLowerCase())) {
setName(''); setCompany('');
return;
}
onChange([
...guests,
{ name: trimmed, company: company.trim() || null, sort_order: guests.length },
{ name: n, company: overrideCompany !== undefined ? overrideCompany : company.trim() || null, sort_order: guests.length },
]);
setName('');
setCompany('');
setShowAC(false);
}
function removeGuest(index: number) {
@@ -55,29 +106,44 @@ const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); addGuest(); }
if (e.key === 'Escape') setShowAC(false);
}
function selectFromAC(c: Contact) {
addGuest(c.name, c.company ?? null);
nameInputRef.current?.focus();
}
function toggleFromPanel(c: Contact) {
const lower = c.name.toLowerCase();
if (alreadyAdded.has(lower)) {
// retirer
onChange(guests.filter(g => g.name.toLowerCase() !== lower));
} else {
// ajouter
onChange([
...guests,
{ name: c.name, company: c.company ?? null, sort_order: guests.length },
]);
}
}
return (
<div className="space-y-3">
{/* Liste des invités ajoutés */}
{/* Invités déjà ajoutés */}
{guests.length > 0 && (
<ul className="space-y-2">
{guests.map((g, i) => (
<li key={i} className="flex items-center gap-3 bg-indigo-50 rounded-xl px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{g.name}</p>
{g.company && (
<p className="text-xs text-gray-500 truncate">{g.company}</p>
)}
{g.company && <p className="text-xs text-gray-500 truncate">{g.company}</p>}
</div>
<button
type="button"
onClick={() => removeGuest(i)}
className="p-1 text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
<button type="button" onClick={() => removeGuest(i)}
className="p-1 text-gray-400 hover:text-red-500 transition-colors shrink-0">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</li>
@@ -85,37 +151,154 @@ const GuestManager = forwardRef<GuestManagerHandle, Props>(function GuestManager
</ul>
)}
{/* Formulaire ajout */}
{/* Formulaire + autocomplete */}
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nom de l'invité *"
className="form-input text-sm py-2"
/>
{/* Champ nom avec autocomplete */}
<div className="relative" ref={acRef}>
<input
ref={nameInputRef}
type="text"
value={name}
onChange={e => { setName(e.target.value); setShowAC(true); }}
onFocus={() => setShowAC(true)}
onKeyDown={handleKeyDown}
placeholder="Nom de l'invité *"
className="form-input text-sm py-2"
autoComplete="off"
/>
{/* Dropdown autocomplete */}
{showAC && acSuggestions.length > 0 && (
<div className="absolute z-40 left-0 right-0 top-full mt-1 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
{acSuggestions.map(c => (
<button
key={c.id}
type="button"
onMouseDown={e => { e.preventDefault(); selectFromAC(c); }}
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 transition-colors flex items-center gap-3">
<div className="w-7 h-7 rounded-full bg-indigo-100 flex items-center justify-center text-xs font-bold text-indigo-600 shrink-0">
{c.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
{c.company && <p className="text-xs text-gray-400 truncate">{c.company}</p>}
</div>
</button>
))}
</div>
)}
</div>
<input
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
onChange={e => setCompany(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Entreprise (optionnel)"
className="form-input text-sm py-2"
/>
<button
type="button"
onClick={addGuest}
disabled={!name.trim()}
className="flex items-center gap-2 text-sm font-semibold text-indigo-600
disabled:text-gray-300 hover:text-indigo-700 transition-colors py-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Ajouter l'invité
</button>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => addGuest()}
disabled={!name.trim()}
className="flex items-center gap-2 text-sm font-semibold text-indigo-600
disabled:text-gray-300 hover:text-indigo-700 transition-colors py-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4"/>
</svg>
Ajouter l'invité
</button>
{allContacts.length > 0 && (
<button
type="button"
onClick={() => { setShowPanel(true); setPanelSearch(''); }}
className="ml-auto flex items-center gap-1.5 text-xs font-medium text-violet-600 hover:text-violet-800 transition-colors py-1 px-2 bg-violet-50 rounded-lg">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Répertoire ({allContacts.length})
</button>
)}
</div>
</div>
{/* Panneau de sélection multiple (overlay) */}
{showPanel && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/40"
onClick={e => { if (e.target === e.currentTarget) setShowPanel(false); }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm flex flex-col max-h-[80vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<p className="font-semibold text-gray-900">Choisir des invités</p>
<button type="button" onClick={() => setShowPanel(false)}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded-lg">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/* Recherche */}
<div className="px-4 py-2">
<input
type="text"
autoFocus
value={panelSearch}
onChange={e => setPanelSearch(e.target.value)}
placeholder="Rechercher…"
className="form-input text-sm py-2"
/>
</div>
{/* Liste */}
<div className="flex-1 overflow-y-auto px-2 pb-2">
{panelFiltered.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-8">Aucun contact trouvé</p>
) : (
panelFiltered.map(c => {
const checked = alreadyAdded.has(c.name.toLowerCase());
return (
<button
key={c.id}
type="button"
onClick={() => toggleFromPanel(c)}
className={`w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors mb-0.5
${checked ? 'bg-indigo-50' : 'hover:bg-gray-50'}`}>
{/* Checkbox visuelle */}
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors
${checked ? 'bg-indigo-600 border-indigo-600' : 'border-gray-300'}`}>
{checked && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
{c.company && <p className="text-xs text-gray-400 truncate">{c.company}</p>}
</div>
</button>
);
})
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100">
<button
type="button"
onClick={() => setShowPanel(false)}
className="btn-primary w-full py-2.5">
Valider ({guests.length} invité{guests.length !== 1 ? 's' : ''})
</button>
</div>
</div>
</div>
)}
</div>
);
});
+139 -1
View File
@@ -8,8 +8,9 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { useRef } from 'react';
import api from '../api/client';
import type { Company, Category, SmtpConfig, AppSettings } from '../types';
import type { Company, Category, SmtpConfig, AppSettings, Contact } from '../types';
// ─── Section SMTP ────────────────────────────────────────────
@@ -421,6 +422,142 @@ function GraphSection() {
);
}
// ─── Section Contacts ────────────────────────────────────────
function ContactsSection() {
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newCompany, setNewCompany] = useState('');
const [importing, setImporting] = useState(false);
const { data: contacts = [] } = useQuery<Contact[]>({
queryKey: ['contacts'],
queryFn: () => api.get('/contacts').then(r => r.data),
});
const add = useMutation({
mutationFn: (payload: { name: string; company: string }) =>
api.post('/contacts', payload),
onSuccess: () => {
toast.success('Contact ajouté');
qc.invalidateQueries({ queryKey: ['contacts'] });
setNewName(''); setNewCompany('');
},
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete(`/contacts/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
});
async function handleFileImport(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
setImporting(true);
const formData = new FormData();
formData.append('file', file);
try {
const r = await api.post('/contacts/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const { inserted, skipped, total } = r.data;
toast.success(`${inserted} contact${inserted !== 1 ? 's' : ''} importé${inserted !== 1 ? 's' : ''}${skipped > 0 ? ` (${skipped} doublon${skipped !== 1 ? 's' : ''} ignoré${skipped !== 1 ? 's' : ''})` : ''} sur ${total}`);
qc.invalidateQueries({ queryKey: ['contacts'] });
} catch (err: any) {
toast.error(err.response?.data?.error ?? 'Erreur lors de l\'import');
} finally {
setImporting(false);
}
}
return (
<div className="card">
<button onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-violet-50 flex items-center justify-center">
<svg className="w-5 h-5 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<div className="text-left">
<p className="font-semibold text-sm text-gray-900">Contacts / Invités</p>
<p className="text-xs text-gray-400">{contacts.length} contact{contacts.length !== 1 ? 's' : ''}</p>
</div>
</div>
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{open && (
<div className="border-t border-gray-50">
{/* Bouton import */}
<div className="px-4 pt-3 pb-2 flex gap-2">
<input ref={fileRef} type="file" accept=".csv,.xls,.xlsx"
onChange={handleFileImport} className="hidden" />
<button
onClick={() => fileRef.current?.click()}
disabled={importing}
className="btn-secondary py-2 text-sm flex items-center gap-2">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
{importing ? 'Import en cours…' : 'Importer CSV / Excel'}
</button>
<p className="text-xs text-gray-400 self-center">Colonnes : Nom, Société (optionnel)</p>
</div>
{/* Liste des contacts */}
{contacts.length > 0 && (
<div className="px-4 pb-2 max-h-64 overflow-y-auto space-y-1">
{contacts.map(c => (
<div key={c.id} className="flex items-center gap-3 py-1.5 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900">{c.name}</span>
{c.company && <span className="ml-2 text-xs text-gray-400">{c.company}</span>}
</div>
<button onClick={() => del.mutate(c.id)}
className="p-1 text-gray-300 hover:text-red-400 shrink-0">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
))}
</div>
)}
{/* Ajout manuel */}
<div className="px-4 pb-4 pt-2 space-y-2 border-t border-gray-50">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">Ajouter manuellement</p>
<div className="flex gap-2">
<input className="form-input text-sm py-2 flex-1" placeholder="Nom *"
value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} />
<input className="form-input text-sm py-2 flex-1" placeholder="Société (optionnel)"
value={newCompany} onChange={e => setNewCompany(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} />
<button
onClick={() => { if (newName.trim()) add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}
disabled={!newName.trim() || add.isPending}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl disabled:opacity-40">
+
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Page principale ─────────────────────────────────────────
export default function Settings() {
@@ -430,6 +567,7 @@ export default function Settings() {
<SmtpSection />
<CompaniesSection />
<CategoriesSection />
<ContactsSection />
<GraphSection />
</div>
);
+8
View File
@@ -116,6 +116,14 @@ export interface SmtpConfig {
has_password: boolean;
}
// ─── Contact (répertoire d'invités) ─────────────────────────
export interface Contact {
id: number;
name: string;
company: string | null;
sort_order: number;
}
// ─── Config Graph + SharePoint ───────────────────────────────
export interface AppSettings {
graph_tenant_id?: string;