From b5705b52093bc7dafc3e85e778b4bf242268244a Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 5 Jun 2026 10:08:38 -0400 Subject: [PATCH] fix: PDF A4 portrait, total sans slash, bouton SharePoint FreePro --- client/src/pages/VentilationFreePro.tsx | 202 ++++++++++++++---------- server/routers.ts | 46 ++++++ 2 files changed, 168 insertions(+), 80 deletions(-) diff --git a/client/src/pages/VentilationFreePro.tsx b/client/src/pages/VentilationFreePro.tsx index e075233..775d7ee 100644 --- a/client/src/pages/VentilationFreePro.tsx +++ b/client/src/pages/VentilationFreePro.tsx @@ -34,6 +34,7 @@ import { Hash, BarChart3, Euro, + Share2, } from "lucide-react"; import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; @@ -82,16 +83,22 @@ function typeBadgeColor(type: string): string { return "bg-orange-100 text-orange-800 border-orange-200"; } -// ── Export PDF ───────────────────────────────────────────────────────────── +/** Formatage montant pour PDF : sans espace insécable pour éviter les artefacts jsPDF */ +function formatMontantPdf(centimes: number): string { + const val = centimes / 100; + const parts = val.toFixed(2).split("."); + // Séparateur de milliers = espace simple (pas \u202f qui cause le slash) + const intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " "); + return `${intPart},${parts[1]} EUR`; +} -function exportToPdf( - importRecord: ImportRecord, - lines: VentilationLine[] -) { - // A4 paysage : 297mm × 210mm - const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" }); - const pageW = 297; - const pageH = 210; +// ── Construction du PDF (partagée entre export local et SharePoint) ───────── + +function buildPdfDoc(importRecord: ImportRecord, lines: VentilationLine[]): jsPDF { + // A4 portrait : 210mm × 297mm + const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + const pageW = 210; + const pageH = 297; const margin = 14; // En-tête @@ -107,7 +114,7 @@ function exportToPdf( // Date d'édition (haut droite) const now = new Date(); - const editDate = `Edité le ${now.toLocaleDateString("fr-FR")} - ${now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`; + const editDate = `Edite le ${now.toLocaleDateString("fr-FR")} - ${now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`; doc.setFontSize(8); doc.setFont("helvetica", "normal"); doc.text(editDate, pageW - margin, 9, { align: "right" }); @@ -119,35 +126,33 @@ function exportToPdf( doc.setFont("helvetica", "normal"); doc.text(importRecord.refPiece ?? "", margin + 25, 29); - // Tableau principal + // Données tableau const tableData = lines.map((l) => [ l.structure ?? "(vide)", l.type, - formatMontant(l.montantCentimes), + formatMontantPdf(l.montantCentimes), ]); - - // Total général const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0); - // Calcul dynamique des largeurs pour tenir sur 1 page - const usableW = pageW - margin * 2; // ~269mm - const colStructure = usableW * 0.50; // ~134mm - const colType = usableW * 0.30; // ~81mm - const colMontant = usableW * 0.20; // ~54mm + // Largeurs colonnes A4 portrait (~182mm utiles) + const usableW = pageW - margin * 2; + const colStructure = usableW * 0.45; + const colType = usableW * 0.32; + const colMontant = usableW * 0.23; - // Calcul de la taille de police pour tenir sur 1 page - // A4 paysage : ~170mm de hauteur utile (210 - 35 header - 10 footer) - const nbRows = lines.length + 2; // +1 header +1 footer - const availH = pageH - 38 - 12; // zone tableau + // Taille de police dynamique pour tenir sur 1 page + const nbRows = lines.length + 2; + const availH = pageH - 38 - 15; const rowH = Math.min(8, Math.floor(availH / nbRows)); - const fontSize = rowH >= 7 ? 9 : rowH >= 6 ? 8 : 7; + const fontSize = rowH >= 7 ? 9 : rowH >= 6 ? 8 : rowH >= 5 ? 7 : 6; + const cellPad = rowH >= 7 ? 2 : 1.2; autoTable(doc, { startY: 33, head: [["Structure", "Type", "Montant TTC"]], body: tableData, - foot: [["Total général", "", formatMontant(totalCentimes)]], - styles: { fontSize, cellPadding: rowH >= 7 ? 2 : 1.5, overflow: "linebreak" }, + foot: [["Total general", "", formatMontantPdf(totalCentimes)]], + styles: { fontSize, cellPadding: cellPad, overflow: "linebreak" }, headStyles: { fillColor: [255, 255, 255], textColor: [0, 0, 0], fontStyle: "bold", lineWidth: 0.3, lineColor: [0, 0, 0], @@ -163,18 +168,28 @@ function exportToPdf( 2: { cellWidth: colMontant, halign: "right" }, }, alternateRowStyles: { fillColor: [248, 248, 248] }, - // Forcer tout sur 1 page pageBreak: "avoid", rowPageBreak: "avoid", margin: { left: margin, right: margin }, tableWidth: usableW, }); + return doc; +} + +function exportToPdf(importRecord: ImportRecord, lines: VentilationLine[]) { + const doc = buildPdfDoc(importRecord, lines); + const [moisStr, anneeStr] = importRecord.moisLabel.split("/"); const moisPad = moisStr.padStart(2, "0"); const anneeCourt = anneeStr.slice(2); doc.save(`FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`); } +function buildPdfBase64(importRecord: ImportRecord, lines: VentilationLine[]): string { + const doc = buildPdfDoc(importRecord, lines); + return doc.output("datauristring").split(",")[1]; +} + // ── Composant principal ──────────────────────────────────────────────────── function VentilationFreeProContent() { @@ -206,7 +221,7 @@ function VentilationFreeProContent() { const importMutation = trpc.freepro.import.useMutation({ onSuccess: (data) => { utils.freepro.list.invalidate(); - toast.success(`Import réussi — ${data.nbLignes} lignes traitées — Total TTC : ${formatTotalTtc(data.totalTtc)}`); + toast.success(`Import reussi — ${data.nbLignes} lignes traitees — Total TTC : ${formatTotalTtc(data.totalTtc)}`); setSelectedImportId(data.importId); setView("detail"); }, @@ -219,15 +234,36 @@ function VentilationFreeProContent() { onSuccess: () => { utils.freepro.list.invalidate(); if (view === "detail") setView("list"); - toast.success("Import supprimé"); + toast.success("Import supprime"); }, }); + const [isExportingSP, setIsExportingSP] = useState(false); + const exportToSharePointMutation = trpc.freepro.exportToSharePoint.useMutation({ + onSuccess: (data) => { + setIsExportingSP(false); + toast.success(`Fichier depose sur SharePoint : ${data.fileName}`); + }, + onError: (err) => { + setIsExportingSP(false); + toast.error(`Erreur SharePoint : ${err.message}`); + }, + }); + + const handleExportSharePoint = useCallback(() => { + if (!selectedImportId || !detail) return; + const imp = detail.import as ImportRecord; + const lines = (detail.lines ?? []) as VentilationLine[]; + const pdfBase64 = buildPdfBase64(imp, lines); + setIsExportingSP(true); + exportToSharePointMutation.mutate({ id: selectedImportId, pdfBase64 }); + }, [selectedImportId, detail, exportToSharePointMutation]); + // Gestion du fichier const handleFile = useCallback( (file: File) => { if (!file.name.match(/\.(xlsx|xls|csv)$/i)) { - toast.error("Format invalide : veuillez sélectionner un fichier Excel (.xlsx, .xls) ou CSV (.csv)"); + toast.error("Format invalide : veuillez selectionner un fichier Excel (.xlsx, .xls) ou CSV (.csv)"); return; } const reader = new FileReader(); @@ -237,7 +273,7 @@ function VentilationFreeProContent() { }; reader.readAsDataURL(file); }, - [moisLabel, importMutation, toast] + [moisLabel, importMutation] ); const handleDrop = useCallback( @@ -251,23 +287,22 @@ function VentilationFreeProContent() { ); // ── Rendu liste ────────────────────────────────────────────────────────── - if (view === "list") { return (
{/* En-tête */} -
-
+
+
-
-
-

Ventilation FreePro

-

Import et ventilation des factures Free Pro par structure

+
+

Ventilation FreePro

+

Import et ventilation des factures Free Pro par structure

+
{/* Zone d'import */} - + @@ -277,31 +312,31 @@ function VentilationFreeProContent() { {/* Sélecteur de mois */}
- -
- - setMoisLabel(e.target.value)} - placeholder="MM/YYYY" - className="border rounded px-3 py-1.5 text-sm w-28 focus:outline-none focus:ring-2 focus:ring-blue-400" - pattern="\d{2}/\d{4}" - /> - Format : MM/YYYY -
+ + setMoisLabel(e.target.value)} + placeholder="MM/AAAA" + className="border rounded px-3 py-1.5 text-sm w-32 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + Format : MM/AAAA (ex : 06/2025)
{/* Zone de dépôt */}
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} > + +

Glissez votre fichier FreePro ici

+

ou cliquez pour sélectionner

+

Formats acceptés : .xlsx, .xls, .csv

{ const f = e.target.files?.[0]; if (f) handleFile(f); }} /> - {importMutation.isPending ? ( -
-
-

Traitement en cours…

-
- ) : ( -
- -

- Glissez-déposez le fichier Excel FreePro ici -

-

ou cliquez pour sélectionner (.xlsx, .xls, .csv)

-
- )}
+ + {importMutation.isPending && ( +
+
+ Traitement en cours… +
+ )} @@ -436,8 +464,7 @@ function VentilationFreeProContent() { ); } - // ── Rendu détail ───────────────────────────────────────────────────────── - // (suite ci-dessous) + // ── Rendu détail ────────────────────────────────────────────────────────── const imp = detail?.import as ImportRecord | undefined; const lines = (detail?.lines ?? []) as VentilationLine[]; @@ -466,13 +493,28 @@ function VentilationFreeProContent() {
{imp && ( - +
+ + +
)}
@@ -521,9 +563,9 @@ function VentilationFreeProContent() { - structure + Structure Type - Montant + Montant TTC diff --git a/server/routers.ts b/server/routers.ts index 9b898ad..63e1675 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -2268,6 +2268,52 @@ export const appRouter = router({ await deleteFreeproImport(input.id); return { success: true }; }), + + /** Exporte la ventilation FreePro vers SharePoint */ + exportToSharePoint: protectedProcedure + .input(z.object({ id: z.number(), pdfBase64: z.string() })) + .mutation(async ({ input, ctx }) => { + const data = await getFreeproImportWithLines(input.id); + if (!data) throw new TRPCError({ code: "NOT_FOUND" }); + if (data.import.userId !== ctx.user.id) + throw new TRPCError({ code: "FORBIDDEN" }); + + // Récupérer les paramètres SharePoint depuis importSettings + const settings = await getImportSettingsByUser(ctx.user.id); + const tenantId = (settings as any)?.azureTenantId || ''; + const clientId = (settings as any)?.azureClientId || ''; + const clientSecret = (settings as any)?.azureClientSecret || ''; + const sharepointUrl = (settings as any)?.exportFolder || ''; + + if (!tenantId || !clientId || !clientSecret) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Credentials Azure AD non configurés dans les paramètres d'export" }); + } + if (!sharepointUrl) { + throw new TRPCError({ code: "BAD_REQUEST", message: "URL SharePoint non configurée dans le dossier d'export" }); + } + + // Construire le nom du fichier + const [moisStr, anneeStr] = data.import.moisLabel.split('/'); + const moisPad = moisStr.padStart(2, '0'); + const anneeCourt = anneeStr.slice(2); + const fileName = `FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`; + + // Convertir le PDF base64 en buffer + const pdfBuffer = Buffer.from(input.pdfBase64, 'base64'); + + const { uploadToSharePoint } = await import('./sharepoint'); + const result = await uploadToSharePoint( + { tenantId, clientId, clientSecret, sharepointUrl }, + pdfBuffer, + fileName + ); + + if (!result.success) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error || 'Erreur upload SharePoint' }); + } + + return { success: true, webUrl: result.webUrl, fileName }; + }), }), }); export type AppRouter = typeof appRouter;