Checkpoint: Ajout du bouton de téléchargement direct du PDF annoté BAP : route Express /api/download-bap/:filename avec Content-Disposition attachment, bouton Télécharger dans InvoicesBAP (apparaît après validation), boutons Voir + Télécharger dans l'Historique BAP. Correction du layout PDF (2 colonnes) également incluse.
This commit is contained in:
@@ -19,8 +19,21 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
User,
|
User,
|
||||||
FileText,
|
FileText,
|
||||||
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Helper : télécharge un PDF annoté BAP depuis son URL de stockage
|
||||||
|
function downloadBapPdf(pdfUrl: string, supplierName?: string | null) {
|
||||||
|
const filename = pdfUrl.split('/').pop() || 'BAP.pdf';
|
||||||
|
const downloadUrl = `/api/download-bap/${filename}`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = supplierName ? `BAP_${supplierName.replace(/[^a-zA-Z0-9]/g, '_')}.pdf` : filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BapHistory() {
|
export default function BapHistory() {
|
||||||
const { data: entries, isLoading, refetch } = trpc.bapHistory.getAll.useQuery();
|
const { data: entries, isLoading, refetch } = trpc.bapHistory.getAll.useQuery();
|
||||||
const deleteMutation = trpc.bapHistory.delete.useMutation();
|
const deleteMutation = trpc.bapHistory.delete.useMutation();
|
||||||
@@ -236,15 +249,27 @@ export default function BapHistory() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{entry.exportMode === "browser" && entry.pdfUrl ? (
|
{entry.exportMode === "browser" && entry.pdfUrl ? (
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-7 px-2 text-blue-600 hover:text-blue-700"
|
size="sm"
|
||||||
onClick={() => window.open(entry.pdfUrl!, "_blank")}
|
className="h-7 px-2 text-blue-600 hover:text-blue-700"
|
||||||
>
|
onClick={() => window.open(entry.pdfUrl!, "_blank")}
|
||||||
<Monitor className="h-3.5 w-3.5 mr-1" />
|
title="Ouvrir dans le navigateur"
|
||||||
<span className="text-xs">Voir PDF</span>
|
>
|
||||||
</Button>
|
<Monitor className="h-3.5 w-3.5 mr-1" />
|
||||||
|
<span className="text-xs">Voir</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50"
|
||||||
|
onClick={() => downloadBapPdf(entry.pdfUrl!, entry.supplierName)}
|
||||||
|
title="Télécharger le PDF annoté"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : entry.exportMode === "folder" && entry.exportPath ? (
|
) : entry.exportMode === "folder" && entry.exportPath ? (
|
||||||
<div className="flex items-center gap-1 text-orange-600">
|
<div className="flex items-center gap-1 text-orange-600">
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck } from "lucide-react";
|
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
// Helper : télécharge un PDF annoté BAP depuis son URL de stockage
|
||||||
|
function downloadBapPdf(pdfUrl: string, supplierName?: string) {
|
||||||
|
const filename = pdfUrl.split('/').pop() || 'BAP.pdf';
|
||||||
|
const downloadUrl = `/api/download-bap/${filename}`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = supplierName ? `BAP_${supplierName.replace(/[^a-zA-Z0-9]/g, '_')}.pdf` : filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
@@ -65,6 +77,8 @@ export default function InvoicesBAP() {
|
|||||||
const [addDialogType, setAddDialogType] = useState<"service" | "ventilation" | null>(null);
|
const [addDialogType, setAddDialogType] = useState<"service" | "ventilation" | null>(null);
|
||||||
const [newItemName, setNewItemName] = useState("");
|
const [newItemName, setNewItemName] = useState("");
|
||||||
const [pendingInvoiceId, setPendingInvoiceId] = useState<number | null>(null);
|
const [pendingInvoiceId, setPendingInvoiceId] = useState<number | null>(null);
|
||||||
|
// Map invoiceId -> pdfUrl après validation BAP (pour bouton téléchargement)
|
||||||
|
const [bapPdfUrls, setBapPdfUrls] = useState<Record<number, string>>({});
|
||||||
const [textDialogOpen, setTextDialogOpen] = useState(false);
|
const [textDialogOpen, setTextDialogOpen] = useState(false);
|
||||||
const [selectedInvoiceText, setSelectedInvoiceText] = useState<string | null>(null);
|
const [selectedInvoiceText, setSelectedInvoiceText] = useState<string | null>(null);
|
||||||
const { data: allInvoices, isLoading } = trpc.invoices.list.useQuery();
|
const { data: allInvoices, isLoading } = trpc.invoices.list.useQuery();
|
||||||
@@ -151,6 +165,10 @@ export default function InvoicesBAP() {
|
|||||||
|
|
||||||
const validateBAPMutation = trpc.invoices.validateBAP.useMutation({
|
const validateBAPMutation = trpc.invoices.validateBAP.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
// Stocker le pdfUrl pour le bouton téléchargement
|
||||||
|
if (data.invoiceId && data.pdfUrl) {
|
||||||
|
setBapPdfUrls(prev => ({ ...prev, [data.invoiceId as number]: data.pdfUrl as string }));
|
||||||
|
}
|
||||||
if (data.exportMode === 'browser' && data.pdfUrl) {
|
if (data.exportMode === 'browser' && data.pdfUrl) {
|
||||||
toast.success("Facture validée BAP ! Ouverture du PDF annoté...", { duration: 3000 });
|
toast.success("Facture validée BAP ! Ouverture du PDF annoté...", { duration: 3000 });
|
||||||
window.open(data.pdfUrl, '_blank');
|
window.open(data.pdfUrl, '_blank');
|
||||||
@@ -679,12 +697,25 @@ export default function InvoicesBAP() {
|
|||||||
<Trash className="h-4 w-4" />
|
<Trash className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{invoice.bapValidated === 1 ? (
|
{invoice.bapValidated === 1 ? (
|
||||||
<div
|
<div className="flex gap-1 items-center">
|
||||||
className="h-8 px-2 flex items-center justify-center rounded border border-green-300 bg-green-50 text-green-700 text-xs font-semibold gap-1"
|
<div
|
||||||
title={`Validé BAP le ${invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt).toLocaleDateString('fr-FR') : ''}`}
|
className="h-8 px-2 flex items-center justify-center rounded border border-green-300 bg-green-50 text-green-700 text-xs font-semibold gap-1"
|
||||||
>
|
title={`Validé BAP le ${invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt).toLocaleDateString('fr-FR') : ''}`}
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
>
|
||||||
<span>Validé</span>
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<span>Validé</span>
|
||||||
|
</div>
|
||||||
|
{bapPdfUrls[invoice.id] && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 px-2 text-blue-600 border-blue-300 hover:bg-blue-50"
|
||||||
|
title="Télécharger le PDF annoté BAP"
|
||||||
|
onClick={() => downloadBapPdf(bapPdfUrls[invoice.id], invoice.supplierName || undefined)}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import "dotenv/config";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||||
import { registerOAuthRoutes } from "./oauth";
|
import { registerOAuthRoutes } from "./oauth";
|
||||||
import { appRouter } from "../routers";
|
import { appRouter } from "../routers";
|
||||||
@@ -38,6 +40,19 @@ async function startServer() {
|
|||||||
// Serve local storage files
|
// Serve local storage files
|
||||||
app.use("/storage", express.static("storage"));
|
app.use("/storage", express.static("storage"));
|
||||||
|
|
||||||
|
// Route de téléchargement forcé du PDF annoté BAP
|
||||||
|
app.get("/api/download-bap/:filename", (req, res) => {
|
||||||
|
const filename = path.basename(req.params.filename); // sécurité : pas de path traversal
|
||||||
|
const storagePath = path.resolve("storage", filename);
|
||||||
|
if (!fs.existsSync(storagePath)) {
|
||||||
|
res.status(404).json({ error: "Fichier introuvable" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.sendFile(storagePath);
|
||||||
|
});
|
||||||
|
|
||||||
// tRPC API
|
// tRPC API
|
||||||
app.use(
|
app.use(
|
||||||
"/api/trpc",
|
"/api/trpc",
|
||||||
|
|||||||
@@ -656,6 +656,7 @@ export const appRouter = router({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
invoiceId: input.id,
|
||||||
validatedAt,
|
validatedAt,
|
||||||
pdfUrl,
|
pdfUrl,
|
||||||
exportPath,
|
exportPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user