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; quantitesDemandees?: Record; cadreDSU?: { dateReception?: string; cautionRequise?: boolean; montantCaution?: string; responsableDSU?: string; dateDecision?: string; observationsDSU?: string; materielEvent?: { itemsAccordes?: Record; quantitesAccordees?: Record; 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 = { 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 = { favorable: 'Favorable', defavorable: 'Défavorable', favorable_avec_reserves: 'Favorable avec réserves', }; const materialEventLabels: Record = { 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 { 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 { 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; }