feat: multi-destinataires email par societe
This commit is contained in:
@@ -48,12 +48,16 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
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
|
||||||
|
extra_emails TEXT[] NOT NULL DEFAULT '{}', -- destinataires supplémentaires
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_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
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
|
|||||||
@@ -12,34 +12,39 @@ const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promi
|
|||||||
|
|
||||||
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]);
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ async function getInvoiceById(id: string) {
|
|||||||
`SELECT i.*,
|
`SELECT i.*,
|
||||||
co.name AS company_name,
|
co.name AS company_name,
|
||||||
co.email AS company_email,
|
co.email AS company_email,
|
||||||
|
co.extra_emails AS company_extra_emails,
|
||||||
cat.name AS category_name,
|
cat.name AS category_name,
|
||||||
u.name AS user_name
|
u.name AS user_name
|
||||||
FROM invoices i
|
FROM invoices i
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,12 +304,16 @@ 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 items-center gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
|
<p className="text-sm font-medium text-gray-900 truncate">{c.name}</p>
|
||||||
<p className="text-xs text-gray-400 truncate">{c.email}</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>
|
</div>
|
||||||
<button onClick={() => setEditing(c)} className="p-1.5 text-gray-300 hover:text-indigo-500">
|
<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}>
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -297,6 +325,7 @@ function CompaniesSection() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Formulaire ajout/modif */}
|
{/* Formulaire ajout/modif */}
|
||||||
@@ -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>
|
||||||
|
<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">
|
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user