diff --git a/.gitignore b/.gitignore index f509b45..e30b965 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -node_modules/ -*/node_modules/ -dist/ -*/dist/ -.env -*.log +node_modules/ +*/node_modules/ +dist/ +*/dist/ +.env +*.log diff --git a/backend/package.json b/backend/package.json index cee34ed..5aeb4b5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/index.ts b/backend/src/index.ts index 3edbd51..bbb972d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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' })); diff --git a/backend/src/migrations/001_init.sql b/backend/src/migrations/001_init.sql index 2f06943..fac6730 100644 --- a/backend/src/migrations/001_init.sql +++ b/backend/src/migrations/001_init.sql @@ -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 diff --git a/backend/src/routes/contacts.ts b/backend/src/routes/contacts.ts new file mode 100644 index 0000000..7d27c50 --- /dev/null +++ b/backend/src/routes/contacts.ts @@ -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): { 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[]; + 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>(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; diff --git a/frontend/src/components/GuestManager.tsx b/frontend/src/components/GuestManager.tsx index 08eac9d..15992ab 100644 --- a/frontend/src/components/GuestManager.tsx +++ b/frontend/src/components/GuestManager.tsx @@ -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(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(null); + const acRef = useRef(null); + // ── Chargement du répertoire ───────────────────────────────── + const { data: allContacts = [] } = useQuery({ + 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(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(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 (
- {/* Liste des invités ajoutés */} + {/* Invités déjà ajoutés */} {guests.length > 0 && (
    {guests.map((g, i) => (
  • {g.name}

    - {g.company && ( -

    {g.company}

    - )} + {g.company &&

    {g.company}

    }
    -
  • @@ -85,37 +151,154 @@ const GuestManager = forwardRef(function GuestManager
)} - {/* Formulaire ajout */} + {/* Formulaire + autocomplete */}
- setName(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Nom de l'invité *" - className="form-input text-sm py-2" - /> + + {/* Champ nom avec autocomplete */} +
+ { 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 && ( +
+ {acSuggestions.map(c => ( + + ))} +
+ )} +
+ setCompany(e.target.value)} + onChange={e => setCompany(e.target.value)} onKeyDown={handleKeyDown} placeholder="Entreprise (optionnel)" className="form-input text-sm py-2" /> - + + {/* Actions */} +
+ + + {allContacts.length > 0 && ( + + )} +
+ + {/* Panneau de sélection multiple (overlay) */} + {showPanel && ( +
{ if (e.target === e.currentTarget) setShowPanel(false); }}> +
+ {/* Header */} +
+

Choisir des invités

+ +
+ + {/* Recherche */} +
+ setPanelSearch(e.target.value)} + placeholder="Rechercher…" + className="form-input text-sm py-2" + /> +
+ + {/* Liste */} +
+ {panelFiltered.length === 0 ? ( +

Aucun contact trouvé

+ ) : ( + panelFiltered.map(c => { + const checked = alreadyAdded.has(c.name.toLowerCase()); + return ( + + ); + }) + )} +
+ + {/* Footer */} +
+ +
+
+
+ )}
); }); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 96da5c8..2f41595 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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(null); + const [open, setOpen] = useState(false); + const [newName, setNewName] = useState(''); + const [newCompany, setNewCompany] = useState(''); + const [importing, setImporting] = useState(false); + + const { data: contacts = [] } = useQuery({ + 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) { + 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 ( +
+ + + {open && ( +
+ {/* Bouton import */} +
+ + +

Colonnes : Nom, Société (optionnel)

+
+ + {/* Liste des contacts */} + {contacts.length > 0 && ( +
+ {contacts.map(c => ( +
+
+ {c.name} + {c.company && {c.company}} +
+ +
+ ))} +
+ )} + + {/* Ajout manuel */} +
+

Ajouter manuellement

+
+ setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} /> + setNewCompany(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) { e.preventDefault(); add.mutate({ name: newName.trim(), company: newCompany.trim() }); }}} /> + +
+
+
+ )} +
+ ); +} + // ─── Page principale ───────────────────────────────────────── export default function Settings() { @@ -430,6 +567,7 @@ export default function Settings() { + ); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b495dce..f214a87 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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;