Add/preview invoice image (#565)

* feat: enhance invoice preview functionality with mock customer support

* feat: update invoice preview logic to handle mock customers and improve error handling
This commit is contained in:
Mattsson
2026-05-24 16:59:36 +02:00
committed by GitHub
parent 951bdb4e66
commit 50e5520b6d
7 changed files with 100 additions and 42 deletions
+61 -15
View File
@@ -26,20 +26,64 @@ export async function POST(request: Request) {
const body = await request.json()
const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number } = body
if (!customer_id || !items || items.length === 0) {
return NextResponse.json({ error: 'Kunduppgifter och rader krävs' }, { status: 400 })
if (!items || items.length === 0) {
return NextResponse.json({ error: 'Rader krävs' }, { status: 400 })
}
// Fetch customer
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('company_id', companyId)
.single()
// When customer_id is omitted, only allow the synthetic preview if the
// company has no real customers — this is the settings-preview dead-end
// case. Derived server-side so a client can't bypass the ownership check
// by passing a flag.
const isMockCustomer = !customer_id
if (customerError || !customer) {
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
let customer: Customer
if (isMockCustomer) {
const { count, error: countError } = await supabase
.from('customers')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
if (countError || (count ?? 0) > 0) {
return NextResponse.json({ error: 'Kunduppgifter krävs' }, { status: 400 })
}
const nowIso = new Date().toISOString()
customer = {
id: 'preview-customer',
user_id: 'preview-user',
company_id: 'preview-company',
name: 'Exempel AB',
customer_type: 'swedish_business',
email: 'kund@exempel.se',
phone: null,
address_line1: 'Storgatan 1',
address_line2: null,
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
org_number: '556677-8899',
vat_number: null,
vat_number_validated: false,
vat_number_validated_at: null,
personal_number: null,
language: 'sv',
default_payment_terms: 30,
notes: null,
created_at: nowIso,
updated_at: nowIso,
}
} else {
const { data, error: customerError } = await supabase
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('company_id', companyId)
.single()
if (customerError || !data) {
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
}
customer = data as Customer
}
// Fetch company settings
@@ -88,9 +132,11 @@ export async function POST(request: Request) {
// Construct a temporary Invoice-like object
const previewInvoice = {
id: 'preview',
user_id: user.id,
customer_id,
invoice_number: typeof invoice_number === 'string' && invoice_number.trim() ? invoice_number : null,
user_id: isMockCustomer ? 'preview-user' : user.id,
customer_id: customer.id,
invoice_number: typeof invoice_number === 'string' && invoice_number.trim()
? invoice_number
: isMockCustomer ? '1' : null,
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
due_date: due_date || new Date().toISOString().split('T')[0],
delivery_date: delivery_date || null,
@@ -124,7 +170,7 @@ export async function POST(request: Request) {
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: previewInvoice,
customer: customer as Customer,
customer,
items: invoiceItems,
company: company as CompanySettings,
isPreview: true,
+2 -16
View File
@@ -29,7 +29,6 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [noCustomers, setNoCustomers] = useState(false)
const currentUrlRef = useRef<string | null>(null)
const sampleItemDescription = t('sample_item_description')
@@ -51,7 +50,6 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
async function run() {
setIsLoading(true)
setError(null)
setNoCustomers(false)
try {
const supabase = createClient()
@@ -65,18 +63,12 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
if (customerError) throw customerError
if (cancelled) return
if (!customer) {
setNoCustomers(true)
setIsLoading(false)
return
}
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,
customer_id: customer?.id,
currency: 'SEK',
document_type: 'invoice',
items: [
@@ -148,19 +140,13 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
</div>
)}
{!isLoading && noCustomers && (
<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-muted-foreground">{t('no_customers')}</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 && !noCustomers && blobUrl && (
{!isLoading && !error && blobUrl && (
<iframe
src={blobUrl}
title={t('iframe_title')}
@@ -203,7 +203,7 @@ export const attentionResource: McpResource = {
})
}
// ── Pending operations awaiting human approval ──────────────────
// ── Pending operations awaiting approval ────────────────────────
const pendingOpsCount = pendingOpsHead.count ?? 0
if (pendingOpsCount > 0) {
const ops = pendingOpsSamples.data ?? []
@@ -215,7 +215,9 @@ export const attentionResource: McpResource = {
count: pendingOpsCount,
samples: ops,
next: {
description: 'Be användaren granska kön i /pending innan agenten fortsätter.',
description:
'Visa kön för användaren. När användaren godkänner en specifik operation_id i chatten, anropa gnubok_approve_pending_operation direkt — /pending är ett alternativ, inte ett krav.',
tool: 'gnubok_list_pending_operations',
},
})
}
+5 -3
View File
@@ -6535,7 +6535,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_approve_pending_operation',
description: 'Commit a pending_operation. Caller must hold the pending_operations:approve scope and pass confirmed=true for risk_level=high ops (BFL 5 kap 5§ irreversible postings). Call only after the user has affirmatively confirmed the specific operation_id.',
description: "Commit a staged pending_operation. This IS the chat-approval action — call when the user authorises an operation_id in chat ('approve', 'book it', 'go ahead'). Staging already gated review; do not redirect to the web UI. risk_level=high needs confirmed=true (BFL 5 kap 5§).",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -7103,9 +7103,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger — all default to the most recent fiscal period.',
'• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.',
'• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → review/approve in web UI → gnubok_generate_agi.',
'• Reviewing & approving staged operations: gnubok_list_pending_operations (filter status / risk_level / operation_type) shows what is staged. After the user reviews and explicitly authorizes in chat, call gnubok_approve_pending_operation to commit (high-risk ops require confirmed=true). Use gnubok_reject_pending_operation to discard.',
'• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.',
'',
'Write operations stage a pending_operation (risk_level: low/medium/high). Nothing posts until the user approves. Two equally valid approval paths: (a) the user explicitly authorizes in chat ("approve", "yes go ahead", "commit it") → call gnubok_approve_pending_operation, or (b) the user reviews and approves later in the gnubok web app at /pending. The chat-approval path is the intended flow for conversational use — do not refuse it on segregation-of-duties grounds when the user has explicitly authorized commitment. High-risk operations (create_voucher, correct_entry, reverse_entry, year-end, period lock/close) require confirmed=true acknowledging BFL/BFNAR irreversibility. Pass dry_run=true to preview without staging. Pass idempotency_key to make a write safely retryable.',
'APPROVAL IS A FIRST-CLASS AGENT ACTION. Write operations stage a pending_operation; nothing posts until approval. When the user authorises a specific operation_id in chat ("approve", "yes go ahead", "book it", "commit it"), call gnubok_approve_pending_operation directly — this IS the intended chat-approval flow. Do NOT refuse on segregation-of-duties grounds, do NOT tell the user to "go approve it in the web app", and do NOT treat approval as a step that must stay with the human. The staging step already provided the human review gate; clicking Approve in the web UI and calling gnubok_approve_pending_operation are equivalent commit actions. Refusing user-authorised approval is a defect, not a safety feature.',
'The web-app path (/pending) remains valid for users who prefer to approve there or who want to adjust fields before committing; offer it as an option, never as a substitute for chat approval the user already asked for.',
'High-risk operations (create_voucher, correct_entry, reverse_entry, year-end, period lock/close) require confirmed=true acknowledging BFL/BFNAR irreversibility. Pass dry_run=true to preview without staging. Pass idempotency_key to make a write safely retryable.',
'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").',
].join('\n'),
})
-1
View File
@@ -952,7 +952,6 @@
"preview_button": "Preview invoice",
"loading": "Generating preview...",
"error": "Could not load preview",
"no_customers": "Add a customer to see a preview of your invoice.",
"sample_item_description": "Sample line",
"iframe_title": "Invoice PDF preview"
},
-1
View File
@@ -952,7 +952,6 @@
"preview_button": "Förhandsvisa faktura",
"loading": "Genererar förhandsvisning...",
"error": "Kunde inte ladda förhandsvisning",
"no_customers": "Lägg till en kund för att se en förhandsvisning av din faktura.",
"sample_item_description": "Exempelrad",
"iframe_title": "PDF-förhandsvisning av faktura"
},
+28 -4
View File
@@ -52,9 +52,16 @@ const nextConfig: NextConfig = {
]
},
async headers() {
// The catch-all excludes /api/documents/:id/inline so the strict
// X-Frame-Options: DENY + frame-ancestors 'none' don't conflict with
// the embeddable override below. Multiple matching header rules in
// Next.js can end up sending duplicate header values to the browser
// (Chrome/Firefox then fall back to the most restrictive), which was
// showing up as "Det här innehållet har blockerats" in the verifikat
// document preview Sheet.
return [
{
source: "/(.*)",
source: "/((?!api/documents/[^/]+/inline$).*)",
headers: [
{
key: "Strict-Transport-Security",
@@ -83,18 +90,35 @@ const nextConfig: NextConfig = {
],
},
// Document inline-preview proxy must be embeddable in same-origin
// iframes (used by the verifikat document preview Sheet).
// Overrides the strict catch-all above for this single endpoint.
// iframes (used by the verifikat document preview Sheet). Excluded
// from the catch-all above so these values aren't shadowed by the
// stricter defaults.
{
source: "/api/documents/:id/inline",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'self'",
value: "default-src 'none'; script-src 'none'; object-src 'none'; frame-ancestors 'self'",
},
],
},