feat: multi-destinataires email par societe

This commit is contained in:
Claude
2026-05-06 17:03:53 +02:00
parent fa32f92f22
commit 536f0cd9d9
6 changed files with 135 additions and 51 deletions
+10 -6
View File
@@ -46,14 +46,18 @@ CREATE TABLE IF NOT EXISTS app_settings (
-- Entités vers lesquelles envoyer les factures à rembourser -- Entités vers lesquelles envoyer les factures à rembourser
-- ============================================================= -- =============================================================
CREATE TABLE IF NOT EXISTS companies ( CREATE TABLE IF NOT EXISTS companies (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL, -- destinataire de l'email de remboursement email VARCHAR(255) NOT NULL, -- destinataire principal
is_active BOOLEAN DEFAULT TRUE, extra_emails TEXT[] NOT NULL DEFAULT '{}', -- destinataires supplémentaires
created_at TIMESTAMPTZ DEFAULT NOW(), is_active BOOLEAN DEFAULT TRUE,
updated_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
); );
-- Migration idempotente : ajoute extra_emails si la colonne n'existe pas encore
ALTER TABLE companies ADD COLUMN IF NOT EXISTS extra_emails TEXT[] NOT NULL DEFAULT '{}';
-- ============================================================= -- =============================================================
-- CATÉGORIES DE DÉPENSES -- CATÉGORIES DE DÉPENSES
-- ============================================================= -- =============================================================
+15 -10
View File
@@ -11,35 +11,40 @@ const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promi
(req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
const companySchema = z.object({ const companySchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
email: z.string().email(), email: z.string().email('Email principal invalide'),
extra_emails: z
.array(z.string().email('Adresse email supplémentaire invalide'))
.default([]),
}); });
/** GET /api/companies — Liste toutes les sociétés actives */ /** GET /api/companies — Liste toutes les sociétés actives */
router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise<void> => { router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
const result = await db.query( const result = await db.query(
'SELECT * FROM companies WHERE is_active = TRUE ORDER BY name' 'SELECT id, name, email, extra_emails, is_active FROM companies WHERE is_active = TRUE ORDER BY name'
); );
res.json(result.rows); res.json(result.rows);
})); }));
/** POST /api/companies — Crée une société */ /** POST /api/companies — Crée une société */
router.post('/', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => { router.post('/', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
const { name, email } = req.body; const { name, email, extra_emails } = req.body;
const result = await db.query( const result = await db.query(
`INSERT INTO companies (name, email) VALUES ($1, $2) RETURNING *`, `INSERT INTO companies (name, email, extra_emails) VALUES ($1, $2, $3) RETURNING *`,
[name, email] [name, email, extra_emails]
); );
res.status(201).json(result.rows[0]); res.status(201).json(result.rows[0]);
})); }));
/** PUT /api/companies/:id — Met à jour une société */ /** PUT /api/companies/:id — Met à jour une société */
router.put('/:id', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => { router.put('/:id', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
const { name, email } = req.body; const { name, email, extra_emails } = req.body;
const result = await db.query( const result = await db.query(
`UPDATE companies SET name=$1, email=$2, updated_at=NOW() `UPDATE companies
WHERE id=$3 AND is_active=TRUE RETURNING *`, SET name=$1, email=$2, extra_emails=$3, updated_at=NOW()
[name, email, req.params.id] WHERE id=$4 AND is_active=TRUE
RETURNING *`,
[name, email, extra_emails, req.params.id]
); );
if (!result.rows[0]) { res.status(404).json({ error: 'Société introuvable' }); return; } if (!result.rows[0]) { res.status(404).json({ error: 'Société introuvable' }); return; }
res.json(result.rows[0]); res.json(result.rows[0]);
+11 -6
View File
@@ -81,10 +81,11 @@ const createSchema = z.object({
async function getInvoiceById(id: string) { async function getInvoiceById(id: string) {
const result = await db.query( const result = await db.query(
`SELECT i.*, `SELECT i.*,
co.name AS company_name, co.name AS company_name,
co.email AS company_email, co.email AS company_email,
cat.name AS category_name, co.extra_emails AS company_extra_emails,
u.name AS user_name cat.name AS category_name,
u.name AS user_name
FROM invoices i FROM invoices i
JOIN companies co ON co.id = i.company_id JOIN companies co ON co.id = i.company_id
JOIN categories cat ON cat.id = i.category_id JOIN categories cat ON cat.id = i.category_id
@@ -327,8 +328,12 @@ router.post('/:id/send', wrap(async (req: AuthRequest, res: Response): Promise<v
await generateInvoicePdf(imagePaths, invoice.guests, pdfPath, user.name); await generateInvoicePdf(imagePaths, invoice.guests, pdfPath, user.name);
// ── Envoi email via Microsoft Graph ────────────────────── // ── Envoi email via Microsoft Graph (tous les destinataires) ────
await sendInvoiceEmail(user.email, user.name, invoice.company_email, pdfPath, pdfFilename); const allRecipients = [
invoice.company_email,
...((invoice.company_extra_emails as string[] | null) ?? []),
].filter(Boolean);
await sendInvoiceEmail(user.email, user.name, allRecipients, pdfPath, pdfFilename);
// ── SharePoint (non bloquant) ───────────────────────────── // ── SharePoint (non bloquant) ─────────────────────────────
let trackingAdded = false; let trackingAdded = false;
+8 -5
View File
@@ -68,14 +68,14 @@ async function getAccessToken(cfg: GraphEmailConfig): Promise<string> {
* *
* @param senderEmail Email de l'expéditeur (user.email en base) * @param senderEmail Email de l'expéditeur (user.email en base)
* @param senderName Nom affiché de l'expéditeur (user.name) * @param senderName Nom affiché de l'expéditeur (user.name)
* @param toEmail Destinataire (company.email) * @param toEmails Destinataires : email principal + éventuels emails supplémentaires
* @param pdfAbsPath Chemin absolu vers le PDF généré * @param pdfAbsPath Chemin absolu vers le PDF généré
* @param pdfFilename Nom du fichier à afficher dans la pièce jointe * @param pdfFilename Nom du fichier à afficher dans la pièce jointe
*/ */
export async function sendInvoiceEmail( export async function sendInvoiceEmail(
senderEmail: string, senderEmail: string,
senderName: string, senderName: string,
toEmail: string, toEmails: string | string[],
pdfAbsPath: string, pdfAbsPath: string,
pdfFilename: string pdfFilename: string
): Promise<void> { ): Promise<void> {
@@ -86,6 +86,11 @@ export async function sendInvoiceEmail(
const pdfBuffer = await fs.readFile(pdfAbsPath); const pdfBuffer = await fs.readFile(pdfAbsPath);
const pdfBase64 = pdfBuffer.toString('base64'); const pdfBase64 = pdfBuffer.toString('base64');
// Normalise en tableau et déduplique
const recipients = [...new Set(
(Array.isArray(toEmails) ? toEmails : [toEmails]).filter(Boolean)
)];
const payload = { const payload = {
message: { message: {
subject: 'Facture à rembourser', subject: 'Facture à rembourser',
@@ -96,9 +101,7 @@ export async function sendInvoiceEmail(
from: { from: {
emailAddress: { name: senderName, address: senderEmail }, emailAddress: { name: senderName, address: senderEmail },
}, },
toRecipients: [ toRecipients: recipients.map(address => ({ emailAddress: { address } })),
{ emailAddress: { address: toEmail } },
],
attachments: [ attachments: [
{ {
'@odata.type': '#microsoft.graph.fileAttachment', '@odata.type': '#microsoft.graph.fileAttachment',
+90 -24
View File
@@ -234,14 +234,20 @@ function CompaniesSection() {
queryFn: () => api.get('/companies').then(r => r.data), queryFn: () => api.get('/companies').then(r => r.data),
}); });
const [editing, setEditing] = useState<Partial<Company> | null>(null); const [editing, setEditing] = useState<(Partial<Company> & { extra_emails: string[] }) | null>(null);
const [extraInput, setExtraInput] = useState('');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const save = useMutation({ const save = useMutation({
mutationFn: (c: Partial<Company>) => c.id mutationFn: (c: Partial<Company> & { extra_emails: string[] }) => c.id
? api.put(`/companies/${c.id}`, c) ? api.put(`/companies/${c.id}`, c)
: api.post('/companies', c), : api.post('/companies', c),
onSuccess: () => { toast.success('Société sauvegardée'); qc.invalidateQueries({ queryKey: ['companies'] }); setEditing(null); }, onSuccess: () => {
toast.success('Société sauvegardée');
qc.invalidateQueries({ queryKey: ['companies'] });
setEditing(null);
setExtraInput('');
},
onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'), onError: (e: any) => toast.error(e.response?.data?.error ?? 'Erreur'),
}); });
@@ -250,6 +256,24 @@ function CompaniesSection() {
onSuccess: () => { toast.success('Société supprimée'); qc.invalidateQueries({ queryKey: ['companies'] }); }, onSuccess: () => { toast.success('Société supprimée'); qc.invalidateQueries({ queryKey: ['companies'] }); },
}); });
function startEdit(c: Company) {
setEditing({ ...c, extra_emails: c.extra_emails ?? [] });
setExtraInput('');
}
function addExtra() {
const v = extraInput.trim().toLowerCase();
if (!v || !v.includes('@')) return;
if (editing && !editing.extra_emails.includes(v)) {
setEditing(p => p ? { ...p, extra_emails: [...p.extra_emails, v] } : p);
}
setExtraInput('');
}
function removeExtra(email: string) {
setEditing(p => p ? { ...p, extra_emails: p.extra_emails.filter(e => e !== email) } : p);
}
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (editing) save.mutate(editing); if (editing) save.mutate(editing);
@@ -280,22 +304,27 @@ function CompaniesSection() {
<div className="border-t border-gray-50"> <div className="border-t border-gray-50">
{/* Liste */} {/* Liste */}
{companies.map(c => ( {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 key={c.id} className="px-4 py-3 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0"> <div className="flex items-center gap-2">
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p> <div className="flex-1 min-w-0">
<p className="text-xs text-gray-400 truncate">{c.email}</p> <p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
<p className="text-xs text-gray-400 truncate">{c.email}
{(c.extra_emails?.length ?? 0) > 0 &&
<span className="ml-1 text-indigo-400">+{c.extra_emails.length}</span>}
</p>
</div>
<button onClick={() => startEdit(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> </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> </div>
))} ))}
@@ -306,19 +335,56 @@ function CompaniesSection() {
{editing.id ? 'Modifier la société' : 'Nouvelle société'} {editing.id ? 'Modifier la société' : 'Nouvelle société'}
</p> </p>
<input className="form-input" placeholder="Nom *" required <input className="form-input" placeholder="Nom *" required
value={editing.name ?? ''} onChange={e => setEditing(p => ({ ...p, name: e.target.value }))} /> value={editing.name ?? ''} onChange={e => setEditing(p => p ? { ...p, name: e.target.value } : p)} />
<input className="form-input" type="email" placeholder="Email destinataire *" required
value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} /> {/* Email principal */}
<div className="flex gap-2"> <div>
<label className="form-label">Email principal *</label>
<input className="form-input" type="email" placeholder="rh@société.fr" required
value={editing.email ?? ''}
onChange={e => setEditing(p => p ? { ...p, email: e.target.value } : p)} />
</div>
{/* Emails supplémentaires */}
<div>
<label className="form-label">Emails supplémentaires</label>
{/* Chips */}
{editing.extra_emails.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{editing.extra_emails.map(em => (
<span key={em}
className="inline-flex items-center gap-1 px-2 py-1 bg-indigo-50 text-indigo-700
text-xs rounded-lg font-medium">
{em}
<button type="button" onClick={() => removeExtra(em)}
className="text-indigo-400 hover:text-indigo-700 leading-none">×</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input className="form-input text-sm flex-1" type="email"
placeholder="autre@société.fr"
value={extraInput}
onChange={e => setExtraInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addExtra(); } }} />
<button type="button" onClick={addExtra}
className="px-3 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl disabled:opacity-40"
disabled={!extraInput.trim()}>+</button>
</div>
</div>
<div className="flex gap-2 pt-1">
<button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm"> <button type="submit" disabled={save.isPending} className="btn-primary py-2 text-sm">
{save.isPending ? '…' : 'Sauvegarder'} {save.isPending ? '…' : 'Sauvegarder'}
</button> </button>
<button type="button" onClick={() => setEditing(null)} className="btn-secondary py-2 text-sm">Annuler</button> <button type="button" onClick={() => { setEditing(null); setExtraInput(''); }}
className="btn-secondary py-2 text-sm">Annuler</button>
</div> </div>
</form> </form>
) : ( ) : (
<div className="p-4"> <div className="p-4">
<button onClick={() => setEditing({})} className="btn-secondary py-2 text-sm"> <button onClick={() => setEditing({ extra_emails: [] })} className="btn-secondary py-2 text-sm">
+ Ajouter une société + Ajouter une société
</button> </button>
</div> </div>
+1
View File
@@ -10,6 +10,7 @@ export interface Company {
id: number; id: number;
name: string; name: string;
email: string; email: string;
extra_emails: string[];
is_active: boolean; is_active: boolean;
created_at: string; created_at: string;
} }