Files
Mattsson 9418de585f fix(invoices): say what is missing when an invoice preview cannot be rendered (#2303)
* fix(invoices): say what is missing when an invoice preview cannot be rendered

The PDF route already refuses with a structured envelope that names exactly
what the invoice lacks (no bankgiro, plusgiro, Swish or bank account for a
SEK invoice; no IBAN account for a foreign currency) and where to add it.
Two clients threw that away:

- The settings preview dialog (Inställningar -> Fakturering -> Förhandsvisa
  faktura) wrapped the envelope's inner object in new Error(), which
  stringified it to "[object Object]" and left only the generic "Kunde inte
  hantera fakturan. Försök igen." fallback. The parsed body now goes to the
  error mapper whole, with the invoice context and status.

- The invoice page's Förhandsgranska navigated a new tab straight to the
  re-render URL, so a 400 showed the raw JSON in that tab. The tab is now
  opened blank inside the click's activation window, the PDF is fetched
  first, and the tab gets the PDF as a blob URL or is closed again with the
  refusal in a toast. The archived delivery copy keeps the direct open.
  Ladda ner on the same page had a fixed "Kunde inte generera PDF" for
  re-render refusals and now maps the body the same way.

Regression test on the mapper covers the exact call shape the two surfaces
use and pins the old mangled shape as the fallback it produced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUdZPW46a16GWUdgt2qSZA

* fix(invoices): probe the PDF route before opening the preview tab

Resolves the review findings on the first push in one pass.

Skeptic (correctness): the archived-copy branch still called window.open
with 'noopener', which returns null by spec even on success, so every
successful archived preview also fired the "popup blocked" toast (#1613
had the same defect). Both branches now go through openDeferredTab, which
opens with a real handle and severs the opener itself.

Skeptic (regression): serving the re-render as a blob URL lost the
Content-Disposition filename and gave the tab an address that dies on
reload. The route gains ?probe=1, which runs every refusal check and
answers 204 without rendering; the page probes first, shows a refusal as a
toast, and otherwise points the tab at the real inline URL. Filename,
reload and the single render are all kept. The blob URL is gone, which
also settles the compliance swarm's noopener and unrevoked-blob notes and
CodeRabbit's revoke request.

CodeRabbit: the probe fetch is bounded by AbortSignal.timeout so a stalled
route cannot leave a blank tab open, and the network-error mapper now
receives the active locale and invoice context.

Tests: route probe (204 without render, same 400 envelope as the render,
unknown value ignored) and the URL helper's probe flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUdZPW46a16GWUdgt2qSZA

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 11:55:45 +02:00

198 lines
6.8 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
import { Eye } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { CompanySettings } from '@/types'
interface InvoicePreviewCardProps {
settings: CompanySettings
}
export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
const t = useTranslations('settings_invoicing_preview')
const locale = useLocale() as ErrorLocale
const { company } = useCompany()
const [open, setOpen] = useState(false)
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentUrlRef = useRef<string | null>(null)
const sampleItemDescription = t('sample_item_description')
useEffect(() => {
if (!open || !company?.id) return
const companyId = company.id
let cancelled = false
const controller = new AbortController()
// Debounce so rapid settings toggles (PDF print options on the invoicing
// page) don't burst-fire requests at /api/invoices/preview-pdf while the
// dialog is open. AbortController still cancels any in-flight fetch.
const timer = setTimeout(() => {
run()
}, 500)
async function run() {
setIsLoading(true)
setError(null)
try {
const supabase = createClient()
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id')
.eq('company_id', companyId)
.is('archived_at', null)
.limit(1)
.maybeSingle()
if (customerError) throw customerError
if (cancelled) return
// Non-momsregistrerade säljare ska inte få en exempel-rad med 25 %
// VAT: förhandsvisningen är hårdkodad sample-data, inte ett val
// användaren gjort, så vi följer settings.vat_registered direkt här
// (till skillnad från /invoices/new som låter användaren välja och
// bara varnar vid submit).
const previewVatRate = settings.vat_registered === false ? 0 : 25
const response = await fetch('/api/invoices/preview-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
customer_id: customer?.id,
currency: 'SEK',
document_type: 'invoice',
items: [
{
description: sampleItemDescription,
quantity: 1,
unit: 'st',
unit_price: 1000,
vat_rate: previewVatRate,
},
],
}),
})
if (!response.ok) {
// The route answers with the structured envelope ({ error: { code,
// message, details } }); the mapper reads it whole and says exactly
// what is missing (e.g. no bankgiro for a SEK invoice). Wrapping
// `body.error` in `new Error()` stringified the object and left only
// the generic "Kunde inte hantera fakturan" fallback.
const body: unknown = await response.json().catch(() => null)
if (cancelled) return
setError(
getErrorMessage(body ?? new Error(`HTTP ${response.status}`), {
locale,
context: 'invoice',
statusCode: response.status,
}),
)
setIsLoading(false)
return
}
const blob = await response.blob()
if (cancelled) return
const url = URL.createObjectURL(blob)
if (currentUrlRef.current) URL.revokeObjectURL(currentUrlRef.current)
currentUrlRef.current = url
setBlobUrl(url)
setIsLoading(false)
} catch (err) {
if (cancelled) return
if (err instanceof Error && err.name === 'AbortError') return
setError(getErrorMessage(err, { locale, context: 'invoice' }))
setIsLoading(false)
}
}
return () => {
cancelled = true
clearTimeout(timer)
controller.abort()
}
}, [open, settings, company?.id, sampleItemDescription, locale])
useEffect(() => {
return () => {
if (currentUrlRef.current) {
URL.revokeObjectURL(currentUrlRef.current)
currentUrlRef.current = null
}
}
}, [])
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* Quiet header-action trigger (Fönster): the preview lives in the
section header's action slot, not as a card in the page flow. */}
<DialogTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<Eye className="h-3.5 w-3.5" />
{t('preview_button')}
</button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
</DialogHeader>
{isLoading && (
<div className="space-y-2" aria-live="polite" aria-busy="true">
<Skeleton className="h-[70vh] w-full rounded-lg" />
<p className="text-xs text-muted-foreground">{t('loading')}</p>
</div>
)}
{!isLoading && error && (
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<p className="text-sm text-destructive">{t('error')}: {error}</p>
</div>
)}
{!isLoading && !error && blobUrl && (
// <object> + type="application/pdf" invokes Chrome's PDF plugin
// directly. <iframe> went through Chrome's frame pipeline first
// and intermittently surfaced "Det här innehållet har blockerats"
// even with a permissive CSP. See AttachmentPreviewSheet.tsx for
// the same workaround on journal entry attachments.
<object
data={blobUrl}
type="application/pdf"
title={t('iframe_title')}
className="w-full h-[70vh] rounded-lg border border-border"
>
<p className="p-4 text-sm text-muted-foreground">
{t('error')}:{' '}
<a href={blobUrl} target="_blank" rel="noreferrer" className="underline">
{t('iframe_title')}
</a>
</p>
</object>
)}
</DialogContent>
</Dialog>
)
}