feat(invoices): preview invoices and underlag in the browser instead of downloading (#1228)
Reviewing an invoice or a verifikat bilaga meant saving a file and opening it from the Downloads folder (user request, christian@odinaero.se 2026-07-25). - GET /api/invoices/[id]/pdf accepts ?disposition=inline and serves the PDF for in-browser review; anything else keeps the download behaviour every existing caller relies on. The filename still travels in the header, so the browser viewer's own save action produces the same name as the download button, and nosniff pins the content type. - The invoice detail page gets a "Förhandsgranska" action next to "Ladda ner PDF". It resolves the document through the same resolveInvoicePdfSource path as the download, so preview cannot become the shortcut that presents a re-render as the invoice the customer received: the archived delivery wins, a re-render is shown with its caveat, and an unreadable delivery history still asks instead of guessing. The archive dialog now remembers whether the user asked to view or to save, and its fallback does that. - DocumentViewButton (supplier-invoice underlag, staged agent previews) points at the existing /api/documents/:id/inline proxy, so bilagor render in the browser. Navigation now happens straight from the click, so the signed-URL fetch and its popup-blocker workaround are gone. - The three re-render caveat strings and the two archive-dialog descriptions lose their "you downloaded" wording so they stay true for both actions; five new keys in sv + en. Tests: route cases for the default, inline and unknown disposition values; invoiceRerenderUrl cases for both modes and id encoding. npm test 11364 passed, lint 0 errors. Button row screenshotted against the design system (pill outline, Eye icon) via a temporary sandbox route. Closes #1190 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
df29817826
commit
c62d00bcb3
@@ -33,6 +33,7 @@ import {
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Download,
|
||||
Eye,
|
||||
XCircle,
|
||||
Mail,
|
||||
ReceiptText,
|
||||
@@ -126,6 +127,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
// Set when the archived copy could not be produced, so the user is asked
|
||||
// instead of being handed a substitute that looks like the original.
|
||||
const [pdfArchiveIssue, setPdfArchiveIssue] = useState<'history' | 'document' | null>(null)
|
||||
// Which action raised that question: the dialog's fallback must do what the
|
||||
// user originally asked for (open in the browser vs save the file), not
|
||||
// silently switch mechanism (#1190).
|
||||
const [pdfIntent, setPdfIntent] = useState<'download' | 'preview'>('download')
|
||||
// Payment history backing the new Betalningsstatus card. Fetched alongside
|
||||
// the invoice itself so the card stays in sync with paid_amount /
|
||||
// remaining_amount on the invoice row.
|
||||
@@ -483,6 +488,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
if (!invoice) return
|
||||
|
||||
if (source.kind === 'unavailable') {
|
||||
setPdfIntent('download')
|
||||
setPdfArchiveIssue('history')
|
||||
return
|
||||
}
|
||||
@@ -496,6 +502,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
// A missing archive is not a generation failure and must not offer a
|
||||
// silent substitute: hand the choice back to the user.
|
||||
if (source.kind === 'archived') {
|
||||
setPdfIntent('download')
|
||||
setPdfArchiveIssue('document')
|
||||
return
|
||||
}
|
||||
@@ -551,6 +558,60 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one specific document in the browser instead of saving it (#1190):
|
||||
* granskning should not require leaving the app for the Downloads folder.
|
||||
*
|
||||
* Which document may be shown is the same question as for the download, and
|
||||
* gets the same answer: the archived delivery when it exists, a re-render only
|
||||
* with the caveat spelled out, and a question rather than a guess when the
|
||||
* delivery history could not be read. Only the mechanism differs, so a tab is
|
||||
* opened synchronously (before any await) to keep the click's user activation
|
||||
* and stay clear of the popup blocker.
|
||||
*/
|
||||
function runInvoicePreview(source: InvoicePdfSource) {
|
||||
if (!invoice) return
|
||||
|
||||
if (source.kind === 'unavailable') {
|
||||
setPdfIntent('preview')
|
||||
setPdfArchiveIssue('history')
|
||||
return
|
||||
}
|
||||
|
||||
const url =
|
||||
source.kind === 'archived' ? source.url : invoiceRerenderUrl(invoice.id, { inline: true })
|
||||
|
||||
if (!window.open(url, '_blank', 'noopener,noreferrer')) {
|
||||
toast({
|
||||
title: t('pdf_preview_blocked_title'),
|
||||
description: t('pdf_preview_blocked_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const caveat = invoiceDocumentCaveat(source)
|
||||
if (caveat) {
|
||||
toast({
|
||||
title: t('pdf_rerender_preview_title'),
|
||||
description: t(RERENDER_CAVEAT_KEYS[caveat]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function previewPDF() {
|
||||
if (!invoice) return
|
||||
setPdfArchiveIssue(null)
|
||||
runInvoicePreview(
|
||||
resolveInvoicePdfSource({
|
||||
invoiceId: invoice.id,
|
||||
invoiceStatus: invoice.status,
|
||||
deliveriesLoaded: !deliveriesUnreadable,
|
||||
deliveries,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// "Försök igen" from the archive dialog. Re-reads the delivery history first
|
||||
// so a transient list failure resolves back to the archived copy instead of
|
||||
// getting stuck on the stale empty state.
|
||||
@@ -560,14 +621,21 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const result = await retryLoadDeliveries()
|
||||
setIsDownloading(false)
|
||||
setPdfArchiveIssue(null)
|
||||
await runInvoiceDownload(
|
||||
resolveInvoicePdfSource({
|
||||
invoiceId: invoice.id,
|
||||
invoiceStatus: invoice.status,
|
||||
deliveriesLoaded: result.ok,
|
||||
deliveries: result.deliveries,
|
||||
}),
|
||||
)
|
||||
const source = resolveInvoicePdfSource({
|
||||
invoiceId: invoice.id,
|
||||
invoiceStatus: invoice.status,
|
||||
deliveriesLoaded: result.ok,
|
||||
deliveries: result.deliveries,
|
||||
})
|
||||
// The retry is a second attempt at what the user asked for, not a switch to
|
||||
// the other mechanism. A preview retry re-resolves the source first, so the
|
||||
// tab it opens is no longer inside the original click's activation window;
|
||||
// a blocked popup is reported rather than swallowed.
|
||||
if (pdfIntent === 'preview') {
|
||||
runInvoicePreview(source)
|
||||
return
|
||||
}
|
||||
await runInvoiceDownload(source)
|
||||
}
|
||||
|
||||
// The user explicitly accepted a re-render after being told it is not the
|
||||
@@ -575,11 +643,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
async function downloadRerenderAnyway() {
|
||||
if (!invoice) return
|
||||
setPdfArchiveIssue(null)
|
||||
await runInvoiceDownload({
|
||||
kind: 'rerender',
|
||||
const source = {
|
||||
kind: 'rerender' as const,
|
||||
url: invoiceRerenderUrl(invoice.id),
|
||||
reason: 'archive_unreachable',
|
||||
})
|
||||
reason: 'archive_unreachable' as const,
|
||||
}
|
||||
if (pdfIntent === 'preview') {
|
||||
runInvoicePreview(source)
|
||||
return
|
||||
}
|
||||
await runInvoiceDownload(source)
|
||||
}
|
||||
|
||||
// Open the finalize dialog and peek the next F-number so the user can see
|
||||
@@ -896,14 +969,21 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
)}
|
||||
{/* No own PDF for a received self-billing invoice: the verifikationsunderlag is the document the customer sent us. */}
|
||||
{!isSelfBilled && (
|
||||
<Button variant="outline" onClick={downloadPDF} disabled={isDownloading}>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('download_pdf')}
|
||||
</Button>
|
||||
<>
|
||||
{/* Review in the browser (#1190); the download stays for keeping a copy. */}
|
||||
<Button variant="outline" onClick={previewPDF}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('preview_pdf')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={downloadPDF} disabled={isDownloading}>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('download_pdf')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1702,7 +1782,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
onClick={downloadRerenderAnyway}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{t('pdf_archive_issue_rerender')}
|
||||
{pdfIntent === 'preview'
|
||||
? t('pdf_archive_issue_rerender_preview')
|
||||
: t('pdf_archive_issue_rerender')}
|
||||
</Button>
|
||||
<Button onClick={retryArchivedDownload} disabled={isDownloading}>
|
||||
{isDownloading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
|
||||
@@ -95,6 +95,52 @@ describe('GET /api/invoices/[id]/pdf', () => {
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
|
||||
it('forces a download by default', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/invoices/invoice-1/pdf'),
|
||||
createMockRouteParams({ id: 'invoice-1' }),
|
||||
)
|
||||
|
||||
expect(response.headers.get('Content-Disposition')).toMatch(/^attachment;/)
|
||||
})
|
||||
|
||||
it('serves the PDF inline for in-browser review on ?disposition=inline (#1190)', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/invoices/invoice-1/pdf', {
|
||||
searchParams: { disposition: 'inline' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'invoice-1' }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Disposition')).toMatch(/^inline;/)
|
||||
// The filename still travels with it, so the browser viewer's own save
|
||||
// action produces the same name as the download button would.
|
||||
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
||||
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
||||
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
})
|
||||
|
||||
it('keeps the download behaviour for an unknown disposition value', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/invoices/invoice-1/pdf', {
|
||||
searchParams: { disposition: 'evil' },
|
||||
}),
|
||||
createMockRouteParams({ id: 'invoice-1' }),
|
||||
)
|
||||
|
||||
expect(response.headers.get('Content-Disposition')).toMatch(/^attachment;/)
|
||||
})
|
||||
|
||||
it('returns 400 before rendering when a foreign payment account is missing', async () => {
|
||||
enqueue({ data: { ...invoice, currency: 'EUR' }, error: null })
|
||||
enqueue({ data: { ...company, invoice_payment_accounts: {} }, error: null })
|
||||
|
||||
@@ -15,6 +15,17 @@ import {
|
||||
|
||||
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
|
||||
|
||||
/**
|
||||
* `?disposition=inline` serves the PDF for in-browser review instead of forcing
|
||||
* a download (#1190): reviewing an invoice should not mean opening a file from
|
||||
* the Downloads folder. Anything else, including a missing or malformed value,
|
||||
* keeps the download behaviour every existing caller relies on.
|
||||
*/
|
||||
function resolveDisposition(request: Request): 'inline' | 'attachment' {
|
||||
const requested = new URL(request.url).searchParams.get('disposition')
|
||||
return requested === 'inline' ? 'inline' : 'attachment'
|
||||
}
|
||||
|
||||
function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
@@ -127,9 +138,13 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': contentDisposition('attachment', filename),
|
||||
'Content-Disposition': contentDisposition(resolveDisposition(request), filename),
|
||||
'Content-Length': pdfBuffer.length.toString(),
|
||||
'Cache-Control': 'private, no-store',
|
||||
// The filename is derived from company/customer/invoice data, so the
|
||||
// Content-Type must not be re-sniffed from the bytes when the browser
|
||||
// renders this inline.
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user