Files
accounted/components/settings/InvoicePreviewCard.tsx
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01: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>
)
}