958 lines
36 KiB
TypeScript
958 lines
36 KiB
TypeScript
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 d’usage', 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;
|
||
}
|