portail-associations/server/materialReturnLitigationPdf.ts

202 lines
6.9 KiB
TypeScript

import PDFDocument from "pdfkit";
import fs from "node:fs";
import path from "node:path";
import { getMaterialEventLabel, materialEventItems, sanitizeMaterialEventQuantityMap, type MaterialEventItemKey } from "@shared/materialEvent";
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
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("[MaterialReturnLitigationPdf] 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(amountCents: number | null | undefined) {
if (!Number.isFinite(amountCents as number)) return "-";
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format((amountCents || 0) / 100);
}
export async function generateMaterialReturnLitigationLetterPdf(input: {
request: {
id: number;
titre: string;
formData?: string | null;
};
association?: {
nomAssociation?: string | null;
emailContact?: string | null;
telephone?: string | null;
ville?: string | null;
} | null;
followup: {
restitutionDate?: string | Date | null;
discrepancyCategories?: string[];
discrepancyDetails?: string | null;
};
arbitration: {
blockedItems: Record<MaterialEventItemKey, number>;
decision: "partial_retention" | "full_retention" | "dismissed";
amountCents: number;
notes?: string | null;
};
}) {
const formData = (() => {
try {
return input.request.formData ? JSON.parse(input.request.formData) : {};
} catch {
return {};
}
})();
const blockedItems = sanitizeMaterialEventQuantityMap(input.arbitration.blockedItems);
const blockedRows = materialEventItems
.map((item) => ({ ...item, quantity: blockedItems[item.key] || 0 }))
.filter((item) => item.quantity > 0);
const decisionLabel =
input.arbitration.decision === "full_retention"
? "Encaissement total de la caution"
: input.arbitration.decision === "partial_retention"
? "Retenue partielle sur caution"
: "Classement sans suite";
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
try {
const doc = new PDFDocument({
size: "A4",
margins: { top: 38, bottom: 42, left: 46, right: 46 },
info: {
Title: `Courrier de litige - Demande ${input.request.id}`,
Author: "Communauté de Communes Des Savanes",
Subject: "Notification de litige sur restitution de matériel",
},
});
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;
drawLogo(doc, doc.page.margins.left, y, 74, 74);
doc.font("Helvetica-Bold").fontSize(16).fillColor("#0f172a").text(
"Notification de litige et arbitrage matériel",
doc.page.margins.left + 92,
y + 14,
{ width: pageWidth - 92 }
);
doc.font("Helvetica").fontSize(10).fillColor("#475569").text(
"Communauté de Communes Des Savanes - Direction des Services aux Usagers",
doc.page.margins.left + 92,
y + 40,
{ width: pageWidth - 92 }
);
y += 96;
const lines = [
["Association", input.association?.nomAssociation || "-"],
["Demande", input.request.titre || `Dossier #${input.request.id}`],
["Commune", formData.commune || input.association?.ville || "-"],
["Date de restitution", formatDateFr(input.followup.restitutionDate)],
["Décision d'arbitrage", decisionLabel],
["Montant retenu", formatCurrency(input.arbitration.amountCents)],
] as const;
lines.forEach(([label, value]) => {
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a").text(`${label} :`, doc.page.margins.left, y, {
width: 170,
});
doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(String(value || "-"), doc.page.margins.left + 170, y, {
width: pageWidth - 170,
});
y += 18;
});
y += 8;
doc.roundedRect(doc.page.margins.left, y, pageWidth, 88, 10).fillAndStroke("#fff7ed", "#fdba74");
doc.font("Helvetica-Bold").fontSize(11).fillColor("#9a3412").text("Constat de terrain", doc.page.margins.left + 14, y + 12);
doc.font("Helvetica").fontSize(10).fillColor("#7c2d12").text(
[
input.followup.discrepancyCategories?.length
? `Réserves : ${input.followup.discrepancyCategories.join(", ")}.`
: null,
input.followup.discrepancyDetails?.trim() || null,
].filter(Boolean).join(" "),
doc.page.margins.left + 14,
y + 32,
{ width: pageWidth - 28 }
);
y += 108;
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Matériel provisoirement bloqué", doc.page.margins.left, y);
y += 18;
if (blockedRows.length === 0) {
doc.font("Helvetica").fontSize(10).fillColor("#475569").text(
"Aucun équipement n'est maintenu en indisponibilité après arbitrage.",
doc.page.margins.left,
y,
{ width: pageWidth }
);
y += 22;
} else {
blockedRows.forEach((item) => {
doc.font("Helvetica").fontSize(10).fillColor("#0f172a").text(
`- ${getMaterialEventLabel(item.key)} : ${item.quantity}`,
doc.page.margins.left + 8,
y,
{ width: pageWidth - 8 }
);
y += 16;
});
}
if (input.arbitration.notes?.trim()) {
y += 12;
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text("Observations administratives", doc.page.margins.left, y);
y += 18;
doc.font("Helvetica").fontSize(10).fillColor("#334155").text(input.arbitration.notes.trim(), doc.page.margins.left, y, {
width: pageWidth,
});
y = doc.y + 10;
}
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text(
`Document généré automatiquement pour le dossier #${input.request.id}.`,
doc.page.margins.left,
Math.max(y + 28, doc.page.height - doc.page.margins.bottom - 18),
{ width: pageWidth, align: "center" }
);
doc.end();
} catch (error) {
reject(error);
}
});
return {
buffer: pdfBuffer,
fileName: `Courrier_Litige_${input.request.id}.pdf`,
};
}