Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

958
server/pdfGenerator.ts Normal file
View file

@ -0,0 +1,958 @@
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;
}