diff --git a/package-lock.json b/package-lock.json index b99dd2b..fbb5584 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-sns": "^3.1041.0", - "@robosystems/client": "0.3.34", + "@robosystems/client": "0.3.36", "flowbite": "^3.1", "flowbite-react": "^0.12.5", "intuit-oauth": "^4.1.0", @@ -3288,9 +3288,9 @@ } }, "node_modules/@robosystems/client": { - "version": "0.3.34", - "resolved": "https://registry.npmjs.org/@robosystems/client/-/client-0.3.34.tgz", - "integrity": "sha512-vkzZBUEf6EGorNYGpuY3zBIYUOtwKkTUc/nPr+9hPm0cgwuBE9YMhC+c34HzKGs2z13oq2HsSFNb/FSi5ZX4hQ==", + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@robosystems/client/-/client-0.3.36.tgz", + "integrity": "sha512-bCFr24GXO2lHeLKqVbygrBO8A/YVwgpx60i+3WSqgqzQGJptoMmgtCPdxjbY5CB8PInwG4qwXhsby+UKCmSPiA==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", diff --git a/package.json b/package.json index f3d0590..2d14d23 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "@aws-sdk/client-sns": "^3.1041.0", - "@robosystems/client": "0.3.34", + "@robosystems/client": "0.3.36", "flowbite": "^3.1", "flowbite-react": "^0.12.5", "intuit-oauth": "^4.1.0", diff --git a/src/app/(app)/reports/[id]/content.tsx b/src/app/(app)/reports/[id]/content.tsx index 61e90b4..5077594 100644 --- a/src/app/(app)/reports/[id]/content.tsx +++ b/src/app/(app)/reports/[id]/content.tsx @@ -8,6 +8,8 @@ import { Badge, Button, Card, + Dropdown, + DropdownItem, Label, Modal, ModalBody, @@ -20,7 +22,9 @@ import { useParams, useSearchParams } from 'next/navigation' import type { FC } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + HiChevronDown, HiChevronLeft, + HiDocumentDownload, HiDocumentReport, HiExclamationCircle, HiShare, @@ -86,6 +90,10 @@ const ReportViewerContent: FC = function () { const [isSharing, setIsSharing] = useState(false) const [shareResult, setShareResult] = useState(null) + const [isDownloadingBundle, setIsDownloadingBundle] = useState(false) + const [isDownloadingXbrl, setIsDownloadingXbrl] = useState(false) + const [downloadError, setDownloadError] = useState(null) + const loadPublishLists = useCallback(async () => { if (!graphId) return try { @@ -99,6 +107,64 @@ const ReportViewerContent: FC = function () { } }, [graphId]) + // window.location.href (not ) because the presigned URL is + // cross-origin — the download attribute is ignored cross-origin, but the + // backend sets Content-Disposition: attachment so the file still saves. + const handleDownloadBundle = useCallback(async () => { + if (!graphId || !reportId) return + try { + setIsDownloadingBundle(true) + setDownloadError(null) + const resp = await clients.reports.getReportBundleDownloadUrl( + graphId, + reportId + ) + window.location.href = resp.downloadUrl + } catch (err) { + console.error('Bundle download failed:', err) + // 404 message from the backend ("no stamped bundle — regenerate to + // produce one") is the most actionable failure mode for users + // viewing a pre-feature Report, so surface the server detail when + // available rather than a generic string. + const message = + err instanceof Error ? err.message : 'Failed to start download.' + setDownloadError(message) + } finally { + setIsDownloadingBundle(false) + } + }, [graphId, reportId]) + + // XBRL is streamed as a blob (no presigned URL), so it saves via the + // object-URL + temporary-anchor pattern used for backup downloads. + const handleDownloadXbrl = useCallback(async () => { + if (!graphId || !reportId) return + try { + setIsDownloadingXbrl(true) + setDownloadError(null) + const { blob, filename } = await clients.reports.getReportBundleXbrlZip( + graphId, + reportId + ) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + // Release the object URL after the browser starts the download — + // some browsers cancel an in-flight save if we revoke immediately. + setTimeout(() => URL.revokeObjectURL(url), 5000) + } catch (err) { + console.error('XBRL download failed:', err) + const message = + err instanceof Error ? err.message : 'Failed to download XBRL bundle.' + setDownloadError(message) + } finally { + setIsDownloadingXbrl(false) + } + }, [graphId, reportId]) + const handleShare = useCallback(async () => { if (!graphId || !reportId || !selectedListId) return @@ -246,6 +312,31 @@ const ReportViewerContent: FC = function () { gradient="from-orange-500 to-red-600" actions={ <> + {pkg.generationStatus === 'published' && ( + + {isDownloadingBundle || isDownloadingXbrl ? ( + + ) : ( + + )} + Download + + + } + > + + JSON-LD bundle + + + XBRL 2.1 package + + + )} {pkg.generationStatus === 'published' && !pkg.sourceGraphId && (