Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
958
server/pdfGenerator.ts
Normal file
958
server/pdfGenerator.ts
Normal 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 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue