portail-associations/server/emailRecap.ts

343 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Génère un récapitulatif complet d'une demande traitée pour diffusion interne.
*/
const requestTypeLabels: Record<string, string> = {
subvention_fonctionnement: "Subvention de fonctionnement",
subvention_projet: "Subvention de projet",
agrement_jeunesse_education: "Agrément Jeunesse et Éducation Populaire",
agrement_sport: "Agrément Sport",
autorisation_occupation: "Autorisation d'occupation",
demande_salle: "Demande de salle / local",
demande_materiel_evenementiel: "Demande de matériel événementiel",
autre: "Autre demande",
};
const statusLabels: Record<string, string> = {
validee: "Validée",
refusee: "Refusée",
en_cours_traitement: "En cours de traitement",
information_complementaire: "Information complémentaire demandée",
};
const statusBadgeStyles: Record<string, { label: string; bg: string; border: string; text: string }> = {
validee: { label: "VALIDÉE", bg: "#ecfdf3", border: "#a7f3d0", text: "#166534" },
refusee: { label: "REFUSÉE", bg: "#fef2f2", border: "#fecaca", text: "#b91c1c" },
en_cours_traitement: { label: "EN COURS", bg: "#eff6ff", border: "#bfdbfe", text: "#1d4ed8" },
information_complementaire: { label: "INFO REQUISE", bg: "#fff7ed", border: "#fed7aa", text: "#c2410c" },
};
interface RecapData {
request: {
id: number;
titre: string;
type: string;
status: string;
description: string | null;
montantDemande: number | null;
montantAccorde: number | null;
commentaireAdmin: string | null;
formData: string | null;
dateSubmission: Date | null;
dateTraitement: Date | null;
createdAt: Date | null;
};
association: {
nomAssociation: string;
siret: string | null;
adresse: string | null;
codePostal: string | null;
ville: string | null;
telephone: string | null;
emailContact: string | null;
nomRepresentant: string | null;
} | null;
traitePar: string;
documents?: { name: string; type: string }[];
serviceLabel?: string | null;
}
function formatDateTimeFr(date: Date | string | null | undefined) {
if (!date) return "Non renseignée";
return new Date(date).toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatDateFr(date: string | null | undefined) {
if (!date) return "Non renseignée";
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatAmount(amount: number | null | undefined) {
if (amount == null) return "Non renseigné";
return `${(amount / 100).toLocaleString("fr-FR")}`;
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function parseRequestHighlights(requestType: string, formData: any): Array<{ label: string; value: string }> {
if (!formData || typeof formData !== "object") return [];
if (requestType === "demande_salle") {
const salles = Array.isArray(formData.sallesNoms)
? formData.sallesNoms.join(", ")
: Array.isArray(formData.sallesSelectionnees)
? formData.sallesSelectionnees.join(", ")
: "";
const highlights = [
salles ? { label: "Salle(s)", value: salles } : null,
formData.dateReservation ? { label: "Date de début", value: formatDateFr(formData.dateReservation) } : null,
formData.dateFinReservation ? { label: "Date de fin", value: formatDateFr(formData.dateFinReservation) } : null,
formData.heureDebut || formData.heureFin
? { label: "Créneau", value: `${formData.heureDebut || "?"} - ${formData.heureFin || "?"}` }
: null,
formData.nombreParticipants ? { label: "Participants", value: String(formData.nombreParticipants) } : null,
formData.motifReservation ? { label: "Motif", value: String(formData.motifReservation) } : null,
];
return highlights.filter(Boolean) as Array<{ label: string; value: string }>;
}
if (requestType === "demande_materiel_evenementiel") {
const highlights = [
formData.commune ? { label: "Commune", value: String(formData.commune) } : null,
formData.dateDebutManifestation || formData.dateManifestation
? { label: "Début manifestation", value: formatDateFr(formData.dateDebutManifestation || formData.dateManifestation) }
: null,
formData.dateFinManifestation
? { label: "Fin manifestation", value: formatDateFr(formData.dateFinManifestation) }
: null,
formData.dateRestitution ? { label: "Restitution", value: formatDateFr(formData.dateRestitution) } : null,
formData.motifDemande ? { label: "Motif", value: String(formData.motifDemande) } : null,
];
return highlights.filter(Boolean) as Array<{ label: string; value: string }>;
}
return [
formData.dateDebut ? { label: "Date de début", value: formatDateFr(formData.dateDebut) } : null,
formData.dateFin ? { label: "Date de fin", value: formatDateFr(formData.dateFin) } : null,
formData.objetProjet ? { label: "Objet", value: String(formData.objetProjet) } : null,
].filter(Boolean) as Array<{ label: string; value: string }>;
}
export function generateRequestRecapEmail(data: RecapData): { title: string; content: string; html: string } {
const { request, association, traitePar, documents, serviceLabel } = data;
const typeLabel = requestTypeLabels[request.type] || request.type;
const statusLabel = statusLabels[request.status] || request.status;
const badge = statusBadgeStyles[request.status] || {
label: statusLabel.toUpperCase(),
bg: "#f3f4f6",
border: "#d1d5db",
text: "#374151",
};
let formData: any = {};
try {
formData = request.formData ? JSON.parse(request.formData) : {};
} catch {
formData = {};
}
const highlights = parseRequestHighlights(request.type, formData);
const subject = `Demande traitée #${request.id} - ${statusLabel} - ${request.titre}`;
const textSections: string[] = [
"Récapitulatif de demande traitée",
"",
`Demande n°${request.id} - ${statusLabel}`,
`Type : ${typeLabel}`,
`Association : ${association?.nomAssociation || "Non renseignée"}`,
`Traitée par : ${traitePar}`,
`Date de traitement : ${formatDateTimeFr(request.dateTraitement)}`,
serviceLabel ? `Service concerné : ${serviceLabel}` : "",
request.commentaireAdmin ? `Commentaire : ${request.commentaireAdmin}` : "",
request.montantAccorde != null ? `Montant accordé : ${formatAmount(request.montantAccorde)}` : "",
"",
"Informations de l'association",
`- Nom : ${association?.nomAssociation || "Non renseigné"}`,
association?.siret ? `- SIRET : ${association.siret}` : "",
association?.adresse ? `- Adresse : ${association.adresse}${association.codePostal ? `, ${association.codePostal}` : ""}${association.ville ? ` ${association.ville}` : ""}` : "",
association?.nomRepresentant ? `- Représentant : ${association.nomRepresentant}` : "",
association?.telephone ? `- Téléphone : ${association.telephone}` : "",
association?.emailContact ? `- Email : ${association.emailContact}` : "",
"",
"Points clés du dossier",
...highlights.map((item) => `- ${item.label} : ${item.value}`),
request.description ? `- Description : ${request.description}` : "",
request.montantDemande != null ? `- Montant demandé : ${formatAmount(request.montantDemande)}` : "",
];
if (documents?.length) {
textSections.push("", "Documents joints au dossier");
textSections.push(...documents.map((doc, index) => `${index + 1}. ${doc.name} (${doc.type})`));
}
textSections.push(
"",
"Le PDF récapitulatif est joint à cet email lorsqu'il est disponible pour ce type de demande.",
"",
"Ce message a été généré automatiquement par le Portail Associations."
);
const htmlHighlights = highlights
.map(
(item) => `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;width:180px;vertical-align:top;">${escapeHtml(item.label)}</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(item.value)}</td>
</tr>`
)
.join("");
const htmlDocuments = documents?.length
? `
<div style="margin-top:24px;padding:20px;border:1px solid #e5e7eb;border-radius:14px;background:#ffffff;">
<h3 style="margin:0 0 12px 0;font-size:16px;color:#111827;">Documents du dossier</h3>
<ul style="margin:0;padding-left:18px;color:#374151;font-size:14px;line-height:1.6;">
${documents.map((doc) => `<li>${escapeHtml(doc.name)} <span style="color:#6b7280;">(${escapeHtml(doc.type)})</span></li>`).join("")}
</ul>
</div>`
: "";
const html = `
<div style="margin:0;padding:32px 0;background:#f5f7fb;font-family:Arial,Helvetica,sans-serif;color:#111827;">
<div style="max-width:720px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:20px;overflow:hidden;box-shadow:0 10px 30px rgba(15,23,42,0.08);">
<div style="padding:28px 32px;background:linear-gradient(135deg,#000091 0%,#163d8f 100%);color:#ffffff;">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;opacity:0.8;">Portail Associations</div>
<h1 style="margin:10px 0 0 0;font-size:24px;line-height:1.25;">Transmission d'une demande traitée</h1>
<p style="margin:10px 0 0 0;font-size:14px;line-height:1.6;opacity:0.92;">
Ce message reprend les informations essentielles du dossier et le PDF récapitulatif est joint lorsqu'il est disponible.
</p>
</div>
<div style="padding:28px 32px;">
<div style="display:inline-block;padding:8px 14px;border-radius:999px;background:${badge.bg};border:1px solid ${badge.border};color:${badge.text};font-size:12px;font-weight:700;letter-spacing:0.04em;">
${escapeHtml(badge.label)}
</div>
<h2 style="margin:18px 0 6px 0;font-size:22px;line-height:1.3;">${escapeHtml(request.titre)}</h2>
<p style="margin:0;color:#4b5563;font-size:15px;line-height:1.7;">
Demande <strong>#${request.id}</strong> • ${escapeHtml(typeLabel)}
</p>
<div style="margin-top:24px;padding:20px;border:1px solid #dbeafe;border-radius:16px;background:#f8fbff;">
<h3 style="margin:0 0 14px 0;font-size:16px;color:#0f172a;">Décision et transmission</h3>
<table style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;width:180px;">Traitée par</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(traitePar)}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Date de traitement</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(formatDateTimeFr(request.dateTraitement))}</td>
</tr>
${serviceLabel ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Service concerné</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(serviceLabel)}</td>
</tr>` : ""}
${request.montantAccorde != null ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Montant accordé</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(formatAmount(request.montantAccorde))}</td>
</tr>` : ""}
</table>
${request.commentaireAdmin ? `
<div style="margin-top:14px;padding:14px 16px;border-radius:12px;background:#ffffff;border:1px solid #e5e7eb;">
<div style="font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">Commentaire administratif</div>
<div style="font-size:14px;line-height:1.7;color:#111827;">${escapeHtml(request.commentaireAdmin).replace(/\n/g, "<br />")}</div>
</div>` : ""}
</div>
<div style="margin-top:24px;padding:20px;border:1px solid #e5e7eb;border-radius:16px;background:#ffffff;">
<h3 style="margin:0 0 14px 0;font-size:16px;color:#111827;">Association</h3>
<table style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;width:180px;">Nom</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association?.nomAssociation || "Non renseignée")}</td>
</tr>
${association?.siret ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">SIRET</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association.siret)}</td>
</tr>` : ""}
${(association?.adresse || association?.ville || association?.codePostal) ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Adresse</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(
[association?.adresse, association?.codePostal, association?.ville].filter(Boolean).join(" ")
)}</td>
</tr>` : ""}
${association?.nomRepresentant ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Représentant</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association.nomRepresentant)}</td>
</tr>` : ""}
${association?.telephone ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Téléphone</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association.telephone)}</td>
</tr>` : ""}
${association?.emailContact ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Email</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(association.emailContact)}</td>
</tr>` : ""}
</table>
</div>
<div style="margin-top:24px;padding:20px;border:1px solid #e5e7eb;border-radius:16px;background:#ffffff;">
<h3 style="margin:0 0 14px 0;font-size:16px;color:#111827;">Points clés du dossier</h3>
<table style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;width:180px;">Date de soumission</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(formatDateTimeFr(request.dateSubmission))}</td>
</tr>
${request.montantDemande != null ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;">Montant demandé</td>
<td style="padding:8px 0;color:#111827;font-size:14px;font-weight:600;">${escapeHtml(formatAmount(request.montantDemande))}</td>
</tr>` : ""}
${htmlHighlights}
${request.description ? `
<tr>
<td style="padding:8px 0;color:#6b7280;font-size:14px;vertical-align:top;">Description</td>
<td style="padding:8px 0;color:#111827;font-size:14px;line-height:1.7;">${escapeHtml(request.description).replace(/\n/g, "<br />")}</td>
</tr>` : ""}
</table>
</div>
${htmlDocuments}
<div style="margin-top:24px;padding:18px 20px;border-radius:14px;background:#f9fafb;border:1px dashed #d1d5db;">
<p style="margin:0;color:#374151;font-size:14px;line-height:1.7;">
Le PDF récapitulatif est joint à cet email lorsquil est disponible pour ce type de demande. Tu peux lutiliser pour la transmission interne, larchivage ou limpression.
</p>
</div>
</div>
</div>
</div>
`;
return {
title: subject,
content: textSections.filter(Boolean).join("\n"),
html,
};
}