182 lines
8.2 KiB
TypeScript
182 lines
8.2 KiB
TypeScript
import PDFDocument from "pdfkit";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { SallePricingSummary } from "@shared/sallePricing";
|
|
|
|
const CCDS_LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
|
|
|
|
function formatCurrency(cents: number) {
|
|
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(cents / 100);
|
|
}
|
|
|
|
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 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("[SalleQuotePdf] Impossible de charger le logo CCDS:", error);
|
|
}
|
|
}
|
|
|
|
function getScheduleSummary(formData: any) {
|
|
const dailySlots = Array.isArray(formData?.horairesParJour)
|
|
? formData.horairesParJour.filter((slot: any) => slot?.date)
|
|
: [];
|
|
if (Boolean(formData?.useDetailedSchedule) && dailySlots.length > 0) {
|
|
const start = dailySlots[0];
|
|
const end = dailySlots[dailySlots.length - 1];
|
|
return `${formatDateFr(start.date)} au ${formatDateFr(end.date)}`;
|
|
}
|
|
return `${formData?.heureDebut || "?"} à ${formData?.heureFin || "?"}`;
|
|
}
|
|
|
|
export async function generateSalleQuotePdf(input: {
|
|
requestId: number;
|
|
requestTitle: string;
|
|
formData: any;
|
|
association: {
|
|
nomAssociation?: string | null;
|
|
adresse?: string | null;
|
|
codePostal?: string | null;
|
|
ville?: string | null;
|
|
} | null | undefined;
|
|
pricing: SallePricingSummary;
|
|
signedAt?: string | Date | null;
|
|
signedByLabel?: string | null;
|
|
}) {
|
|
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
|
|
try {
|
|
const doc = new PDFDocument({
|
|
size: "A4",
|
|
margins: { top: 36, bottom: 42, left: 34, right: 34 },
|
|
});
|
|
const chunks: Buffer[] = [];
|
|
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
doc.on("error", reject);
|
|
|
|
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
let y = doc.page.margins.top;
|
|
|
|
drawLogo(doc, doc.page.margins.left, y, 180, 70);
|
|
doc.font("Helvetica-Bold").fontSize(18).fillColor("#244f7a").text(
|
|
`Devis N° ${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`,
|
|
doc.page.margins.left,
|
|
y + 34,
|
|
{ width, align: "right" }
|
|
);
|
|
y += 82;
|
|
|
|
const clientAddress = [input.association?.adresse, input.association?.codePostal, input.association?.ville].filter(Boolean).join(" ");
|
|
const infoRows: Array<[string, string]> = [
|
|
["Date", formatDateFr(new Date())],
|
|
["Référence", `${String(input.requestId).padStart(3, "0")}/${new Date().getFullYear()}`],
|
|
["Émis par", "Communauté de Communes Des Savanes"],
|
|
["Client", input.association?.nomAssociation || input.formData.nomAssociation || "-"],
|
|
["Adresse", clientAddress || "-"],
|
|
];
|
|
|
|
const labelWidth = 160;
|
|
infoRows.forEach(([label, value]) => {
|
|
doc.rect(doc.page.margins.left, y, labelWidth, 36).stroke("#d9d9d9");
|
|
doc.rect(doc.page.margins.left + labelWidth, y, width - labelWidth, 36).stroke("#d9d9d9");
|
|
doc.font("Helvetica-Bold").fontSize(10).fillColor("#404040").text(label, doc.page.margins.left + 10, y + 12, { width: labelWidth - 20 });
|
|
doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(value, doc.page.margins.left + labelWidth + 10, y + 12, {
|
|
width: width - labelWidth - 20,
|
|
});
|
|
y += 36;
|
|
});
|
|
|
|
y += 26;
|
|
const col1 = width * 0.54;
|
|
const col2 = width * 0.13;
|
|
const col3 = width * 0.15;
|
|
const col4 = width - col1 - col2 - col3;
|
|
doc.rect(doc.page.margins.left, y, width, 40).fillAndStroke("#244f7a", "#244f7a");
|
|
doc.font("Helvetica-Bold").fontSize(10).fillColor("#ffffff")
|
|
.text("DESCRIPTION", doc.page.margins.left + 14, y + 14, { width: col1 - 20 })
|
|
.text("QUANTITÉ", doc.page.margins.left + col1 + 10, y + 14, { width: col2 - 20, align: "center" })
|
|
.text("PRIX UNITAIRE HT", doc.page.margins.left + col1 + col2 + 10, y + 8, { width: col3 - 20, align: "center" })
|
|
.text("TOTAL TTC", doc.page.margins.left + col1 + col2 + col3 + 10, y + 14, { width: col4 - 20, align: "center" });
|
|
y += 40;
|
|
|
|
input.pricing.lineItems.forEach((line) => {
|
|
const rowHeight = 92;
|
|
doc.rect(doc.page.margins.left, y, col1, rowHeight).stroke("#d9d9d9");
|
|
doc.rect(doc.page.margins.left + col1, y, col2, rowHeight).stroke("#d9d9d9");
|
|
doc.rect(doc.page.margins.left + col1 + col2, y, col3, rowHeight).stroke("#d9d9d9");
|
|
doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, rowHeight).stroke("#d9d9d9");
|
|
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1f2937").text(
|
|
`location de la salle "${line.salleNom.replace("Salle ", "")}"`,
|
|
doc.page.margins.left + 14,
|
|
y + 12,
|
|
{ width: col1 - 24 }
|
|
);
|
|
doc.font("Helvetica").fontSize(10).fillColor("#334155").text(
|
|
`${line.categoryLabel}\n${input.formData.dateReservation ? `Période du ${formatDateFr(input.formData.dateReservation)}${input.formData.dateFinReservation && input.formData.dateFinReservation !== input.formData.dateReservation ? ` au ${formatDateFr(input.formData.dateFinReservation)}` : ""}` : ""}\n${getScheduleSummary(input.formData)}`,
|
|
doc.page.margins.left + 14,
|
|
y + 34,
|
|
{ width: col1 - 24 }
|
|
);
|
|
doc.font("Helvetica-Bold").fontSize(12).fillColor("#1f2937")
|
|
.text(String(line.quantity), doc.page.margins.left + col1, y + 38, { width: col2, align: "center" })
|
|
.text(formatCurrency(line.unitAmountCents), doc.page.margins.left + col1 + col2, y + 38, { width: col3, align: "center" })
|
|
.text(formatCurrency(line.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 38, { width: col4, align: "center" });
|
|
y += rowHeight;
|
|
});
|
|
|
|
doc.rect(doc.page.margins.left, y, col1 + col2 + col3, 44).stroke("#244f7a");
|
|
doc.rect(doc.page.margins.left + col1 + col2 + col3, y, col4, 44).stroke("#244f7a");
|
|
doc.font("Helvetica-Bold").fontSize(12).fillColor("#244f7a")
|
|
.text("TOTAL TTC", doc.page.margins.left, y + 14, { width: col1 + col2 + col3 - 16, align: "right" })
|
|
.fillColor("#c2410c")
|
|
.text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left + col1 + col2 + col3, y + 14, { width: col4, align: "center" });
|
|
y += 64;
|
|
|
|
doc.font("Helvetica-Bold").fontSize(12).fillColor("#111827").text("Arrêter le présent devis à la somme de :", doc.page.margins.left, y);
|
|
y += 22;
|
|
doc.font("Helvetica").fontSize(11).fillColor("#374151").text(formatCurrency(input.pricing.totalAmountCents), doc.page.margins.left, y);
|
|
y += 48;
|
|
|
|
doc.font("Helvetica-Bold").fontSize(10).fillColor("#244f7a").text("COMMUNAUTÉ DE COMMUNES DES SAVANES", doc.page.margins.left, y, { width, align: "center" });
|
|
y += 16;
|
|
doc.font("Helvetica").fontSize(10).fillColor("#475569").text("GESTION ADMINISTRATIVE ET FACTURATION", doc.page.margins.left, y, { width, align: "center" });
|
|
|
|
if (input.signedAt) {
|
|
y += 26;
|
|
doc.roundedRect(doc.page.margins.left, y, width, 56, 8).fillAndStroke("#eef6ff", "#93c5fd");
|
|
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text("Devis validé pour signature Direction", doc.page.margins.left + 16, y + 12, {
|
|
width: width - 32,
|
|
});
|
|
doc.font("Helvetica").fontSize(10).fillColor("#1f2937").text(
|
|
`Validation électronique le ${formatDateFr(input.signedAt)}${input.signedByLabel ? ` par ${input.signedByLabel}` : ""}.`,
|
|
doc.page.margins.left + 16,
|
|
y + 30,
|
|
{ width: width - 32 }
|
|
);
|
|
}
|
|
|
|
doc.end();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
return {
|
|
buffer: pdfBuffer,
|
|
fileName: `devis-salle-ccds-${input.requestId}.pdf`,
|
|
};
|
|
}
|