portail-associations/server/pdfGenerator.ts

958 lines
36 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.

import PDFDocument from 'pdfkit';
import fs from 'node:fs';
import path from 'node:path';
import { SALLE_FREQUENCY_LABELS, SALLE_USAGE_TYPE_LABELS } from '@shared/sallePricing';
interface ReservationPDFData {
titre: string;
status: string;
createdAt: string | Date;
dateSubmission?: string | Date | null;
dateTraitement?: string | Date | null;
commentaireAdmin?: string | null;
formData: {
nomAssociation?: string;
adresseAssociation?: string;
communeSiege?: string;
representantLegal?: string;
telephoneAssociation?: string;
emailAssociation?: string;
sallesSelectionnees?: string[];
motifReservation?: string;
dateReservation?: string;
dateFinReservation?: string;
heureDebut?: string;
heureFin?: string;
useDetailedSchedule?: boolean;
horairesParJour?: Array<{
date?: string;
heureDebut?: string;
heureFin?: string;
}>;
typeUsage?: 'conventionne' | 'occasionnel';
frequence?: 'demi_journee' | 'journee' | 'mensuel';
nombreParticipants?: string;
besoinsComplementaires?: string;
materielNecessaire?: string;
observations?: string;
salleWorkflow?: {
pricing?: {
totalAmountCents?: number;
};
directorSignedAt?: string;
directorSignedByName?: string;
};
cadreDSU?: {
dateReception?: string;
avisDSU?: string;
conditionsParticulieres?: string;
cautionRequise?: boolean;
montantCaution?: string;
assuranceRequise?: boolean;
horairesImposes?: string;
responsableDSU?: string;
dateDecision?: string;
observationsDSU?: string;
materielEvent?: {
financialDecision?: {
financialMode?: 'gratuite' | 'gratuite_avec_caution' | 'location_payante';
depositRequired?: boolean;
depositAmountCents?: number;
rentalAmountCents?: number;
pricingNotes?: string;
contractStatus?: 'a_generer' | 'generee' | 'signee' | 'refusee' | 'annulee';
};
};
};
};
}
interface MaterialEventPDFData {
titre: string;
status: string;
createdAt: string | Date;
dateSubmission?: string | Date | null;
dateTraitement?: string | Date | null;
commentaireAdmin?: string | null;
formData: {
nomAssociation?: string;
adresseAssociation?: string;
communeSiege?: string;
representantLegal?: string;
telephoneAssociation?: string;
emailAssociation?: string;
commune?: string;
direction?: string;
service?: string;
demandeurNomPrenom?: string;
dateDemande?: string;
dateManifestation?: string;
dateDebutManifestation?: string;
dateFinManifestation?: string;
motifDemande?: string;
datePriseEnCharge?: string;
dateRestitution?: string;
autreMaterielPrecisions?: string;
materielsDemandes?: Record<string, boolean>;
quantitesDemandees?: Record<string, string>;
cadreDSU?: {
dateReception?: string;
cautionRequise?: boolean;
montantCaution?: string;
responsableDSU?: string;
dateDecision?: string;
observationsDSU?: string;
materielEvent?: {
itemsAccordes?: Record<string, boolean>;
quantitesAccordees?: Record<string, string>;
autresPrecisions?: string;
financialDecision?: {
financialMode?: 'gratuite' | 'gratuite_avec_caution' | 'location_payante';
depositRequired?: boolean;
depositAmountCents?: number;
rentalAmountCents?: number;
pricingNotes?: string;
contractStatus?: 'a_generer' | 'generee' | 'signee' | 'refusee' | 'annulee';
contractGeneratedAt?: string | Date | null;
};
};
};
};
}
const statusLabels: Record<string, string> = {
brouillon: 'Brouillon',
soumise: 'Soumise',
en_cours_traitement: 'En cours de traitement',
information_complementaire: 'Information complémentaire requise',
validee: 'Validée',
refusee: 'Refusée',
};
const avisDSULabels: Record<string, string> = {
favorable: 'Favorable',
defavorable: 'Défavorable',
favorable_avec_reserves: 'Favorable avec réserves',
};
const materialEventLabels: Record<string, string> = {
tente3x3: 'Tente 3x3',
chapiteau5x5: 'Chapiteau 5x5',
podium: 'Podium',
autres: 'Autres',
};
const CCDS_LOGO_PATH = path.resolve(process.cwd(), 'client/src/assets/ccds.png');
function formatDateFr(dateStr: string | Date | null | undefined): string {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
} catch {
return String(dateStr);
}
}
function formatCurrency(cents: number | null | undefined): string {
if (cents === null || cents === undefined || !Number.isFinite(cents)) return '-';
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(cents / 100);
}
function getFinancialModeLabel(mode: string | null | undefined): string {
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 '-';
}
}
function getContractStatusLabel(status: string | null | undefined): string {
switch (status) {
case 'a_generer':
return 'À générer';
case 'generee':
return 'Générée';
case 'signee':
return 'Signée';
case 'refusee':
return 'Refusée';
case 'annulee':
return 'Annulée';
default:
return '-';
}
}
function formatMaterialEventPeriod(formData: {
dateManifestation?: string;
dateDebutManifestation?: string;
dateFinManifestation?: string;
}): string {
const start = formData.dateDebutManifestation || formData.dateManifestation;
const end = formData.dateFinManifestation || formData.dateManifestation;
if (!start && !end) return '-';
if (start && end && start !== end) {
return `${formatDateFr(start)} au ${formatDateFr(end)}`;
}
return formatDateFr(start || end);
}
function drawCcdsLogo(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('[PDF] Impossible de charger le logo CCDS:', error);
}
}
// Color definitions (RGB)
const COLORS = {
primary: [0, 0, 102] as [number, number, number], // oklch(0.25 0.05 265) approx
primaryLight: [230, 230, 245] as [number, number, number],
text: [30, 30, 30] as [number, number, number],
muted: [120, 120, 120] as [number, number, number],
border: [200, 200, 200] as [number, number, number],
white: [255, 255, 255] as [number, number, number],
green: [34, 139, 34] as [number, number, number],
red: [220, 20, 60] as [number, number, number],
amber: [200, 150, 0] as [number, number, number],
blueBg: [235, 245, 255] as [number, number, number],
blueBorder: [180, 210, 240] as [number, number, number],
blueText: [50, 80, 140] as [number, number, number],
};
export function generateReservationPDF(data: ReservationPDFData): Promise<Buffer> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margins: { top: 40, bottom: 40, left: 50, right: 50 },
info: {
Title: `Formulaire de Réservation - ${data.formData.nomAssociation || 'Association'}`,
Author: 'Communauté de Communes Des Savanes',
Subject: 'Réservation de local - Maison de la Jeunesse des Savanes',
},
});
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;
const dateDebut = formatDateFr(data.formData.dateReservation);
const dateFin = data.formData.dateFinReservation && data.formData.dateFinReservation !== data.formData.dateReservation
? formatDateFr(data.formData.dateFinReservation)
: dateDebut;
const dailySlots = Array.isArray(data.formData.horairesParJour)
? data.formData.horairesParJour.filter((slot) => slot?.date)
: [];
const creneau = data.formData.heureDebut || data.formData.heureFin
? `${data.formData.heureDebut || '?'}${data.formData.heureFin || '?'}`
: '-';
doc.save();
doc.lineWidth(2)
.strokeColor('#f0c94b')
.moveTo(doc.page.margins.left, y)
.lineTo(doc.page.margins.left + pageWidth, y)
.stroke();
doc.restore();
y += 16;
drawCcdsLogo(doc, doc.page.margins.left + (pageWidth - 90) / 2, y, 90, 90);
y += 96;
doc.fillColor(COLORS.muted)
.fontSize(9)
.font('Helvetica')
.text('COMMUNAUTÉ DE COMMUNES DES SAVANES', doc.page.margins.left, y, {
width: pageWidth,
align: 'center',
});
y += 18;
doc.fillColor(COLORS.text)
.fontSize(18)
.font('Helvetica-Bold')
.text('FORMULAIRE ADMINISTRATIF DE RÉSERVATION', doc.page.margins.left, y, {
width: pageWidth,
align: 'center',
});
y += 20;
doc.text('D\'UN LOCAL - MAISON DE LA JEUNESSE DES SAVANES', doc.page.margins.left, y, {
width: pageWidth,
align: 'center',
});
y += 26;
doc.save();
doc.roundedRect(doc.page.margins.left + 18, y, pageWidth - 36, 34, 4)
.fill('#d9eefc')
.stroke('#f0c94b');
doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold')
.text('Réservation à transmettre à la Direction des Services aux Usagers', doc.page.margins.left + 28, y + 7, {
width: pageWidth - 56,
align: 'center',
});
doc.fontSize(9).font('Helvetica')
.text('Merci de compléter les informations demandées avant instruction', doc.page.margins.left + 28, y + 19, {
width: pageWidth - 56,
align: 'center',
});
doc.restore();
y += 50;
// Statut de la demande
const statusLabel = statusLabels[data.status] || data.status;
doc.fillColor(COLORS.muted)
.fontSize(8)
.font('Helvetica')
.text(`Statut : ${statusLabel} | Créée le : ${formatDateFr(data.createdAt)}`, doc.page.margins.left, y, {
width: pageWidth,
align: 'right',
});
y += 18;
// ==========================================
// SECTION 1 : INFORMATIONS DE L'ASSOCIATION
// ==========================================
y = drawSectionTitle(doc, 'INFORMATIONS DE L\'ASSOCIATION', y, pageWidth);
const assoRows = [
['Nom de l\'association', data.formData.nomAssociation || '-'],
['Adresse', data.formData.adresseAssociation || '-'],
['Commune du siège', data.formData.communeSiege || '-'],
['Représentant légal', data.formData.representantLegal || '-'],
['Téléphone', data.formData.telephoneAssociation || '-'],
['Email', data.formData.emailAssociation || '-'],
];
y = drawTable(doc, assoRows, y, pageWidth);
y += 15;
// ==========================================
// SECTION 2 : LOCAL(AUX) SOLLICITÉ(S)
// ==========================================
y = drawSectionTitle(doc, 'LOCAL(AUX) SOLLICITÉ(S)', y, pageWidth);
const salles = data.formData.sallesSelectionnees?.join(', ') || '-';
doc.fillColor(COLORS.text)
.fontSize(10)
.font('Helvetica')
.text(salles, doc.page.margins.left + 5, y, { width: pageWidth - 10 });
y += doc.heightOfString(salles, { width: pageWidth - 10 }) + 15;
y = checkPageBreak(doc, y, 200);
y = drawSectionTitle(doc, 'DÉTAILS DE LA RÉSERVATION', y, pageWidth);
y = drawTable(doc, [['Motif de la réservation', data.formData.motifReservation || '-']], y, pageWidth);
y += 8;
const hasDetailedSchedule = Boolean(data.formData.useDetailedSchedule && dailySlots.length > 0);
const scheduleBoxHeight = hasDetailedSchedule ? 70 + (dailySlots.length * 18) : 70;
y = checkPageBreak(doc, y, scheduleBoxHeight + 18);
doc.save();
doc.roundedRect(doc.page.margins.left, y, pageWidth, scheduleBoxHeight, 4)
.fill('#f8fafc')
.stroke(COLORS.border);
doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold')
.text('PÉRIODE ET CRÉNEAU SOLLICITÉS', doc.page.margins.left + 14, y + 10, { width: pageWidth - 28 });
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Du', doc.page.margins.left + 14, y + 34);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(dateDebut, doc.page.margins.left + 34, y + 33);
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Au', doc.page.margins.left + pageWidth / 2, y + 34);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(dateFin, doc.page.margins.left + pageWidth / 2 + 20, y + 33, {
width: pageWidth / 2 - 34,
});
if (hasDetailedSchedule) {
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Organisation', doc.page.margins.left + 14, y + 52);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(`Horaires détaillés sur ${dailySlots.length} jour(s)`, doc.page.margins.left + 72, y + 51, {
width: pageWidth - 86,
});
let slotY = y + 68;
dailySlots.forEach((slot) => {
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text(formatDateFr(slot.date || ''), doc.page.margins.left + 28, slotY);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(`${slot.heureDebut || '?'}${slot.heureFin || '?'}`, doc.page.margins.left + 160, slotY, {
width: pageWidth - 190,
});
slotY += 16;
});
} else {
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Créneau', doc.page.margins.left + 14, y + 52);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(creneau, doc.page.margins.left + 72, y + 51, {
width: pageWidth - 86,
});
}
doc.restore();
y += scheduleBoxHeight + 12;
const detailRows = [
['Nombre prévisionnel de participants', data.formData.nombreParticipants ? `${data.formData.nombreParticipants} personnes` : '-'],
['Type dusage', data.formData.typeUsage ? SALLE_USAGE_TYPE_LABELS[data.formData.typeUsage] : '-'],
['Fréquence', data.formData.frequence ? SALLE_FREQUENCY_LABELS[data.formData.frequence] : '-'],
['Besoins complémentaires', data.formData.besoinsComplementaires || '-'],
['Matériel nécessaire', data.formData.materielNecessaire || '-'],
['Observations complémentaires', data.formData.observations || '-'],
['Montant estimatif / validé', typeof data.formData.salleWorkflow?.pricing?.totalAmountCents === 'number' ? formatCurrency(data.formData.salleWorkflow.pricing.totalAmountCents) : '-'],
];
y = drawTable(doc, detailRows, y, pageWidth);
y += 18;
// ==========================================
// SECTION 4 : CADRE RÉSERVÉ À LA DSU
// ==========================================
y = checkPageBreak(doc, y, 250);
y = drawSectionTitle(doc, 'CADRE RÉSERVÉ À LA DIRECTION DES SERVICES AUX USAGERS (DSU)', y, pageWidth);
const dsu = data.formData.cadreDSU;
if (dsu) {
const financialDecision = dsu.materielEvent?.financialDecision;
// Avis DSU avec couleur
if (dsu.avisDSU) {
const avisLabel = avisDSULabels[dsu.avisDSU] || dsu.avisDSU;
const avisColor = dsu.avisDSU === 'favorable' ? COLORS.green
: dsu.avisDSU === 'defavorable' ? COLORS.red
: COLORS.amber;
doc.fillColor(COLORS.muted)
.fontSize(9)
.font('Helvetica')
.text('Avis de la DSU : ', doc.page.margins.left + 5, y, { continued: true });
doc.fillColor(avisColor)
.font('Helvetica-Bold')
.text(avisLabel);
y += 18;
}
const dsuRows = [
['Date de réception', dsu.dateReception ? formatDateFr(dsu.dateReception) : '-'],
['Date de la décision', dsu.dateDecision ? formatDateFr(dsu.dateDecision) : '-'],
['Responsable DSU', dsu.responsableDSU || '-'],
];
y = drawTable(doc, dsuRows, y, pageWidth);
y += 10;
// Conditions particulières
const conditions: string[] = [];
if (dsu.cautionRequise) {
conditions.push(`Caution requise${dsu.montantCaution ? ` : ${dsu.montantCaution}` : ''}`);
}
if (dsu.assuranceRequise) {
conditions.push('Attestation d\'assurance requise');
}
if (dsu.horairesImposes) {
conditions.push(`Horaires imposés : ${dsu.horairesImposes}`);
}
if (dsu.conditionsParticulieres) {
conditions.push(dsu.conditionsParticulieres);
}
if (conditions.length > 0) {
y = checkPageBreak(doc, y, 80);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Conditions particulières :', doc.page.margins.left + 5, y);
y += 14;
for (const condition of conditions) {
y = checkPageBreak(doc, y, 20);
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica')
.text(`${condition}`, doc.page.margins.left + 15, y, { width: pageWidth - 25 });
y += doc.heightOfString(`${condition}`, { width: pageWidth - 25 }) + 4;
}
y += 5;
}
if (financialDecision?.financialMode) {
y = checkPageBreak(doc, y, 96);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Conditions financières / location :', doc.page.margins.left + 5, y);
y += 14;
const financialRows = [
['Mode financier', getFinancialModeLabel(financialDecision.financialMode)],
['Caution / dépôt', financialDecision.depositRequired ? formatCurrency(financialDecision.depositAmountCents ?? 0) : 'Aucune'],
['Montant de location', financialDecision.financialMode === 'location_payante' ? formatCurrency(financialDecision.rentalAmountCents ?? 0) : 'Non applicable'],
['Statut de la convention', getContractStatusLabel(financialDecision.contractStatus)],
];
y = drawTable(doc, financialRows, y, pageWidth);
y += 8;
if (financialDecision.pricingNotes) {
y = checkPageBreak(doc, y, 50);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Clauses spécifiques :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica')
.text(financialDecision.pricingNotes, doc.page.margins.left + 15, y, { width: pageWidth - 25 });
y += doc.heightOfString(financialDecision.pricingNotes, { width: pageWidth - 25 }) + 10;
}
}
// Observations DSU
if (dsu.observationsDSU) {
y = checkPageBreak(doc, y, 50);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Observations de la DSU :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica')
.text(dsu.observationsDSU, doc.page.margins.left + 15, y, { width: pageWidth - 25 });
y += doc.heightOfString(dsu.observationsDSU, { width: pageWidth - 25 }) + 10;
}
} else {
// Cadre vide avec lignes pointillées
const emptyRows = [
['Date de réception', ''],
['Avis', ''],
['Conditions particulières', ''],
['Responsable DSU', ''],
['Date de la décision', ''],
['Observations', ''],
];
y = drawTable(doc, emptyRows, y, pageWidth, true);
}
y += 18;
y = checkPageBreak(doc, y, 80);
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left + 10, y + 28)
.lineTo(doc.page.margins.left + pageWidth / 2 - 20, y + 28)
.stroke();
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left + pageWidth / 2 + 20, y + 28)
.lineTo(doc.page.margins.left + pageWidth - 10, y + 28)
.stroke();
doc.fillColor(COLORS.text).fontSize(9).font('Helvetica')
.text('Signature du représentant de l\'association', doc.page.margins.left + 10, y + 34, {
width: pageWidth / 2 - 30,
align: 'center',
})
.text('Visa de la DSU', doc.page.margins.left + pageWidth / 2 + 20, y + 34, {
width: pageWidth / 2 - 30,
align: 'center',
});
const directorSignedAt = data.formData.salleWorkflow?.directorSignedAt;
const directorSignedByName = data.formData.salleWorkflow?.directorSignedByName;
if (directorSignedAt) {
doc.fillColor(COLORS.blueText).fontSize(8).font('Helvetica-Bold')
.text('Validé électroniquement', doc.page.margins.left + pageWidth / 2 + 20, y + 49, {
width: pageWidth / 2 - 30,
align: 'center',
});
doc.fillColor(COLORS.text).fontSize(8).font('Helvetica')
.text(
`${formatDateFr(directorSignedAt)}${directorSignedByName ? ` · ${directorSignedByName}` : ''}`,
doc.page.margins.left + pageWidth / 2 + 20,
y + 61,
{
width: pageWidth / 2 - 30,
align: 'center',
}
);
}
// ==========================================
// COMMENTAIRE ADMIN (si présent)
// ==========================================
if (data.commentaireAdmin) {
y = checkPageBreak(doc, y, 60);
y += 10;
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text('Commentaire de l\'administration :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica')
.text(data.commentaireAdmin, doc.page.margins.left + 15, y, { width: pageWidth - 25 });
y += doc.heightOfString(data.commentaireAdmin, { width: pageWidth - 25 }) + 10;
}
// ==========================================
// PIED DE PAGE
// ==========================================
const footerY = doc.page.height - doc.page.margins.bottom - 30;
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left, footerY)
.lineTo(doc.page.margins.left + pageWidth, footerY)
.stroke();
doc.fillColor(COLORS.muted)
.fontSize(7)
.font('Helvetica')
.text(
`Document généré le ${formatDateFr(new Date())} — Communauté de Communes Des Savanes — Direction des Services aux Usagers`,
doc.page.margins.left,
footerY + 8,
{ width: pageWidth, align: 'center' }
);
if (directorSignedAt) {
doc.text(
`Visa Direction enregistré le ${formatDateFr(directorSignedAt)}${directorSignedByName ? ` par ${directorSignedByName}` : ''}`,
doc.page.margins.left,
footerY + 18,
{ width: pageWidth, align: 'center' }
);
}
doc.end();
} catch (err) {
reject(err);
}
});
}
export function generateMaterialEventPDF(data: MaterialEventPDFData): Promise<Buffer> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margins: { top: 40, bottom: 40, left: 50, right: 50 },
info: {
Title: `Demande matériel - ${data.formData.nomAssociation || data.formData.commune || 'Association'}`,
Author: 'Communauté de Communes Des Savanes',
Subject: 'Demande de mise à disposition 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;
const manifestationStart = data.formData.dateDebutManifestation || data.formData.dateManifestation;
const manifestationEnd = data.formData.dateFinManifestation || data.formData.dateManifestation;
doc.save();
doc.lineWidth(2)
.strokeColor('#f0c94b')
.moveTo(doc.page.margins.left, y)
.lineTo(doc.page.margins.left + pageWidth, y)
.stroke();
doc.restore();
y += 16;
drawCcdsLogo(doc, doc.page.margins.left + (pageWidth - 90) / 2, y, 90, 90);
y += 96;
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('COMMUNAUTÉ DE COMMUNES DES SAVANES', doc.page.margins.left, y, { width: pageWidth, align: 'center' });
y += 18;
doc.fillColor(COLORS.text).fontSize(18).font('Helvetica-Bold')
.text('FICHE DE DEMANDE DE MISE À DISPOSITION', doc.page.margins.left, y, { width: pageWidth, align: 'center' });
y += 20;
doc.text('DU MATÉRIEL ÉVÉNEMENTIEL', doc.page.margins.left, y, { width: pageWidth, align: 'center' });
y += 26;
doc.save();
doc.roundedRect(doc.page.margins.left + 18, y, pageWidth - 36, 34, 4)
.fill('#d9eefc')
.stroke('#f0c94b');
doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold')
.text('Fiche à remplir obligatoirement (1 mois avant la manifestation)', doc.page.margins.left + 28, y + 7, {
width: pageWidth - 56,
align: 'center',
});
doc.fontSize(9).font('Helvetica')
.text('À transmettre à la Direction des Services aux Usagers', doc.page.margins.left + 28, y + 19, {
width: pageWidth - 56,
align: 'center',
});
doc.restore();
y += 50;
const statusLabel = statusLabels[data.status] || data.status;
doc.fillColor(COLORS.muted).fontSize(8).font('Helvetica')
.text(`Statut : ${statusLabel} | Créée le : ${formatDateFr(data.createdAt)}`, doc.page.margins.left, y, {
width: pageWidth,
align: 'right',
});
y += 18;
y = drawSectionTitle(doc, 'INFORMATIONS DU DEMANDEUR', y, pageWidth);
y = drawTable(doc, [
['Association', data.formData.nomAssociation || '-'],
['Commune', data.formData.commune || data.formData.communeSiege || '-'],
['Direction', data.formData.direction || '-'],
['Service', data.formData.service || '-'],
['Nom et prénom du demandeur', data.formData.demandeurNomPrenom || '-'],
['Téléphone', data.formData.telephoneAssociation || '-'],
['Email', data.formData.emailAssociation || '-'],
], y, pageWidth);
y += 15;
y = drawSectionTitle(doc, 'MATÉRIEL DEMANDÉ', y, pageWidth);
const requestedRows = Object.entries(materialEventLabels)
.filter(([key]) => data.formData.materielsDemandes?.[key])
.map(([key, label]) => [
label,
`${data.formData.quantitesDemandees?.[key] || '-'}${key === 'autres' && data.formData.autreMaterielPrecisions ? `${data.formData.autreMaterielPrecisions}` : ''}`,
]);
y = drawTable(doc, requestedRows.length > 0 ? requestedRows : [['Aucun matériel demandé', '-']], y, pageWidth);
y += 15;
y = drawSectionTitle(doc, 'DÉTAILS DE LA DEMANDE', y, pageWidth);
y = drawTable(doc, [['Date de la demande', formatDateFr(data.formData.dateDemande)]], y, pageWidth);
y += 8;
y = checkPageBreak(doc, y, 88);
doc.save();
doc.roundedRect(doc.page.margins.left, y, pageWidth, 70, 4)
.fill('#f8fafc')
.stroke(COLORS.border);
doc.fillColor(COLORS.primary).fontSize(10).font('Helvetica-Bold')
.text('PÉRIODE DE LA MANIFESTATION', doc.page.margins.left + 14, y + 10, { width: pageWidth - 28 });
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Du', doc.page.margins.left + 14, y + 34);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(formatDateFr(manifestationStart), doc.page.margins.left + 34, y + 33);
doc.fillColor(COLORS.muted).fontSize(9).font('Helvetica')
.text('Au', doc.page.margins.left + pageWidth / 2, y + 34);
doc.fillColor(COLORS.text).fontSize(10).font('Helvetica-Bold')
.text(formatDateFr(manifestationEnd), doc.page.margins.left + pageWidth / 2 + 20, y + 33, {
width: pageWidth / 2 - 34,
});
doc.restore();
y += 82;
y = drawTable(doc, [
['Date prévisionnelle de prise en charge du matériel', formatDateFr(data.formData.datePriseEnCharge)],
['Date prévisionnelle de restitution', formatDateFr(data.formData.dateRestitution)],
['Motif de la demande', data.formData.motifDemande || '-'],
], y, pageWidth);
y += 18;
y = checkPageBreak(doc, y, 220);
y = drawSectionTitle(doc, 'CADRE RÉSERVÉ À LA DSU', y, pageWidth);
const dsu = data.formData.cadreDSU;
if (dsu) {
y = drawTable(doc, [
['Date de réception', formatDateFr(dsu.dateReception)],
['Date de la décision', formatDateFr(dsu.dateDecision)],
['Responsable DSU', dsu.responsableDSU || '-'],
], y, pageWidth);
y += 10;
const grantedRows = Object.entries(materialEventLabels).map(([key, label]) => {
const granted = dsu.materielEvent?.itemsAccordes?.[key];
const qty = dsu.materielEvent?.quantitesAccordees?.[key];
const extra = key === 'autres' ? dsu.materielEvent?.autresPrecisions : '';
return [label, granted ? `${qty || '-'} accordé(s)${extra ? `${extra}` : ''}` : (data.status === 'refusee' ? 'Refusé' : 'Non renseigné')];
});
y = drawTable(doc, grantedRows, y, pageWidth);
const financialDecision = dsu.materielEvent?.financialDecision;
if (financialDecision?.financialMode) {
y += 12;
y = checkPageBreak(doc, y, 120);
doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold')
.text('Conditions financières / convention :', doc.page.margins.left + 5, y);
y += 14;
const financialRows = [
['Mode financier', getFinancialModeLabel(financialDecision.financialMode)],
['Caution / dépôt de garantie', financialDecision.depositRequired ? formatCurrency(financialDecision.depositAmountCents ?? 0) : 'Aucune'],
['Montant de location', financialDecision.financialMode === 'location_payante' ? formatCurrency(financialDecision.rentalAmountCents ?? 0) : 'Non applicable'],
['Statut de la convention', getContractStatusLabel(financialDecision.contractStatus)],
['Convention générée le', formatDateFr(financialDecision.contractGeneratedAt)],
];
y = drawTable(doc, financialRows, y, pageWidth);
if (financialDecision.pricingNotes) {
y += 10;
doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold')
.text('Clauses spécifiques :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text).fontSize(9).font('Helvetica')
.text(financialDecision.pricingNotes, doc.page.margins.left + 5, y, { width: pageWidth - 10 });
y += doc.heightOfString(financialDecision.pricingNotes, { width: pageWidth - 10 }) + 6;
}
}
if (dsu.observationsDSU) {
y += 12;
doc.fillColor(COLORS.primary).fontSize(9).font('Helvetica-Bold')
.text('Observations de la DSU :', doc.page.margins.left + 5, y);
y += 14;
doc.fillColor(COLORS.text).fontSize(9).font('Helvetica')
.text(dsu.observationsDSU, doc.page.margins.left + 5, y, { width: pageWidth - 10 });
}
} else {
doc.fillColor(COLORS.muted).fontSize(10).font('Helvetica-Oblique')
.text('Cadre DSU non encore renseigné.', doc.page.margins.left + 5, y);
}
y += 18;
y = checkPageBreak(doc, y, 80);
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left + 10, y + 28)
.lineTo(doc.page.margins.left + pageWidth / 2 - 20, y + 28)
.stroke();
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left + pageWidth / 2 + 20, y + 28)
.lineTo(doc.page.margins.left + pageWidth - 10, y + 28)
.stroke();
doc.fillColor(COLORS.text).fontSize(9).font('Helvetica')
.text('Signature du DGS de la commune', doc.page.margins.left + 10, y + 34, {
width: pageWidth / 2 - 30,
align: 'center',
})
.text('Signature de la DSU', doc.page.margins.left + pageWidth / 2 + 20, y + 34, {
width: pageWidth / 2 - 30,
align: 'center',
});
const footerY = doc.page.height - doc.page.margins.bottom - 30;
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.moveTo(doc.page.margins.left, footerY)
.lineTo(doc.page.margins.left + pageWidth, footerY)
.stroke();
doc.fillColor(COLORS.muted)
.fontSize(7)
.font('Helvetica')
.text(
`Document généré le ${formatDateFr(new Date())} — Communauté de Communes Des Savanes — Direction des Services aux Usagers`,
doc.page.margins.left,
footerY + 8,
{ width: pageWidth, align: 'center' }
);
doc.end();
} catch (error) {
reject(error);
}
});
}
// ==========================================
// HELPER FUNCTIONS
// ==========================================
function drawSectionTitle(doc: PDFKit.PDFDocument, title: string, y: number, pageWidth: number): number {
const leftMargin = (doc as any).page.margins.left;
// Background bar
doc.save();
doc.rect(leftMargin - 5, y, pageWidth + 10, 20)
.fill(COLORS.primaryLight);
doc.fillColor(COLORS.primary)
.fontSize(9)
.font('Helvetica-Bold')
.text(title, leftMargin + 5, y + 5, { width: pageWidth });
doc.restore();
return y + 28;
}
function drawTable(doc: PDFKit.PDFDocument, rows: string[][], y: number, pageWidth: number, emptyStyle: boolean = false): number {
const leftMargin = (doc as any).page.margins.left;
const labelWidth = pageWidth * 0.4;
const valueWidth = pageWidth * 0.6;
const rowPadding = 5;
for (const [label, value] of rows) {
y = checkPageBreak(doc, y, 20);
// Label
doc.fillColor(COLORS.muted)
.fontSize(9)
.font('Helvetica')
.text(label + ' :', leftMargin + rowPadding, y, {
width: labelWidth - rowPadding * 2,
});
// Value
if (emptyStyle && !value) {
// Draw dotted line for empty fields
doc.strokeColor(COLORS.border)
.lineWidth(0.5)
.dash(3, { space: 2 })
.moveTo(leftMargin + labelWidth + 5, y + 10)
.lineTo(leftMargin + pageWidth - 5, y + 10)
.stroke()
.undash();
} else {
doc.fillColor(COLORS.text)
.fontSize(9)
.font('Helvetica-Bold')
.text(value || '-', leftMargin + labelWidth, y, {
width: valueWidth - rowPadding,
align: 'right',
});
}
// Calculate the height used
const labelH = doc.heightOfString(label + ' :', { width: labelWidth - rowPadding * 2 });
const valueH = value ? doc.heightOfString(value, { width: valueWidth - rowPadding }) : 12;
const rowHeight = Math.max(labelH, valueH) + 4;
y += rowHeight;
// Separator line
doc.strokeColor(COLORS.border)
.lineWidth(0.3)
.dash(1, { space: 2 })
.moveTo(leftMargin + 5, y)
.lineTo(leftMargin + pageWidth - 5, y)
.stroke()
.undash();
y += 4;
}
return y;
}
function checkPageBreak(doc: PDFKit.PDFDocument, y: number, requiredSpace: number): number {
const pageBottom = doc.page.height - doc.page.margins.bottom - 40;
if (y + requiredSpace > pageBottom) {
doc.addPage();
return doc.page.margins.top;
}
return y;
}