Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 46 additions & 23 deletions frontend/src/pages/Reports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
} from '@hugeicons/core-free-icons'
import { getDashboardSummary, getReports, API_BASE } from '../api'
import { formatDateLong, isWithinDateRange, type DateRange } from '../utils/date'
import { usePreferredExportFormat } from '../hooks/usePreferredExportFormat'

type Report = {
id: string
Expand Down Expand Up @@ -73,7 +72,10 @@ export default function Reports() {
const [selectedDateRange, setSelectedDateRange] = useState<DateRange>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { preferred, savePreference } = usePreferredExportFormat()
const [preferredFormat, setPreferredFormat] = useState<string | null>(null)
const latestReadyReport = [...reports]
.filter((report) => report.status === 'ready')
.sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime())[0]

const fetchReports = () => {
setLoading(true)
Expand All @@ -93,6 +95,8 @@ export default function Reports() {

useEffect(() => {
fetchReports()
const pref = localStorage.getItem('secuscan:preferred-export-format')
if (pref) setPreferredFormat(pref)
}, [])

const filteredReports = reports.filter((report) =>
Expand All @@ -118,6 +122,18 @@ export default function Reports() {
</div>

<div className="flex items-center gap-6">
<button
onClick={() => {
if (!latestReadyReport) return
window.open(`${API_BASE}/task/${latestReadyReport.task_id}/report/pdf`, '_blank')
}}
aria-label="Download latest ready report PDF"
title={latestReadyReport ? 'Download latest ready report PDF' : 'No ready report available'}
disabled={!latestReadyReport}
className="bg-charcoal border-4 border-black p-4 text-silver-bright shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
<ReportIcon icon={Download01Icon} className="block" aria-hidden="true" />
</button>
<button
onClick={fetchReports}
className="bg-charcoal border-4 border-black p-4 text-silver-bright shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all"
Expand Down Expand Up @@ -345,28 +361,35 @@ export default function Reports() {
>
<ReportIcon icon={ScanEyeIcon} size={18} aria-hidden="true"/>
</button>
{[...exportFormats].sort((a, b) =>
a === preferred ? -1 : b === preferred ? 1 : 0
).map((format) => (
<button
key={format}
onClick={() => {
if (report.status !== 'generating') {
savePreference(format)
{(() => {
const ordered = preferredFormat
? [preferredFormat, ...exportFormats.filter((f) => f !== preferredFormat)]
: exportFormats

return ordered.map((format) => (
<button
key={format}
onClick={() => {
if (report.status === 'generating') return
// Persist preferred export format for future sessions/tests
try {
localStorage.setItem('secuscan:preferred-export-format', format)
} catch {}
setPreferredFormat(format)
window.open(`${API_BASE}/task/${report.task_id}/report/${format}`, '_blank')
}
}}
disabled={report.status === 'generating'}
className={`border-4 border-black px-3 py-2 text-[9px] font-black uppercase tracking-widest transition-all disabled:opacity-30 disabled:cursor-not-allowed disabled:group-hover:text-silver/20 disabled:group-hover:bg-charcoal-dark ${
format === preferred
? 'bg-rag-amber text-black group-hover:bg-rag-amber'
: 'bg-charcoal-dark text-silver/20 group-hover:text-silver-bright group-hover:bg-black'
}`}
title={report.status === 'generating' ? 'Export unavailable while report is generating' : `Download ${format.toUpperCase()}${format === preferred ? ' (preferred)' : ''}`}
>
{format}
</button>
))}
}}
disabled={report.status === 'generating'}
className={`border-4 border-black px-3 py-2 text-[9px] font-black uppercase tracking-widest transition-all disabled:opacity-30 disabled:cursor-not-allowed disabled:group-hover:text-silver/20 disabled:group-hover:bg-charcoal-dark ${
format === preferredFormat
? 'bg-rag-amber border-black text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]'
: 'bg-charcoal-dark text-silver/20 group-hover:text-silver-bright group-hover:bg-black'
}`}
title={report.status === 'generating' ? 'Export unavailable while report is generating' : `Download ${format.toUpperCase()}`}
>
{format}
</button>
))
})()}
</div>
</div>
</div>
Expand Down
39 changes: 39 additions & 0 deletions frontend/testing/unit/pages/Reports.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ const readyReport = {
generated_at: '2026-05-14T10:00:00Z', status: 'ready',
findings: 7, assets: 3, pages: 12,
}
const newerReadyReport = {
id: 'report-4', task_id: 'task-jkl-012',
name: 'Security Scan — docs.example.com', type: 'technical',
generated_at: '2026-05-14T11:30:00Z', status: 'ready',
findings: 2, assets: 1, pages: 4,
}
const generatingReport = {
id: 'report-2', task_id: 'task-def-456',
name: 'Security Scan — staging.example.com', type: 'executive',
Expand Down Expand Up @@ -168,6 +174,39 @@ describe('Reports — export buttons on a ready report', () => {
})
})

describe('Reports — header export button', () => {
beforeEach(() => {
vi.mocked(getDashboardSummary).mockResolvedValue(emptySummary)
openSpy.mockClear()
})

it('opens the newest ready report PDF from the header button', async () => {
const user = userEvent.setup()
vi.mocked(getReports).mockResolvedValue({ reports: [readyReport, generatingReport, newerReadyReport, failedReport] })

renderReports()

await user.click(await screen.findByRole('button', { name: /download latest ready report pdf/i }))

expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('/task/' + newerReadyReport.task_id + '/report/pdf'), '_blank')
expect(openSpy).not.toHaveBeenCalledWith(expect.stringContaining('/task/latest/report/pdf'), expect.anything())
})

it('disables the header export button when there is no ready report', async () => {
const user = userEvent.setup()
vi.mocked(getReports).mockResolvedValue({ reports: [generatingReport, failedReport] })

renderReports()

const button = await screen.findByRole('button', { name: /download latest ready report pdf/i })
expect(button).toBeDisabled()

await user.click(button)
expect(openSpy).not.toHaveBeenCalled()
})
})

// ── Export buttons — generating report ────────────────────────────────────────────────────────────

describe('Reports — export buttons on a generating report', () => {
Expand Down
Loading