fix: PDF A4 portrait, total sans slash, bouton SharePoint FreePro

This commit is contained in:
Manus
2026-06-05 10:08:38 -04:00
parent d3bb0c7e07
commit b5705b5209
2 changed files with 168 additions and 80 deletions

View File

@@ -34,6 +34,7 @@ import {
Hash, Hash,
BarChart3, BarChart3,
Euro, Euro,
Share2,
} from "lucide-react"; } from "lucide-react";
import jsPDF from "jspdf"; import jsPDF from "jspdf";
import autoTable from "jspdf-autotable"; import autoTable from "jspdf-autotable";
@@ -82,16 +83,22 @@ function typeBadgeColor(type: string): string {
return "bg-orange-100 text-orange-800 border-orange-200"; 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( // ── Construction du PDF (partagée entre export local et SharePoint) ─────────
importRecord: ImportRecord,
lines: VentilationLine[] function buildPdfDoc(importRecord: ImportRecord, lines: VentilationLine[]): jsPDF {
) { // A4 portrait : 210mm × 297mm
// A4 paysage : 297mm × 210mm const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" }); const pageW = 210;
const pageW = 297; const pageH = 297;
const pageH = 210;
const margin = 14; const margin = 14;
// En-tête // En-tête
@@ -107,7 +114,7 @@ function exportToPdf(
// Date d'édition (haut droite) // Date d'édition (haut droite)
const now = new Date(); 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.setFontSize(8);
doc.setFont("helvetica", "normal"); doc.setFont("helvetica", "normal");
doc.text(editDate, pageW - margin, 9, { align: "right" }); doc.text(editDate, pageW - margin, 9, { align: "right" });
@@ -119,35 +126,33 @@ function exportToPdf(
doc.setFont("helvetica", "normal"); doc.setFont("helvetica", "normal");
doc.text(importRecord.refPiece ?? "", margin + 25, 29); doc.text(importRecord.refPiece ?? "", margin + 25, 29);
// Tableau principal // Données tableau
const tableData = lines.map((l) => [ const tableData = lines.map((l) => [
l.structure ?? "(vide)", l.structure ?? "(vide)",
l.type, l.type,
formatMontant(l.montantCentimes), formatMontantPdf(l.montantCentimes),
]); ]);
// Total général
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0); const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
// Calcul dynamique des largeurs pour tenir sur 1 page // Largeurs colonnes A4 portrait (~182mm utiles)
const usableW = pageW - margin * 2; // ~269mm const usableW = pageW - margin * 2;
const colStructure = usableW * 0.50; // ~134mm const colStructure = usableW * 0.45;
const colType = usableW * 0.30; // ~81mm const colType = usableW * 0.32;
const colMontant = usableW * 0.20; // ~54mm const colMontant = usableW * 0.23;
// Calcul de la taille de police pour tenir sur 1 page // Taille de police dynamique pour tenir sur 1 page
// A4 paysage : ~170mm de hauteur utile (210 - 35 header - 10 footer) const nbRows = lines.length + 2;
const nbRows = lines.length + 2; // +1 header +1 footer const availH = pageH - 38 - 15;
const availH = pageH - 38 - 12; // zone tableau
const rowH = Math.min(8, Math.floor(availH / nbRows)); 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, { autoTable(doc, {
startY: 33, startY: 33,
head: [["Structure", "Type", "Montant TTC"]], head: [["Structure", "Type", "Montant TTC"]],
body: tableData, body: tableData,
foot: [["Total général", "", formatMontant(totalCentimes)]], foot: [["Total general", "", formatMontantPdf(totalCentimes)]],
styles: { fontSize, cellPadding: rowH >= 7 ? 2 : 1.5, overflow: "linebreak" }, styles: { fontSize, cellPadding: cellPad, overflow: "linebreak" },
headStyles: { headStyles: {
fillColor: [255, 255, 255], textColor: [0, 0, 0], fillColor: [255, 255, 255], textColor: [0, 0, 0],
fontStyle: "bold", lineWidth: 0.3, lineColor: [0, 0, 0], fontStyle: "bold", lineWidth: 0.3, lineColor: [0, 0, 0],
@@ -163,18 +168,28 @@ function exportToPdf(
2: { cellWidth: colMontant, halign: "right" }, 2: { cellWidth: colMontant, halign: "right" },
}, },
alternateRowStyles: { fillColor: [248, 248, 248] }, alternateRowStyles: { fillColor: [248, 248, 248] },
// Forcer tout sur 1 page
pageBreak: "avoid", pageBreak: "avoid",
rowPageBreak: "avoid", rowPageBreak: "avoid",
margin: { left: margin, right: margin }, margin: { left: margin, right: margin },
tableWidth: usableW, 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 moisPad = moisStr.padStart(2, "0");
const anneeCourt = anneeStr.slice(2); const anneeCourt = anneeStr.slice(2);
doc.save(`FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`); 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 ──────────────────────────────────────────────────── // ── Composant principal ────────────────────────────────────────────────────
function VentilationFreeProContent() { function VentilationFreeProContent() {
@@ -206,7 +221,7 @@ function VentilationFreeProContent() {
const importMutation = trpc.freepro.import.useMutation({ const importMutation = trpc.freepro.import.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
utils.freepro.list.invalidate(); 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); setSelectedImportId(data.importId);
setView("detail"); setView("detail");
}, },
@@ -219,15 +234,36 @@ function VentilationFreeProContent() {
onSuccess: () => { onSuccess: () => {
utils.freepro.list.invalidate(); utils.freepro.list.invalidate();
if (view === "detail") setView("list"); 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 // Gestion du fichier
const handleFile = useCallback( const handleFile = useCallback(
(file: File) => { (file: File) => {
if (!file.name.match(/\.(xlsx|xls|csv)$/i)) { 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; return;
} }
const reader = new FileReader(); const reader = new FileReader();
@@ -237,7 +273,7 @@ function VentilationFreeProContent() {
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, },
[moisLabel, importMutation, toast] [moisLabel, importMutation]
); );
const handleDrop = useCallback( const handleDrop = useCallback(
@@ -251,23 +287,22 @@ function VentilationFreeProContent() {
); );
// ── Rendu liste ────────────────────────────────────────────────────────── // ── Rendu liste ──────────────────────────────────────────────────────────
if (view === "list") { if (view === "list") {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* En-tête */} {/* En-tête */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50 border border-blue-200">
<BarChart3 className="h-6 w-6 text-blue-600" /> <BarChart3 className="h-6 w-6 text-blue-600" />
</div>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Ventilation FreePro</h1> <h1 className="text-2xl font-bold">Ventilation FreePro</h1>
<p className="text-sm text-muted-foreground">Import et ventilation des factures Free Pro par structure</p> <p className="text-sm text-muted-foreground">Import et ventilation des factures Free Pro par structure</p>
</div> </div>
</div> </div>
</div>
{/* Zone d'import */} {/* Zone d'import */}
<Card className="border-2 border-dashed border-blue-200 bg-blue-50/30"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Upload className="h-4 w-4 text-blue-600" /> <Upload className="h-4 w-4 text-blue-600" />
@@ -277,31 +312,31 @@ function VentilationFreeProContent() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Sélecteur de mois */} {/* Sélecteur de mois */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label className="text-sm font-medium text-foreground w-28">Mois de facturation</label> <label className="text-sm font-medium text-muted-foreground whitespace-nowrap">Mois de facturation :</label>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<input <input
type="text" type="text"
value={moisLabel} value={moisLabel}
onChange={(e) => setMoisLabel(e.target.value)} onChange={(e) => setMoisLabel(e.target.value)}
placeholder="MM/YYYY" placeholder="MM/AAAA"
className="border rounded px-3 py-1.5 text-sm w-28 focus:outline-none focus:ring-2 focus:ring-blue-400" className="border rounded px-3 py-1.5 text-sm w-32 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
pattern="\d{2}/\d{4}"
/> />
<span className="text-xs text-muted-foreground">Format : MM/YYYY</span> <span className="text-xs text-muted-foreground">Format : MM/AAAA (ex : 06/2025)</span>
</div>
</div> </div>
{/* Zone de dépôt */} {/* Zone de dépôt */}
<div <div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all ${ className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
isDragging ? "border-blue-500 bg-blue-100" : "border-blue-300 hover:border-blue-400 hover:bg-blue-50" isDragging ? "border-blue-500 bg-blue-50" : "border-muted-foreground/30 hover:border-blue-400 hover:bg-blue-50/30"
} ${importMutation.isPending ? "opacity-50 pointer-events-none" : ""}`} }`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)} onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop} onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
<FileSpreadsheet className="h-10 w-10 mx-auto mb-3 text-blue-400" />
<p className="text-sm font-medium">Glissez votre fichier FreePro ici</p>
<p className="text-xs text-muted-foreground mt-1">ou cliquez pour sélectionner</p>
<p className="text-xs text-muted-foreground mt-2">Formats acceptés : .xlsx, .xls, .csv</p>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -309,21 +344,14 @@ function VentilationFreeProContent() {
className="hidden" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/> />
{importMutation.isPending ? (
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-blue-600 font-medium">Traitement en cours</p>
</div> </div>
) : (
<div className="flex flex-col items-center gap-2"> {importMutation.isPending && (
<FileSpreadsheet className="h-10 w-10 text-blue-400" /> <div className="flex items-center gap-2 text-sm text-blue-600">
<p className="text-sm font-medium text-foreground"> <div className="h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
Glissez-déposez le fichier Excel FreePro ici Traitement en cours
</p>
<p className="text-xs text-muted-foreground">ou cliquez pour sélectionner (.xlsx, .xls, .csv)</p>
</div> </div>
)} )}
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -436,8 +464,7 @@ function VentilationFreeProContent() {
); );
} }
// ── Rendu détail ───────────────────────────────────────────────────────── // ── Rendu détail ─────────────────────────────────────────────────────────
// (suite ci-dessous)
const imp = detail?.import as ImportRecord | undefined; const imp = detail?.import as ImportRecord | undefined;
const lines = (detail?.lines ?? []) as VentilationLine[]; const lines = (detail?.lines ?? []) as VentilationLine[];
@@ -466,6 +493,7 @@ function VentilationFreeProContent() {
</div> </div>
</div> </div>
{imp && ( {imp && (
<div className="flex items-center gap-2">
<Button <Button
onClick={() => exportToPdf(imp, lines)} onClick={() => exportToPdf(imp, lines)}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white" className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
@@ -473,6 +501,20 @@ function VentilationFreeProContent() {
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
Exporter PDF Exporter PDF
</Button> </Button>
<Button
onClick={handleExportSharePoint}
disabled={isExportingSP}
variant="outline"
className="flex items-center gap-2 border-green-600 text-green-700 hover:bg-green-50"
>
{isExportingSP ? (
<div className="h-4 w-4 border-2 border-green-600 border-t-transparent rounded-full animate-spin" />
) : (
<Share2 className="h-4 w-4" />
)}
{isExportingSP ? "Envoi…" : "Envoyer vers SharePoint"}
</Button>
</div>
)} )}
</div> </div>
@@ -521,9 +563,9 @@ function VentilationFreeProContent() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/30"> <TableRow className="bg-muted/30">
<TableHead className="font-bold">structure</TableHead> <TableHead className="font-bold">Structure</TableHead>
<TableHead className="font-bold">Type</TableHead> <TableHead className="font-bold">Type</TableHead>
<TableHead className="text-right font-bold">Montant</TableHead> <TableHead className="text-right font-bold">Montant TTC</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>

View File

@@ -2268,6 +2268,52 @@ export const appRouter = router({
await deleteFreeproImport(input.id); await deleteFreeproImport(input.id);
return { success: true }; 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; export type AppRouter = typeof appRouter;