f338850bd0
* fix: hide API-archived customers and suppliers from lists and pickers The v1 API soft-archives customers and suppliers (archived_at, plus is_active=false on suppliers) and its own list routes hide those rows behind ?include_archived=true. No other surface filtered archived_at, so an archived counterparty stayed a normal row in the dashboard rosters, the internal /api/customers and /api/suppliers list routes, the MCP list tools and every customer/supplier picker. Apply the same canonical `archived_at IS NULL` filter on every non-v1 list and picker path: - /api/customers GET, /api/suppliers GET (feeds the customers page and the supplier-invoice form) - suppliers dashboard page (reads suppliers via browser Supabase) - InvoiceEditor and NewRecurringScheduleDialog customer pickers; an invoice or schedule being edited keeps its current customer visible (archiving does not refuse on drafts, so a draft can point at one) - deadlines page and CalendarWorkspace customer pickers - InvoicePreviewCard sample customer - gnubok_list_customers and gnubok_list_suppliers: hidden by default, optional include_archived boolean mirroring the v1 flag; rows now carry archived_at so an agent can tell them apart when opted in Detail routes and by-id lookups are untouched: an archived row still opens. The delete-vs-archive semantics are unchanged. The tools/list payload guard moves 60.7K to 60.8K: main had ~6 tokens of headroom, so even the bare boolean contract crossed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): raise the unresolvable-expression ceiling by 2 for the archived-customer picker filters The two .or('archived_at.is.null,id.eq.<uuid>') filters keep an edited draft's archived customer selectable. The uuid is a runtime value, so the scanner cannot resolve the expression; both columns exist and the filter is covered by the archived-counterparty tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
184 lines
6.2 KiB
TypeScript
184 lines
6.2 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) {
|
|
const body = await response.json().catch(() => null)
|
|
throw new Error(body?.error || `HTTP ${response.status}`)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|