fix: PDF A4 portrait, total sans slash, bouton SharePoint FreePro
This commit is contained in:
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-50 border border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Ventilation FreePro</h1>
|
||||
<p className="text-sm text-muted-foreground">Import et ventilation des factures Free Pro par structure</p>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone d'import */}
|
||||
<Card className="border-2 border-dashed border-blue-200 bg-blue-50/30">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-blue-600" />
|
||||
@@ -277,31 +312,31 @@ function VentilationFreeProContent() {
|
||||
<CardContent className="space-y-4">
|
||||
{/* Sélecteur de mois */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-foreground w-28">Mois de facturation</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={moisLabel}
|
||||
onChange={(e) => 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}"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Format : MM/YYYY</span>
|
||||
</div>
|
||||
<label className="text-sm font-medium text-muted-foreground whitespace-nowrap">Mois de facturation :</label>
|
||||
<input
|
||||
type="text"
|
||||
value={moisLabel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Format : MM/AAAA (ex : 06/2025)</span>
|
||||
</div>
|
||||
|
||||
{/* Zone de dépôt */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all ${
|
||||
isDragging ? "border-blue-500 bg-blue-100" : "border-blue-300 hover:border-blue-400 hover:bg-blue-50"
|
||||
} ${importMutation.isPending ? "opacity-50 pointer-events-none" : ""}`}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
|
||||
isDragging ? "border-blue-500 bg-blue-50" : "border-muted-foreground/30 hover:border-blue-400 hover:bg-blue-50/30"
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
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
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -309,21 +344,14 @@ function VentilationFreeProContent() {
|
||||
className="hidden"
|
||||
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 className="flex flex-col items-center gap-2">
|
||||
<FileSpreadsheet className="h-10 w-10 text-blue-400" />
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Glissez-déposez le fichier Excel FreePro ici
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">ou cliquez pour sélectionner (.xlsx, .xls, .csv)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{importMutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<div className="h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
Traitement en cours…
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
{imp && (
|
||||
<Button
|
||||
onClick={() => exportToPdf(imp, lines)}
|
||||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Exporter PDF
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => exportToPdf(imp, lines)}
|
||||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Exporter PDF
|
||||
</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>
|
||||
|
||||
@@ -521,9 +563,9 @@ function VentilationFreeProContent() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<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="text-right font-bold">Montant</TableHead>
|
||||
<TableHead className="text-right font-bold">Montant TTC</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user