9418de585f
* 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>
198 lines
6.8 KiB
TypeScript
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>
|
|
)
|
|
}
|