import PDFDocument from "pdfkit"; import fs from "node:fs"; import path from "node:path"; const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png"); export type MaterialContractFinancialMode = "gratuite" | "gratuite_avec_caution" | "location_payante"; export type MaterialContractStatus = "a_generer" | "generee" | "signee" | "refusee" | "annulee"; type ContractRequestLike = { id: number; titre: string; status: string; formData?: string | null; }; type ContractAssociationLike = { nomAssociation?: string | null; adresse?: string | null; codePostal?: string | null; ville?: string | null; telephone?: string | null; emailContact?: string | null; nomRepresentant?: string | null; } | null | undefined; type ContractDecision = { financialMode: MaterialContractFinancialMode; depositRequired: boolean; depositAmountCents: number; rentalAmountCents: number; pricingNotes?: string | null; contractStatus: MaterialContractStatus; contractGeneratedAt?: string | Date | null; contractValidatedByUserId?: number | null; }; function drawLogo(doc: PDFKit.PDFDocument, x: number, y: number, width: number, height: number) { if (!fs.existsSync(CCDS_LOGO_PATH)) return; try { doc.image(CCDS_LOGO_PATH, x, y, { fit: [width, height], align: "center", valign: "center" }); } catch (error) { console.warn("[MaterialConventionPdf] Impossible de charger le logo CCDS:", error); } } function formatDateFr(date: string | Date | null | undefined) { if (!date) return "-"; try { return new Date(date).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric", }); } catch { return String(date); } } function formatCurrency(cents: number | null | undefined) { return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format((cents || 0) / 100); } function getFinancialModeLabel(mode: MaterialContractFinancialMode) { switch (mode) { case "gratuite": return "Mise à disposition gratuite"; case "gratuite_avec_caution": return "Mise à disposition gratuite avec caution"; case "location_payante": return "Location payante"; default: return mode; } } export async function generateMaterialConventionPdf(input: { request: ContractRequestLike; association?: ContractAssociationLike; decision: ContractDecision; }) { const formData = (() => { try { return input.request.formData ? JSON.parse(input.request.formData) : {}; } catch { return {}; } })(); const requestedRows = Object.entries(formData.cadreDSU?.materielEvent?.itemsAccordes || {}) .filter(([, granted]) => Boolean(granted)) .map(([key]) => ({ key, label: key === "tente3x3" ? "Tente 3x3" : key === "chapiteau5x5" ? "Chapiteau 5x5" : key === "podium" ? "Podium" : "Autres", quantity: formData.cadreDSU?.materielEvent?.quantitesAccordees?.[key] || "-", extra: key === "autres" ? formData.cadreDSU?.materielEvent?.autresPrecisions || "" : "", })); const manifestationStart = formData.dateDebutManifestation || formData.dateManifestation; const manifestationEnd = formData.dateFinManifestation || formData.dateManifestation; const pdfBuffer = await new Promise((resolve, reject) => { try { const doc = new PDFDocument({ size: "A4", margins: { top: 38, bottom: 42, left: 42, right: 42 }, info: { Title: `Convention matériel CCDS - dossier ${input.request.id}`, Author: "Communauté de Communes Des Savanes", Subject: "Convention de mise à disposition / location du matériel événementiel", }, }); const chunks: Buffer[] = []; doc.on("data", (chunk: Buffer) => chunks.push(chunk)); doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject); const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; let y = doc.page.margins.top; doc.lineWidth(2).strokeColor("#efb100").moveTo(doc.page.margins.left, y).lineTo(doc.page.margins.left + pageWidth, y).stroke(); y += 16; drawLogo(doc, doc.page.margins.left + (pageWidth - 84) / 2, y, 84, 84); y += 92; doc.font("Helvetica").fontSize(9).fillColor("#64748b").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, { width: pageWidth, align: "center", }); y += 18; doc.font("Helvetica-Bold").fontSize(17).fillColor("#0f172a").text( "CONVENTION DE MISE À DISPOSITION / LOCATION", doc.page.margins.left, y, { width: pageWidth, align: "center" } ); y += 20; doc.font("Helvetica-Bold").fontSize(14).text("DU MATÉRIEL ÉVÉNEMENTIEL DE LA CCDS", doc.page.margins.left, y, { width: pageWidth, align: "center", }); y += 28; doc.roundedRect(doc.page.margins.left, y, pageWidth, 42, 8).fillAndStroke("#eef5ff", "#bfd2ef"); doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f2d63").text( "Document généré à partir de la décision administrative et de l'annexe de référence CCDS", doc.page.margins.left + 14, y + 9, { width: pageWidth - 28, align: "center" } ); doc.font("Helvetica").fontSize(9).fillColor("#334155").text( `Dossier #${input.request.id} — statut contrat : ${input.decision.contractStatus}`, doc.page.margins.left + 14, y + 23, { width: pageWidth - 28, align: "center" } ); y += 56; const infoRows: Array<[string, string]> = [ ["Association", input.association?.nomAssociation || formData.nomAssociation || "-"], ["Représentant", input.association?.nomRepresentant || formData.demandeurNomPrenom || "-"], ["Adresse", [input.association?.adresse, input.association?.codePostal, input.association?.ville].filter(Boolean).join(" ") || "-"], ["Téléphone", input.association?.telephone || formData.telephoneAssociation || "-"], ["Email", input.association?.emailContact || formData.emailAssociation || "-"], ["Commune / manifestation", formData.commune || "-"], [ "Période d'utilisation", manifestationStart || manifestationEnd ? `${formatDateFr(manifestationStart)}${manifestationEnd ? ` au ${formatDateFr(manifestationEnd)}` : ""}` : "-", ], ["Prise en charge", formatDateFr(formData.datePriseEnCharge)], ["Restitution", formatDateFr(formData.dateRestitution)], ["Objet", formData.motifDemande || input.request.titre || "-"], ]; for (const [label, value] of infoRows) { doc.font("Helvetica-Bold").fontSize(9).fillColor("#334155").text(`${label} :`, doc.page.margins.left, y, { width: 140 }); doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(value, doc.page.margins.left + 145, y - 1, { width: pageWidth - 145, }); y += 18; } y += 6; doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel accordé", doc.page.margins.left, y); y += 16; const cols = [pageWidth * 0.56, pageWidth * 0.14, pageWidth * 0.30]; let x = doc.page.margins.left; ["Équipement", "Qté", "Observation"].forEach((header, index) => { doc.rect(x, y, cols[index], 22).fillAndStroke("#dce8f8", "#bfd2ef"); doc.font("Helvetica-Bold").fontSize(9).fillColor("#0f2d63").text(header, x + 6, y + 7, { width: cols[index] - 12, align: index === 0 ? "left" : "center", }); x += cols[index]; }); y += 22; const rows = requestedRows.length > 0 ? requestedRows : [{ key: "none", label: "Aucun matériel accordé", quantity: "-", extra: "" }]; rows.forEach((row) => { const rowHeight = row.extra ? 34 : 26; let rowX = doc.page.margins.left; [row.label, row.quantity, row.extra || "—"].forEach((value, index) => { doc.rect(rowX, y, cols[index], rowHeight).stroke("#cbd5e1"); doc.font("Helvetica").fontSize(9).fillColor("#0f172a").text(String(value), rowX + 6, y + 7, { width: cols[index] - 12, align: index === 0 ? "left" : "center", }); rowX += cols[index]; }); y += rowHeight; }); y += 18; doc.roundedRect(doc.page.margins.left, y, pageWidth, 108, 8).fillAndStroke("#f8fafc", "#cbd5e1"); doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Conditions financières", doc.page.margins.left + 14, y + 12); doc.font("Helvetica").fontSize(10).fillColor("#334155") .text(`Régime retenu : ${getFinancialModeLabel(input.decision.financialMode)}`, doc.page.margins.left + 14, y + 34, { width: pageWidth - 28, }) .text(`Montant de location : ${formatCurrency(input.decision.rentalAmountCents)}`, doc.page.margins.left + 14, y + 52, { width: pageWidth - 28, }) .text( `Caution : ${input.decision.depositRequired ? formatCurrency(input.decision.depositAmountCents) : "Aucune caution exigée"}`, doc.page.margins.left + 14, y + 70, { width: pageWidth - 28 } ); y += 122; if (input.decision.pricingNotes?.trim()) { doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Clauses / réserves spécifiques", doc.page.margins.left, y); y += 16; doc.roundedRect(doc.page.margins.left, y, pageWidth, 70, 8).stroke("#cbd5e1"); doc.font("Helvetica").fontSize(10).fillColor("#334155").text(input.decision.pricingNotes.trim(), doc.page.margins.left + 12, y + 12, { width: pageWidth - 24, }); y += 84; } y += 6; doc.font("Helvetica").fontSize(9).fillColor("#475569").text( "Cette convention formalise les conditions administratives retenues par la CCDS pour la mise à disposition ou la location du matériel événementiel.", doc.page.margins.left, y, { width: pageWidth } ); y += 34; const signatureWidth = (pageWidth - 20) / 2; doc.roundedRect(doc.page.margins.left, y, signatureWidth, 90, 8).stroke("#cbd5e1"); doc.roundedRect(doc.page.margins.left + signatureWidth + 20, y, signatureWidth, 90, 8).stroke("#cbd5e1"); doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a") .text("Pour l'association", doc.page.margins.left + 12, y + 12, { width: signatureWidth - 24 }) .text("Pour la CCDS / DSU", doc.page.margins.left + signatureWidth + 32, y + 12, { width: signatureWidth - 24 }); doc.font("Helvetica").fontSize(9).fillColor("#64748b") .text("Nom, qualité, signature", doc.page.margins.left + 12, y + 32, { width: signatureWidth - 24 }) .text("Nom, qualité, signature", doc.page.margins.left + signatureWidth + 32, y + 32, { width: signatureWidth - 24 }); doc.font("Helvetica").fontSize(8).fillColor("#64748b").text( `Convention générée le ${formatDateFr(input.decision.contractGeneratedAt || new Date())}.`, doc.page.margins.left, doc.page.height - doc.page.margins.bottom - 12, { width: pageWidth, align: "center" } ); doc.end(); } catch (error) { reject(error); } }); return { buffer: pdfBuffer, fileName: `Convention_Materiel_CCDS_${input.request.id}.pdf`, }; }