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
@@ -1,10 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { openDeferredTab } from '@/lib/browser/deferred-tab'
|
||||
|
||||
interface DocumentViewButtonProps {
|
||||
documentId: string
|
||||
@@ -15,18 +13,24 @@ interface DocumentViewButtonProps {
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Opens a document's signed download URL in a new tab. The signed URL is
|
||||
* minted on demand via /api/documents/:id (60 min TTL), so we don't bake
|
||||
* stale URLs into the preview payload.
|
||||
* Opens a document in the browser through the same-origin inline proxy
|
||||
* (/api/documents/:id/inline), which serves it with
|
||||
* `Content-Disposition: inline`: PDFs land in the browser's viewer and images
|
||||
* render, instead of the file dropping into the Downloads folder (#1190).
|
||||
*
|
||||
* The proxy authorizes with the caller's own client before the service-role
|
||||
* client reads the bucket, and no URL has to be minted first: navigation
|
||||
* happens straight from the click, so there is nothing for a popup blocker to
|
||||
* catch (the previous signed-URL fetch needed openDeferredTab for exactly that
|
||||
* reason). The browser's own viewer still offers saving the file.
|
||||
*/
|
||||
export function DocumentViewButton({ documentId, label = 'Visa dokument', className }: DocumentViewButtonProps) {
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
const handleClick = () => {
|
||||
// documentId originates from staged preview_data (Record<string, unknown>);
|
||||
// validate the shape before interpolating into the request URL so a malformed
|
||||
// payload can't redirect the fetch at another internal endpoint.
|
||||
// validate the shape before interpolating into the URL so a malformed
|
||||
// payload can't point the tab at another internal endpoint.
|
||||
if (!UUID_RE.test(documentId)) {
|
||||
toast({
|
||||
title: 'Ogiltigt dokument-ID',
|
||||
@@ -35,37 +39,13 @@ export function DocumentViewButton({ documentId, label = 'Visa dokument', classN
|
||||
})
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
// Pre-open inside the click's user activation: a window.open after the
|
||||
// await is popup-blocked when the signed-URL fetch is slow.
|
||||
const tab = openDeferredTab('Laddar...')
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}`)
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok || !json?.data?.download_url) {
|
||||
tab.close()
|
||||
toast({
|
||||
title: 'Kunde inte öppna dokumentet',
|
||||
description: json?.error || 'Försök igen om en stund.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!tab.navigate(json.data.download_url as string)) {
|
||||
tab.close()
|
||||
toast({
|
||||
title: 'Kunde inte öppna dokumentet',
|
||||
description: tab.blocked
|
||||
? 'Tillåt popupfönster för Accounted i webbläsaren och försök igen.'
|
||||
: 'Försök igen om en stund.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
tab.close()
|
||||
toast({ title: 'Kunde inte öppna dokumentet', variant: 'destructive' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
||||
if (!window.open(`/api/documents/${documentId}/inline`, '_blank', 'noopener,noreferrer')) {
|
||||
toast({
|
||||
title: 'Kunde inte öppna dokumentet',
|
||||
description: 'Tillåt popupfönster för Accounted i webbläsaren och försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,14 +55,9 @@ export function DocumentViewButton({ documentId, label = 'Visa dokument', classN
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={className}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user