Files
accounted/app/(dashboard)/invoices/[id]/page.tsx
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

2407 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useRef, use } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { guardBrowserWrite } from '@/lib/company/tab-guard'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { invoiceDisplayNumber, isTextLikeLine } from '@/lib/invoices/display'
import { getDisplayTotal, getAmountToPay } from '@/lib/invoices/rounding'
import { workTypeLabel } from '@/lib/invoices/rot-rut-rules'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import { canCopyInvoice } from '@/lib/invoices/copy-invoice'
import {
invoiceDocumentCaveat,
invoiceRerenderUrl,
paymentConfirmationPdfSource,
resolveInvoicePdfSource,
type InvoicePdfRerenderReason,
type InvoicePdfSource,
} from '@/lib/invoices/invoice-pdf-source'
import { isPaymentConfirmationEligible } from '@/lib/invoices/payment-confirmation'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
import {
Loader2,
ArrowLeft,
Send,
CheckCircle,
FileCheck2,
FileText,
Download,
Eye,
XCircle,
Mail,
ReceiptText,
AlertTriangle,
Trash2,
Lock,
CalendarClock,
Pencil,
Copy,
MoreHorizontal,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Skeleton } from '@/components/ui/skeleton'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import {
InvoiceDeliveryHistory,
type InvoiceDeliveryView,
} from '@/components/invoices/InvoiceDeliveryHistory'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
import type { InvoiceWithRelations } from '@/components/invoices/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { useBranding } from '@/lib/branding/brand-context'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Minimized Peppol delivery projection from GET /api/invoices/[id]/peppol/deliveries. */
interface PeppolDeliveryView {
id: string
recipient_scheme: string
recipient_identifier: string
status: string
status_at: string
status_detail: string | null
provider_submission_id: string | null
}
const PEPPOL_STATUS_KEYS = new Set([
'staged', 'recipient_verified', 'submitting', 'retryable_failure', 'submission_accepted',
'transport_succeeded', 'recipient_acknowledged', 'business_accepted', 'business_rejected',
'no_route', 'failed',
])
const PEPPOL_SENDABLE_STATUSES = new Set<InvoiceStatus>(['draft', 'sent', 'overdue'])
// Why the downloaded file is not the invoice the customer received. One key
// per reason: "no archived copy exists" and "the archive could not be reached"
// are different facts and must not be told as the same story.
const RERENDER_CAVEAT_KEYS: Record<
Exclude<InvoicePdfRerenderReason, 'not_sent_yet'>,
string
> = {
sent_outside_accounted: 'pdf_rerender_reason_sent_outside',
no_archived_copy: 'pdf_rerender_reason_no_archive',
archive_unreachable: 'pdf_rerender_reason_archive_unreachable',
payment_confirmation: 'pdf_rerender_reason_payment_confirmation',
}
// A line is periodiserad when both period dates are set: the revenue was
// parked on the 29xx interim account and dissolves monthly via accrual_schedules.
const itemHasAccrual = (item: InvoiceItem): boolean =>
!!(item.accrual_period_start && item.accrual_period_end)
const accrualMonth = (date: string): string => date.slice(0, 7)
// In-row text actions inside DefRow values: always underlined so they read as
// actions next to plain values, the hairline underline darkening on hover.
const ROW_ACTION_CLASS =
'underline decoration-border underline-offset-4 transition-colors duration-150 hover:decoration-foreground disabled:opacity-50'
// Same arithmetic as the list page's overdue chip, so both say the same days.
function daysOverdue(dueDateStr: string): number {
const today = new Date()
today.setHours(0, 0, 0, 0)
const dueDate = new Date(dueDateStr)
dueDate.setHours(0, 0, 0, 0)
return Math.round((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
}
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { company, isSandbox } = useCompany()
const canEmail = useCapability(CAPABILITY.email_send)
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoice_detail')
const { appName } = useBranding()
// Begäran status labels are shared with the payout dialog on the list page.
const tInvoices = useTranslations('invoices')
const tCommon = useTranslations('common')
const locale = useLocale()
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
const [deliveries, setDeliveries] = useState<InvoiceDeliveryView[]>([])
// An empty deliveries list means "nothing was ever sent through Accounted".
// A failed read also produces an empty list, and the two must never be
// conflated: the archived PDF the customer received is the räkenskapsunderlag
// (BFL 7 kap), and a freshly re-rendered one is a different document.
const [deliveriesUnreadable, setDeliveriesUnreadable] = useState(false)
// Set when the archived copy could not be produced, so the user is asked
// instead of being handed a substitute that looks like the original.
const [pdfArchiveIssue, setPdfArchiveIssue] = useState<'history' | 'document' | null>(null)
// Which action raised that question: the dialog's fallback must do what the
// user originally asked for (open in the browser vs save the file), not
// silently switch mechanism (#1190).
const [pdfIntent, setPdfIntent] = useState<'download' | 'preview'>('download')
// Payment history backing the new Betalningsstatus card. Fetched alongside
// the invoice itself so the card stays in sync with paid_amount /
// remaining_amount on the invoice row.
const [payments, setPayments] = useState<
Array<{
id: string
payment_date: string
amount: number
currency: string
journal_entry_id: string | null
voucher_series: string | null
voucher_number: number | null
}>
>([])
// ROT/RUT begäran rows this invoice is part of (fakturamodellen). Empty
// for invoices without a deduction and for claimed invoices whose begäran
// has not been generated yet; the Skattereduktion card reads it.
const [payoutRequests, setPayoutRequests] = useState<
Array<{
id: string
requested_amount: number
decided_amount: number | null
status: string
name: string
created_at: string
submitted_at: string | null
decided_at: string | null
}>
>([])
// Display form of the ROT/RUT personnummer (YYYYMMDD-XXXX). The row the
// browser holds carries only ciphertext + last four digits, and it must
// never hold both a mask and the last four (that is the full number), so
// the mask is fetched from the server for invoices with a claim.
// undefined = not fetched yet, null = nothing stored or unreadable.
const [deductionPersonnummerMasked, setDeductionPersonnummerMasked] = useState<
string | null | undefined
>(undefined)
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
const [showSendDialog, setShowSendDialog] = useState(false)
const [sendDialogMode, setSendDialogMode] = useState<'email' | 'manual'>('email')
const [isConverting, setIsConverting] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isUpdating, setIsUpdating] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [isDownloadingPeppol, setIsDownloadingPeppol] = useState(false)
// Betalningsbekräftelse (#1693): the paid re-render handed to the customer.
const [isDownloadingConfirmation, setIsDownloadingConfirmation] = useState(false)
const [showConfirmationSendDialog, setShowConfirmationSendDialog] = useState(false)
const [isPreparingPeppol, setIsPreparingPeppol] = useState(false)
const [isSendingPeppol, setIsSendingPeppol] = useState(false)
const [showPeppolSendDialog, setShowPeppolSendDialog] = useState(false)
// Whether this deployment has a contracted Access Point switched on; the
// menu item stays a truthful "provider required" note otherwise.
const [peppolTransportAvailable, setPeppolTransportAvailable] = useState(false)
// Per-company grant from the operators; without it the send item explains
// how to ask instead of pretending to work.
const [peppolAccess, setPeppolAccess] = useState<{
send_enabled: boolean
max_sends: number | null
sent_count: number
remaining_sends: number | null
} | null>(null)
const [peppolDeliveries, setPeppolDeliveries] = useState<PeppolDeliveryView[]>([])
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false)
const [isFinalizing, setIsFinalizing] = useState(false)
const [nextNumberPreview, setNextNumberPreview] = useState<string | null>(null)
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
// #967: register/send without booking; ekonomi books in a separate step.
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState(false)
// Company settings from the session cache (lib/reference-data): applied
// whenever the cached row (re)loads, no request per invoice visit.
const { settings: companySettings } = useCompanySettings()
useEffect(() => {
const settings = companySettings
if (!settings) return
setOreRounding(settings.ore_rounding ?? true)
if (typeof settings.vat_registered === 'boolean') {
setVatRegistered(settings.vat_registered)
}
setAccountingMethod(settings.accounting_method === 'cash' ? 'cash' : 'accrual')
setDeferInvoiceBooking(!!settings.defer_invoice_booking)
setReminderDays([
settings.reminder_days_level_1 ?? 15,
settings.reminder_days_level_2 ?? 30,
settings.reminder_days_level_3 ?? 45,
])
setAutoRemindersEnabled(settings.send_invoice_reminders ?? true)
}, [companySettings])
const [showBookConfirm, setShowBookConfirm] = useState(false)
const [bookVoucherPreview, setBookVoucherPreview] = useState<string | null>(null)
const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45])
// null = settings row not loaded; don't promise a reminder schedule then.
const [autoRemindersEnabled, setAutoRemindersEnabled] = useState<boolean | null>(null)
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
const reminderLevelLabel = (level: 1 | 2 | 3): string => t(`reminder_level_${level}`)
// Latest-request guard for fetchInvoice. A mutation refresh can overlap the
// pager stepping to a sibling invoice (the component stays mounted, only
// `id` changes), and without it the older response would commit invoice A's
// state under invoice B's URL. Only the newest request may write state.
const fetchSeqRef = useRef(0)
useEffect(() => {
fetchInvoice()
}, [id])
// Peppol status and transport availability for invoices that can carry an
// e-invoice; refreshed when the invoice changes state (draft -> sent).
useEffect(() => {
if (!invoice || invoice.id !== id) return
const eligible = (!invoice.document_type || invoice.document_type === 'invoice')
&& !invoice.credited_invoice_id
&& !invoice.is_self_billed
if (!eligible) return
void loadPeppolDeliveries()
}, [id, invoice?.id, invoice?.status, invoice?.invoice_number]) // eslint-disable-line react-hooks/exhaustive-deps
/**
* Read the delivery history, keeping "read failed" distinct from "nothing
* has been sent". Both used to arrive as `[]`, which is what let a network
* blip silently downgrade the invoice download from the archived PDF the
* customer received to a freshly re-rendered one.
*/
async function loadDeliveries(): Promise<{
ok: boolean
deliveries: InvoiceDeliveryView[]
}> {
try {
const response = await fetch(`/api/invoices/${encodeURIComponent(id)}/deliveries`)
if (!response.ok) return { ok: false, deliveries: [] }
const payload = (await response.json()) as { data?: InvoiceDeliveryView[] }
if (!Array.isArray(payload.data)) return { ok: false, deliveries: [] }
return { ok: true, deliveries: payload.data }
} catch {
return { ok: false, deliveries: [] }
}
}
/** Masked ROT/RUT personnummer from the server; null when absent or unreadable. */
async function loadDeductionPersonnummerMasked(): Promise<string | null> {
try {
const response = await fetch(`/api/invoices/${encodeURIComponent(id)}/rot-rut`)
if (!response.ok) return null
const payload = (await response.json()) as {
data?: { deduction_personnummer_masked?: string | null }
}
return payload.data?.deduction_personnummer_masked ?? null
} catch {
return null
}
}
async function retryLoadDeliveries() {
const result = await loadDeliveries()
setDeliveries(result.deliveries)
setDeliveriesUnreadable(!result.ok)
return result
}
async function fetchInvoice() {
const seq = ++fetchSeqRef.current
// The blocking spinner is reserved for the first load (or stepping to a
// different invoice via the pager). Refetches after Bokför / status
// change / finalize / payment / send reconcile BEHIND the mounted page:
// a one-field state change must not collapse the whole detail view to a
// spinner, reset scroll, and remount every card.
if (!invoice || invoice.id !== id) {
setIsLoading(true)
// A different invoice: never let the previous one's mask show on it.
setDeductionPersonnummerMasked(undefined)
}
const deliveriesPromise = loadDeliveries()
// Invoice, reminders, payments, and deliveries all key on the route id: one
// parallel batch. Only the follow-ups below need the invoice row.
const [{ data, error }, { data: reminderData }, { data: paymentData }, deliveryData, { data: payoutData }] =
await Promise.all([
supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.single(),
supabase
.from('invoice_reminders')
.select('*')
.eq('invoice_id', id)
.order('sent_at', { ascending: false }),
// Payment history for the Betalningsstatus card. Joins the
// journal_entries row to get voucher_series + voucher_number so each
// payment row can link to its verifikat. Manual payments (no tx, no
// JE) still surface with the amount + date.
supabase
.from('invoice_payments')
.select(
'id, payment_date, amount, currency, journal_entry_id, journal_entries(voucher_series, voucher_number)',
)
.eq('invoice_id', id)
.order('payment_date', { ascending: true }),
deliveriesPromise,
// ROT/RUT begäran this invoice belongs to (usually 0 or 1 rows).
// RLS scopes the join to the user's companies.
supabase
.from('rot_rut_payout_request_items')
.select(
'id, requested_amount, decided_amount, request:rot_rut_payout_requests(status, name, created_at, submitted_at, decided_at)',
)
.eq('invoice_id', id),
])
// A newer fetch owns the page now (pager step or later refresh): commit
// nothing from this one, not even the not-found redirect.
if (seq !== fetchSeqRef.current) return
if (error || !data) {
toast({
title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
router.push('/invoices')
return
}
// Sort items by sort_order
if (data.items) {
data.items.sort((a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order)
}
setInvoice(data as InvoiceWithRelations)
setDeliveries(deliveryData.deliveries)
setDeliveriesUnreadable(!deliveryData.ok)
if (reminderData) {
setReminders(reminderData as InvoiceReminder[])
}
if (paymentData) {
type PaymentRow = {
id: string
payment_date: string
amount: number
currency: string
journal_entry_id: string | null
journal_entries: { voucher_series: string | null; voucher_number: number | null } | null
}
setPayments(
(paymentData as unknown as PaymentRow[]).map((p) => ({
id: p.id,
payment_date: p.payment_date,
amount: p.amount,
currency: p.currency,
journal_entry_id: p.journal_entry_id,
voucher_series: p.journal_entries?.voucher_series ?? null,
voucher_number: p.journal_entries?.voucher_number ?? null,
})),
)
}
type PayoutRow = {
id: string
requested_amount: number
decided_amount: number | null
request:
| { status: string; name: string; created_at: string; submitted_at: string | null; decided_at: string | null }
| { status: string; name: string; created_at: string; submitted_at: string | null; decided_at: string | null }[]
| null
}
setPayoutRequests(
((payoutData ?? []) as unknown as PayoutRow[]).flatMap((row) => {
const req = Array.isArray(row.request) ? row.request[0] : row.request
if (!req) return []
return [
{
id: row.id,
requested_amount: Number(row.requested_amount),
decided_amount: row.decided_amount === null ? null : Number(row.decided_amount),
status: req.status,
name: req.name,
created_at: req.created_at,
submitted_at: req.submitted_at,
decided_at: req.decided_at,
},
]
}),
)
if (seq !== fetchSeqRef.current) return
// Related documents need the invoice row but do not gate the main detail
// view. Resolve them together after first paint and fill their links in.
setIsLoading(false)
void Promise.all([
(data.deduction_total ?? 0) > 0
? loadDeductionPersonnummerMasked()
: Promise.resolve(null),
!data.credited_invoice_id &&
['sent', 'paid', 'overdue', 'credited'].includes(data.status)
? supabase
.from('invoices')
.select('id, invoice_number, status')
.eq('credited_invoice_id', id)
.neq('status', 'cancelled')
.maybeSingle()
: Promise.resolve(null),
data.credited_invoice_id
? supabase
.from('invoices')
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', data.credited_invoice_id)
.single()
: Promise.resolve(null),
data.converted_from_id
? supabase
.from('invoices')
.select('id, invoice_number')
.eq('id', data.converted_from_id)
.single()
: Promise.resolve(null),
]).then(([personnummerMasked, creditNoteRes, originalRes, convertedRes]) => {
// Deferred writes need the same guard: they land after first paint
// and would otherwise attach the previous invoice's related documents
// to the one the pager has since navigated to.
if (seq !== fetchSeqRef.current) return
setDeductionPersonnummerMasked(personnummerMasked)
setCreditNote(creditNoteRes?.data ? (creditNoteRes.data as Invoice) : null)
if (originalRes?.data) {
setOriginalInvoice(originalRes.data as Invoice)
}
if (convertedRes?.data) {
setConvertedFromInvoice(convertedRes.data as Invoice)
}
})
}
// #967: deferred booking: create the revenue verifikat afterwards.
// Confirm-before-posting (convention 10): booking an invoice writes an
// immutable verifikat, so describe the outcome first. The predicted voucher
// number is indicative; the toast afterwards reports what actually landed.
function openBookConfirm() {
setShowBookConfirm(true)
setBookVoucherPreview(null)
fetch('/api/bookkeeping/voucher-sequences/next')
.then((r) => r.json())
.then(({ data }) => {
if (data?.next != null) setBookVoucherPreview(`${data.series}${data.next}`)
})
.catch(() => {})
}
async function handleBook() {
if (!invoice) return
setIsUpdating(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/book`, { method: 'POST' })
const data = await response.json()
if (!response.ok) {
// data.error is a structured object for this route; only strings are
// usable as a toast message.
const message =
typeof data.error === 'string'
? data.error
: typeof data.error?.message === 'string'
? data.error.message
: t('book_failed_fallback')
throw new Error(message)
}
if (Array.isArray(data.warnings) && data.warnings.length > 0) {
// Booked, but a follow-up is needed (e.g. periodiseringar failed).
toast({ title: t('booked_title'), description: t('booked_with_warnings_description'), variant: 'destructive' })
} else {
toast({ title: t('booked_title'), description: t('booked_description') })
}
// Awaited so the Bokför button's pending state covers the in-place
// refresh: the spinner stops when the page shows the booked state.
await fetchInvoice()
} catch (error) {
toast({
title: t('book_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
} finally {
setIsUpdating(false)
}
}
async function updateStatus(status: InvoiceStatus) {
if (!invoice) return
// Cross-tab guard (WL-09): the direct Supabase branches below bypass the
// patched window.fetch, so consult the tab guard explicitly. On a
// mismatch the blocking dialog raised by guardBrowserWrite is the user
// feedback; nothing is written.
if (!guardBrowserWrite()) return
setIsUpdating(true)
try {
if (status === 'sent') {
// Use mark-sent API for proper bookkeeping
const response = await fetch(`/api/invoices/${invoice.id}/mark-sent`, {
method: 'POST',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || t('mark_sent_failed_fallback'))
}
} else if (status === 'cancelled') {
// Only drafts and proformas can be cancelled directly: sent/overdue/paid
// invoices have committed journal entries and require a credit note instead
if (invoice.status !== 'draft') {
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
if (docType !== 'proforma') {
throw new Error(t('cancel_posted_error'))
}
}
const { error } = await supabase
.from('invoices')
.update({ status })
.eq('id', invoice.id)
if (error) throw new Error(error.message)
} else {
const { error } = await supabase
.from('invoices')
.update({ status })
.eq('id', invoice.id)
if (error) throw new Error(error.message)
}
toast({
title: t('status_update_toast_title'),
description: t('status_update_toast_description', { status: statusLabel(status).toLowerCase() }),
})
// Awaited: the acting button keeps its pending state until the page
// reflects the new status (the refetch runs behind the mounted content).
await fetchInvoice()
} catch (error) {
toast({
title: t('status_update_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
setIsUpdating(false)
}
function openSendDialog(mode: 'email' | 'manual') {
setSendDialogMode(mode)
setShowSendDialog(true)
}
async function convertToInvoice() {
if (!invoice) return
setIsConverting(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/convert`, {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || t('convert_failed_fallback'))
}
toast({
title: t('converted_toast_title'),
description: t('converted_toast_description', { number: data.data.invoice_number }),
})
router.push(`/invoices/${data.data.id}`)
} catch (error) {
toast({
title: t('convert_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
setIsConverting(false)
}
/**
* Fetch and save one specific document, then say truthfully which one it was.
*
* The archived delivery is the invoice the customer actually received and is
* the räkenskapsunderlag kept for 7 years (BFL 7 kap). A re-render comes off
* today's invoice row, customer row, company settings and logo, so it is a
* different document whenever any of those moved. It may be served when it
* is the only thing that exists, but never under the plain "nedladdad" toast
* that reads as "here is what you sent".
*/
async function runInvoiceDownload(source: InvoicePdfSource) {
if (!invoice) return
if (source.kind === 'unavailable') {
setPdfIntent('download')
setPdfArchiveIssue('history')
return
}
setIsDownloading(true)
try {
const response = await fetch(source.url)
if (!response.ok) {
// A missing archive is not a generation failure and must not offer a
// silent substitute: hand the choice back to the user.
if (source.kind === 'archived') {
setPdfIntent('download')
setPdfArchiveIssue('document')
return
}
throw new Error(t('pdf_generate_failed'))
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = contentDispositionFilename(response.headers.get('Content-Disposition'))
?? `faktura-${invoice.invoice_number ?? `utkast-${invoice.id.slice(0, 8)}`}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
const caveat = invoiceDocumentCaveat(source)
if (caveat) {
toast({
title: t('pdf_rerender_downloaded_title'),
description: t(RERENDER_CAVEAT_KEYS[caveat], { appName }),
})
} else {
toast({
title: t('pdf_downloaded_title'),
description: invoice.invoice_number
? t('pdf_downloaded_with_number', { number: invoice.invoice_number })
: t('pdf_downloaded_draft'),
})
}
} catch (error) {
toast({
title: t('pdf_download_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
} finally {
setIsDownloading(false)
}
}
async function downloadPDF() {
if (!invoice) return
setPdfArchiveIssue(null)
await runInvoiceDownload(
resolveInvoicePdfSource({
invoiceId: invoice.id,
invoiceStatus: invoice.status,
deliveriesLoaded: !deliveriesUnreadable,
deliveries,
}),
)
}
/**
* Download the betalningsbekräftelse (#1693). Always a fresh render with the
* BETALD stamp, never the archived original: the file is named and toasted
* as a payment confirmation so it is not mistaken for the invoice sent.
*/
async function downloadPaymentConfirmation() {
if (!invoice) return
const source = paymentConfirmationPdfSource(invoice.id)
setIsDownloadingConfirmation(true)
try {
const response = await fetch(source.url)
if (!response.ok) {
const body = await response.json().catch(() => null) as {
error?: { code?: string; message?: string; message_en?: string }
} | null
throw body?.error ?? new Error(t('pdf_generate_failed'))
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = contentDispositionFilename(response.headers.get('Content-Disposition'))
?? `Betalningsbekraftelse-${invoice.invoice_number ?? invoice.id.slice(0, 8)}.pdf`
document.body.appendChild(anchor)
anchor.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(anchor)
toast({
title: t('payment_confirmation_downloaded_title'),
description: t(RERENDER_CAVEAT_KEYS.payment_confirmation),
})
} catch (error) {
toast({
title: t('pdf_download_failed_title'),
description: getUserErrorMessage(error, {
context: 'invoice',
locale: locale.startsWith('sv') ? 'sv' : 'en',
}),
variant: 'destructive',
})
} finally {
setIsDownloadingConfirmation(false)
}
}
/** Email the betalningsbekräftelse to the customer; confirmed up front. */
async function sendPaymentConfirmation() {
if (!invoice) return
try {
const response = await fetch(`/api/invoices/${invoice.id}/send-payment-confirmation`, {
method: 'POST',
})
const body = await response.json().catch(() => null) as {
error?: { code?: string; message?: string; message_en?: string }
} | null
if (!response.ok) {
throw body?.error ?? new Error(t('payment_confirmation_send_failed_description'))
}
toast({
title: t('payment_confirmation_sent_title'),
description: t('payment_confirmation_sent_description', {
email: invoice.customer?.email ?? '',
}),
})
} catch (error) {
toast({
title: t('payment_confirmation_send_failed_title'),
description: getUserErrorMessage(error, {
context: 'invoice',
locale: locale.startsWith('sv') ? 'sv' : 'en',
}),
variant: 'destructive',
})
}
}
async function downloadPeppolXml() {
if (!invoice) return
setIsDownloadingPeppol(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/peppol`)
if (!response.ok) {
const body = await response.json().catch(() => null) as {
error?: { code?: string; message?: string; message_en?: string }
} | null
throw body?.error ?? new Error(t('peppol_download_failed_description'))
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = contentDispositionFilename(response.headers.get('Content-Disposition'))
?? `peppol-invoice-${invoice.invoice_number ?? invoice.id}.xml`
document.body.appendChild(anchor)
anchor.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(anchor)
toast({
title: t('peppol_downloaded_title'),
description: t('peppol_downloaded_description'),
})
} catch (error) {
toast({
title: t('peppol_download_failed_title'),
description: error instanceof Error
? getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' })
: getUserErrorMessage(error, {
context: 'invoice',
locale: locale.startsWith('sv') ? 'sv' : 'en',
}),
variant: 'destructive',
})
} finally {
setIsDownloadingPeppol(false)
}
}
async function preparePeppolDelivery() {
if (!invoice) return
setIsPreparingPeppol(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/peppol`, { method: 'POST' })
const body = await response.json().catch(() => null) as {
error?: { code?: string; message?: string; message_en?: string }
} | null
if (!response.ok) {
throw body?.error ?? new Error(t('peppol_prepare_failed_description'))
}
toast({
title: t('peppol_prepared_title'),
description: t('peppol_prepared_description'),
})
} catch (error) {
toast({
title: t('peppol_prepare_failed_title'),
description: error instanceof Error
? getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' })
: getUserErrorMessage(error, {
context: 'invoice',
locale: locale.startsWith('sv') ? 'sv' : 'en',
}),
variant: 'destructive',
})
} finally {
setIsPreparingPeppol(false)
}
}
async function loadPeppolDeliveries() {
try {
const response = await fetch(`/api/invoices/${encodeURIComponent(id)}/peppol/deliveries`)
if (!response.ok) return
const payload = (await response.json()) as {
data?: PeppolDeliveryView[]
transport?: { available?: boolean }
access?: { send_enabled: boolean; max_sends: number | null; sent_count: number; remaining_sends: number | null }
}
const rows = Array.isArray(payload.data) ? [...payload.data] : []
rows.sort((a, b) => (a.status_at < b.status_at ? 1 : a.status_at > b.status_at ? -1 : 0))
setPeppolDeliveries(rows)
setPeppolTransportAvailable(payload.transport?.available === true)
setPeppolAccess(payload.access ?? null)
} catch {
// Peppol status is supplementary; the page stays usable without it.
}
}
async function sendViaPeppol() {
if (!invoice) return
setIsSendingPeppol(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/peppol/send`, { method: 'POST' })
const body = await response.json().catch(() => null) as {
data?: { already_submitted?: boolean; issuance?: { ok: boolean } | null }
error?: { code?: string; message?: string; message_en?: string }
} | null
if (!response.ok) {
throw body?.error ?? new Error(t('peppol_send_failed_description'))
}
setShowPeppolSendDialog(false)
const issuanceFailed = !!body?.data?.issuance && !body.data.issuance.ok
toast({
title: t('peppol_sent_title'),
description: body?.data?.already_submitted
? t('peppol_already_sent_description')
: issuanceFailed
? t('peppol_issue_failed_description')
: t('peppol_sent_description'),
...(issuanceFailed ? { variant: 'destructive' as const } : {}),
})
await fetchInvoice()
await loadPeppolDeliveries()
} catch (error) {
toast({
title: t('peppol_send_failed_title'),
description: error instanceof Error
? getUserErrorMessage(error, { locale: locale.startsWith('sv') ? 'sv' : 'en' })
: getUserErrorMessage(error, {
context: 'invoice',
locale: locale.startsWith('sv') ? 'sv' : 'en',
}),
variant: 'destructive',
})
} finally {
setIsSendingPeppol(false)
}
}
/**
* Show one specific document in the browser instead of saving it (#1190):
* granskning should not require leaving the app for the Downloads folder.
*
* Which document may be shown is the same question as for the download, and
* gets the same answer: the archived delivery when it exists, a re-render only
* with the caveat spelled out, and a question rather than a guess when the
* delivery history could not be read. Only the mechanism differs, so a tab is
* opened synchronously (before any await) to keep the click's user activation
* and stay clear of the popup blocker.
*/
function runInvoicePreview(source: InvoicePdfSource) {
if (!invoice) return
if (source.kind === 'unavailable') {
setPdfIntent('preview')
setPdfArchiveIssue('history')
return
}
const url =
source.kind === 'archived' ? source.url : invoiceRerenderUrl(invoice.id, { inline: true })
if (!window.open(url, '_blank', 'noopener,noreferrer')) {
toast({
title: t('pdf_preview_blocked_title'),
description: t('pdf_preview_blocked_description', { appName }),
variant: 'destructive',
})
return
}
const caveat = invoiceDocumentCaveat(source)
if (caveat) {
toast({
title: t('pdf_rerender_preview_title'),
description: t(RERENDER_CAVEAT_KEYS[caveat], { appName }),
})
}
}
function previewPDF() {
if (!invoice) return
setPdfArchiveIssue(null)
runInvoicePreview(
resolveInvoicePdfSource({
invoiceId: invoice.id,
invoiceStatus: invoice.status,
deliveriesLoaded: !deliveriesUnreadable,
deliveries,
}),
)
}
// "Försök igen" from the archive dialog. Re-reads the delivery history first
// so a transient list failure resolves back to the archived copy instead of
// getting stuck on the stale empty state.
async function retryArchivedDownload() {
if (!invoice) return
setIsDownloading(true)
const result = await retryLoadDeliveries()
setIsDownloading(false)
setPdfArchiveIssue(null)
const source = resolveInvoicePdfSource({
invoiceId: invoice.id,
invoiceStatus: invoice.status,
deliveriesLoaded: result.ok,
deliveries: result.deliveries,
})
// The retry is a second attempt at what the user asked for, not a switch to
// the other mechanism. A preview retry re-resolves the source first, so the
// tab it opens is no longer inside the original click's activation window;
// a blocked popup is reported rather than swallowed.
if (pdfIntent === 'preview') {
runInvoicePreview(source)
return
}
await runInvoiceDownload(source)
}
// The user explicitly accepted a re-render after being told it is not the
// document that was sent. The toast still says so.
async function downloadRerenderAnyway() {
if (!invoice) return
setPdfArchiveIssue(null)
const source = {
kind: 'rerender' as const,
url: invoiceRerenderUrl(invoice.id),
reason: 'archive_unreachable' as const,
}
if (pdfIntent === 'preview') {
runInvoicePreview(source)
return
}
await runInvoiceDownload(source)
}
// Open the finalize dialog and peek the next F-number so the user can see
// which number they'll get before committing. Read-only (peek_next_invoice_number);
// the real number is allocated atomically on confirm and may differ by one if
// another invoice is created in between.
async function openFinalizeDialog() {
setNextNumberPreview(null)
setShowFinalizeDialog(true)
try {
const r = await fetch('/api/invoices/next-number?document_type=invoice')
if (r.ok) {
const json = await r.json()
const preview = json?.data?.preview
// Only show a value that looks like a real invoice number. Guards the
// preview against an unexpected/oversized API response being rendered
// verbatim, a short alphanumeric token (optional series prefix), never
// free-form text.
setNextNumberPreview(
typeof preview === 'string' && /^[A-Za-z0-9-]{1,32}$/.test(preview) ? preview : null
)
}
} catch {
// Best-effort preview; the dialog still works without it.
}
}
// "Granska & skapa": finalize an unnumbered draft into a real invoice:
// allocate the F-number and emit invoice.created. After this the invoice
// behaves like any draft (send / makulera), no longer hard-deletable.
async function finalizeInvoice() {
if (!invoice) return
setIsFinalizing(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/finalize`, {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || t('fallback_try_again'))
}
toast({
title: t('finalized_toast_title'),
description: t('finalized_toast_description', { number: data.data?.invoice_number ?? '' }),
})
setShowFinalizeDialog(false)
await fetchInvoice()
} catch (error) {
toast({
title: t('finalize_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
} finally {
setIsFinalizing(false)
}
}
async function deleteInvoice() {
if (!invoice) return
setIsDeleting(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error?.message || t('cancel_failed_fallback'))
}
// Unnumbered drafts are hard deleted ("Ta bort"); numbered drafts are
// makulerade and keep their number in the series.
toast(
invoice.invoice_number && !invoice.credited_invoice_id
? {
title: t('cancelled_toast_title'),
description: t('cancelled_with_number', { number: invoice.invoice_number }),
}
: {
title: t('removed_toast_title'),
description: t('removed_toast_description'),
}
)
router.push(
invoice.credited_invoice_id
? `/invoices/${invoice.credited_invoice_id}`
: '/invoices',
)
} catch (error) {
toast({
title: t('cancel_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('fallback_try_again'),
variant: 'destructive',
})
}
setIsDeleting(false)
setShowDeleteDialog(false)
}
if (isLoading) {
return <DetailPageSkeleton cards={3} />
}
if (!invoice) {
return null
}
const customer = invoice.customer
const customerHasEmail = !!customer.email
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
const isProforma = docType === 'proforma'
const isDeliveryNote = docType === 'delivery_note'
const isRealInvoice = docType === 'invoice'
// #1693: only a fully paid faktura has a betalningsbekräftelse to offer.
const canSendPaymentConfirmation = isPaymentConfirmationEligible(invoice)
const isCreditNote = !!invoice.credited_invoice_id
const booksOnIssue = isCreditNote
? !!originalInvoice && creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
: accountingMethod === 'accrual' && !deferInvoiceBooking
// #967: sent under deferred booking; ekonomi books the revenue verifikat
// from here afterwards.
const canBookAfterwards =
isRealInvoice &&
!isCreditNote &&
!invoice.journal_entry_id &&
accountingMethod === 'accrual' &&
['sent', 'overdue'].includes(invoice.status)
const preferredSendMode = getCreditNoteSendMode({
customerHasEmail,
isSandbox,
canEmail,
})
const creditNoteNeedsRepair =
isCreditNote &&
invoice.status === 'sent' &&
!!originalInvoice &&
(
originalInvoice.status !== 'credited' ||
(
creditNoteNeedsJournalEntry(accountingMethod, originalInvoice) &&
!invoice.journal_entry_id
)
)
// An unnumbered draft is one saved via "Spara som utkast" that hasn't been
// finalized: no F-number yet, so it can still be reviewed-and-created or
// hard-deleted. Once finalized it gets a number and behaves like any draft.
const isUnnumberedDraft = invoice.status === 'draft' && !invoice.invoice_number && isRealInvoice
// A numbered draft is issued-but-unsent ("Ej skickad"), distinct from an
// unnumbered draft ("Utkast"). Display-only: the DB status stays 'draft'.
const isUnsentNumberedInvoice = invoice.status === 'draft' && !!invoice.invoice_number && isRealInvoice
// Self-billing invoices we received: the document is the counterparty's, so
// there is no own PDF to render and no send step: it arrives already booked.
const isSelfBilled = !!invoice.is_self_billed
// A draft (no committed verifikat, not sent, not self-billed) can be edited
// in place (header + lines) via /invoices/{id}/edit. Sent/paid invoices are
// immutable (BFL); they are corrected with a credit note instead.
const isEditableDraft = isEditableInvoiceDraft(invoice)
const isCopyable = canCopyInvoice(invoice)
// ROT/RUT (fakturamodellen): the customer owes total minus the deduction and
// the rest is claimed from Skatteverket. Same helper as the PDF and the
// invoice email so all three surfaces state the same "Att betala".
const amountToPay = getAmountToPay(invoice, { ore_rounding: oreRounding })
const deductionItems = invoice.items.filter(
(i) => i.deduction_type === 'rot' || i.deduction_type === 'rut',
)
const hasRot = deductionItems.some((i) => i.deduction_type === 'rot')
const hasRut = deductionItems.some((i) => i.deduction_type === 'rut')
const deductionKindLabel = hasRot && hasRut ? 'ROT/RUT' : hasRot ? 'ROT' : 'RUT'
const showDeduction = amountToPay.deductionApplies && !isDeliveryNote
// Fastighetsbeteckning / lägenhet live on the ROT lines (one property per
// invoice in practice); the first ROT line carries the value.
const rotItem = deductionItems.find((i) => i.deduction_type === 'rot')
const rotHousing = rotItem?.housing_designation ?? null
const rotApartment = rotItem?.apartment_number ?? null
const rotBrf = rotItem?.brf_org_number ?? null
const skvClaimable = invoice.status === 'paid' && payoutRequests.length === 0
// "RUT · Städning · 4 tim" under a claimed line, so the claim is visible on
// the item itself, not only in the PDF. The amount lives in the totals block.
const deductionLineInfo = (item: InvoiceItem): string => {
const parts = [item.deduction_type === 'rot' ? 'ROT' : 'RUT']
const label = workTypeLabel(item.work_type)
if (label) parts.push(label)
if (item.labor_hours && item.labor_hours > 0) {
parts.push(t('deduction_line_hours', { hours: item.labor_hours }))
}
return parts.join(' · ')
}
const hasAccruedItems = invoice.items.some(itemHasAccrual)
const latestCompletedDelivery = deliveries.find(
(delivery) => delivery.status === 'sent' || delivery.status === 'marked_sent',
)
// Document title: the number with its kind spelled out ("Faktura 4",
// "Kreditfaktura K-5", "Proforma 3"), so the doc-type chips the header used
// to carry become the title itself.
const titleNumber = invoice.invoice_number ?? ''
const title = isSelfBilled
? t('title_self_billed', { number: invoiceDisplayNumber(invoice as Invoice) })
: isCreditNote
? invoice.invoice_number
? t('title_credit_note', { number: titleNumber })
: t('title_credit_draft')
: isProforma
? t('title_proforma', { number: invoiceDisplayNumber(invoice as Invoice) })
: isDeliveryNote
? t('title_delivery_note', { number: invoiceDisplayNumber(invoice as Invoice) })
: invoice.invoice_number
? t('title_invoice', { number: titleNumber })
: t('title_draft')
// One status element, same rule as the list page (chips mark exceptions):
// sent and paid render as muted text, everything that deviates gets a chip.
const status: { label: string; exception: boolean; variant?: 'secondary' | 'outline' | 'warning' } =
invoice.status === 'cancelled'
? { label: statusLabel('cancelled'), exception: true, variant: 'secondary' }
: invoice.status === 'credited'
? { label: statusLabel('credited'), exception: true, variant: 'secondary' }
: invoice.status === 'draft'
? isUnsentNumberedInvoice
? { label: t('status_unsent'), exception: true, variant: 'outline' }
: { label: statusLabel('draft'), exception: true, variant: 'secondary' }
: invoice.status === 'partially_paid'
? { label: statusLabel('partially_paid'), exception: true, variant: 'warning' }
: invoice.status === 'overdue' && invoice.due_date
? {
label: tInvoices('status_overdue_days', { days: Math.max(1, daysOverdue(invoice.due_date)) }),
exception: true,
variant: 'warning',
}
: invoice.status === 'paid'
? {
label: invoice.paid_at
? tInvoices('status_paid_date', { date: formatDate(invoice.paid_at) })
: statusLabel('paid'),
exception: false,
}
: { label: statusLabel('sent'), exception: false }
const metaParts = [
customer.name,
t('created_at', { date: formatDate(invoice.created_at) }),
latestCompletedDelivery?.sent_at
? t('sent_on', { date: formatDate(latestCompletedDelivery.sent_at) })
: null,
].filter(Boolean)
// Secondary actions collapse into one overflow menu (convention 9: one
// obvious next step in the header, the alternatives behind a caret).
const showManualSendAlternative =
!isProforma &&
!isDeliveryNote &&
isUnsentNumberedInvoice &&
preferredSendMode === 'email'
const canCreateCreditNote =
(invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'paid') &&
isRealInvoice &&
!creditNote
// Download/prepare need the F-number (the XML carries it); sending a draft
// assigns the number server-side, so the menu shows once a provider is on.
const showPeppolActions = !isSelfBilled && isRealInvoice && !isCreditNote
&& (!!invoice.invoice_number || peppolTransportAvailable)
const peppolSendGranted = !!peppolAccess?.send_enabled
const peppolSendsLeft = peppolAccess?.remaining_sends === null || peppolAccess?.remaining_sends === undefined
? true
: peppolAccess.remaining_sends > 0
const canSendPeppol = peppolTransportAvailable && peppolSendGranted && peppolSendsLeft
&& PEPPOL_SENDABLE_STATUSES.has(invoice.status)
const peppolRecipientLabel = invoice.customer?.org_number
? `0007:${invoice.customer.org_number.replace(/\D/g, '')}`
: '0007'
const peppolStatusLabel = (status: string) =>
PEPPOL_STATUS_KEYS.has(status) ? t(`peppol_status_${status}`) : status
const latestPeppolDelivery = peppolDeliveries[0] ?? null
const showDestructive =
invoice.status !== 'cancelled' &&
invoice.status !== 'credited' &&
(!invoice.credited_invoice_id || invoice.status === 'draft') &&
(isProforma || invoice.status === 'draft')
const hasMenu =
!isSelfBilled ||
(isCopyable && canWrite) ||
showManualSendAlternative ||
canCreateCreditNote ||
showPeppolActions ||
showDestructive
// Aggregated VAT per rate for the totals block. Shown when at least one
// rate applies; a VAT-exempt company with a zero-VAT invoice shows no row.
const vatRows = (() => {
const vatByRate = new Map<number, number>()
for (const item of invoice.items) {
const rate = item.vat_rate ?? 0
const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
}
const entries = Array.from(vatByRate.entries())
.filter(([, vat]) => vat > 0)
.sort(([a], [b]) => b - a)
if (entries.length > 0) {
return entries.map(([rate, vat]) => ({ key: String(rate), label: t('vat_at_rate', { rate }), amount: vat }))
}
if (vatRegistered === false && invoice.vat_amount === 0) return []
return [{ key: 'zero', label: t('vat_label'), amount: 0 }]
})()
const { rounding } = amountToPay
const lineSubInfo = (item: InvoiceItem) => (
<>
{itemHasAccrual(item) && (
<span className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<CalendarClock className="h-3 w-3 shrink-0" />
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
{item.accrual_balance_account && ` · ${item.accrual_balance_account}`}
</span>
)}
{item.deduction_type && (
<span className="mt-0.5 block text-xs text-muted-foreground tabular-nums">
{deductionLineInfo(item)}
</span>
)}
</>
)
return (
<div className="space-y-8 stagger-enter">
{/* Back link + prev/next record pager on their own quiet row, so the
title below keeps a stable position while stepping between records */}
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={() => router.back()}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</button>
<DetailPager
contextKey={listContextKey('invoices', company?.id)}
basePath="/invoices"
currentId={id}
/>
</div>
{/* Header: serif title with one status element, a quiet meta line, and
the next step on the right. Everything else lives in the ⋯ menu. */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
{/* data-ph-mask: the title carries the invoice number */}
<h1 data-ph-mask="" className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
{status.exception ? (
<Badge variant={status.variant}>{status.label}</Badge>
) : (
<span className="text-sm text-muted-foreground">{status.label}</span>
)}
{hasAccruedItems && (
<Badge variant="outline" className="gap-1">
<CalendarClock className="h-3 w-3" />
{t('badge_accrued')}
</Badge>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">{metaParts.join(' · ')}</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{isEditableDraft && canWrite && (
<Button variant="outline" asChild>
<Link href={`/invoices/${invoice.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
{t('edit_draft')}
</Link>
</Button>
)}
{/* Review in the browser (#1190); the download lives in the menu. */}
{!isSelfBilled && (
<Button variant="outline" onClick={previewPDF}>
<Eye className="mr-2 h-4 w-4" />
{t('preview_pdf')}
</Button>
)}
{isProforma && invoice.status !== 'cancelled' && (
<Button
onClick={convertToInvoice}
disabled={isConverting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isConverting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : !canWrite ? (
<Lock className="mr-2 h-4 w-4" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
{t('convert_to_invoice')}
</Button>
)}
{isUnnumberedDraft && (
<Button
onClick={openFinalizeDialog}
disabled={isFinalizing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <FileText className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('finalize_action')}
</Button>
)}
{invoice.status === 'draft' && !isDeliveryNote && invoice.invoice_number && (
preferredSendMode === 'email' ? (
<Button
onClick={() => openSendDialog('email')}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Mail className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t(booksOnIssue ? 'send_via_email_and_book' : 'send_via_email')}
</Button>
) : (
<Button
onClick={() => openSendDialog('manual')}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}
</Button>
)
)}
{creditNoteNeedsRepair && (
<Button
onClick={() => openSendDialog('manual')}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <AlertTriangle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('complete_credit_bookkeeping')}
</Button>
)}
{isDeliveryNote && invoice.status === 'draft' && (
<Button
onClick={() => updateStatus('sent')}
disabled={isUpdating || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isUpdating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : canWrite ? (
<Send className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
{t('mark_as_sent')}
</Button>
)}
{/* partially_paid included (#1717): completes a stuck partial, e.g.
a sub-krona öresavrundning remaining, via the same dialog. */}
{(invoice.status === 'sent' || invoice.status === 'overdue' || invoice.status === 'partially_paid') && isRealInvoice && !isCreditNote && (
<Button
onClick={() => setShowPaymentDialog(true)}
disabled={isUpdating || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <CheckCircle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('mark_as_paid')}
</Button>
)}
{hasMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={tCommon('more_options')}>
{isDownloading || isDownloadingPeppol || isPreparingPeppol ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[240px]">
{!isSelfBilled && (
<DropdownMenuItem onSelect={() => void downloadPDF()} disabled={isDownloading}>
<Download className="h-4 w-4" />
{t('download_pdf')}
</DropdownMenuItem>
)}
{isCopyable && canWrite && (
<DropdownMenuItem asChild>
<Link href={`/invoices?copy=${invoice.id}`}>
<Copy className="h-4 w-4" />
{t('copy_invoice')}
</Link>
</DropdownMenuItem>
)}
{/* The header offers "Skicka via e-post" as the next step; the
manual path stays reachable for invoices sent another way. */}
{showManualSendAlternative && (
<DropdownMenuItem
onSelect={() => openSendDialog('manual')}
disabled={!canWrite}
className="items-start"
>
<Send className="mt-0.5 h-4 w-4" />
<span className="min-w-0">
<span className="block">{t(booksOnIssue ? 'mark_sent_and_book' : 'mark_as_sent')}</span>
<span className="block text-[11px] leading-snug text-muted-foreground">
{t('send_manual_hint_with_email')}
</span>
</span>
</DropdownMenuItem>
)}
{canCreateCreditNote && (
<DropdownMenuItem asChild>
<Link href={`/invoices/${invoice.id}/credit`}>
<ReceiptText className="h-4 w-4" />
{t('create_credit_note')}
</Link>
</DropdownMenuItem>
)}
{showPeppolActions && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => void downloadPeppolXml()}
disabled={isDownloadingPeppol || !invoice.invoice_number}
>
<FileText className="h-4 w-4" />
{t('download_peppol_xml')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => void preparePeppolDelivery()}
disabled={isPreparingPeppol || !canWrite || !invoice.invoice_number}
>
<FileCheck2 className="h-4 w-4" />
{t('prepare_peppol_delivery')}
</DropdownMenuItem>
{peppolTransportAvailable && peppolSendGranted && peppolSendsLeft ? (
<DropdownMenuItem
onSelect={() => setShowPeppolSendDialog(true)}
disabled={isSendingPeppol || !canWrite || !canSendPeppol}
>
<Send className="h-4 w-4" />
{t('send_via_peppol')}
</DropdownMenuItem>
) : peppolTransportAvailable ? (
<DropdownMenuItem disabled className="items-start">
<Send className="mt-0.5 h-4 w-4" />
<span className="min-w-0">
<span className="block">{t('send_via_peppol')}</span>
<span className="block text-[11px] leading-snug text-muted-foreground">
{peppolSendGranted ? t('peppol_send_limit_reached') : t('peppol_access_required')}
</span>
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled className="items-start">
<Send className="mt-0.5 h-4 w-4" />
<span className="min-w-0">
<span className="block">{t('send_via_peppol')}</span>
<span className="block text-[11px] leading-snug text-muted-foreground">
{t('peppol_provider_required')}
</span>
</span>
</DropdownMenuItem>
)}
</>
)}
{showDestructive && (
<>
<DropdownMenuSeparator />
{isProforma ? (
<DropdownMenuItem
onSelect={() => void updateStatus('cancelled')}
disabled={isUpdating || !canWrite}
className="text-destructive focus:text-destructive"
>
<XCircle className="h-4 w-4" />
{t('cancel_action')}
</DropdownMenuItem>
) : (
<DropdownMenuItem
onSelect={() => setShowDeleteDialog(true)}
disabled={isDeleting || !canWrite}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" />
{isUnnumberedDraft
? t('remove_action')
: t(isCreditNote ? 'remove_credit_draft' : 'delete_draft')}
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Kund and Detaljer side by side like an invoice head: who it is for
on the left, the facts on the right. */}
<div className="grid gap-x-12 gap-y-8 lg:grid-cols-2">
<DetailSection kicker={t('customer_card_title')}>
<DefRow label={t('def_customer')}>
<Link href={`/customers/${customer.id}`} className="hover:underline">
{customer.name}
</Link>
</DefRow>
{customer.customer_type !== 'individual' && customer.org_number && (
<DefRow label={t('def_org_number')}>
<span className="tabular-nums">{customer.org_number}</span>
</DefRow>
)}
{customer.customer_type !== 'individual' && customer.vat_number && (
<DefRow label={t('def_vat_number')}>{customer.vat_number}</DefRow>
)}
<DefRow label={t('def_email')}>
{customer.email ? (
<a href={`mailto:${customer.email}`} className="hover:underline">
{customer.email}
</a>
) : (
<DefEmpty />
)}
</DefRow>
{customer.phone && <DefRow label={t('def_phone')}>{customer.phone}</DefRow>}
<DefRow label={t('def_address')}>
{customer.address_line1 || customer.city ? (
<div>
{customer.address_line1 && <p>{customer.address_line1}</p>}
{customer.address_line2 && <p>{customer.address_line2}</p>}
<p>
{[customer.postal_code, customer.city].filter(Boolean).join(' ')}
{customer.country && customer.country !== 'SE' && `, ${customer.country}`}
</p>
</div>
) : (
<DefEmpty />
)}
</DefRow>
{invoice.your_reference && (
<DefRow label={t('your_reference_label')}>
{invoice.your_reference.split(',').map((ref) => ref.trim()).join(', ')}
</DefRow>
)}
</DetailSection>
<DetailSection kicker={t('details_card_title')}>
{isSelfBilled && (
<DefRow label={t('external_number_label')}>
{invoiceDisplayNumber(invoice as Invoice)}
</DefRow>
)}
{isSelfBilled && (invoice as Invoice).self_billing_agreement_ref && (
<DefRow label={t('agreement_ref_label')}>
{(invoice as Invoice).self_billing_agreement_ref}
</DefRow>
)}
<DefRow label={t('invoice_date_label')}>
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
</DefRow>
<DefRow label={t('due_date_label')}>
<span className="tabular-nums">{formatDate(invoice.due_date)}</span>
</DefRow>
<DefRow label={t('currency_label')}>{invoice.currency}</DefRow>
<DefRow label={t('vat_treatment_label')}>{getVatTreatmentLabel(invoice.vat_treatment)}</DefRow>
{invoice.our_reference && (
<DefRow label={t('our_reference_label')}>
{invoice.our_reference.split(',').map((ref) => ref.trim()).join(', ')}
</DefRow>
)}
{/* ROT/RUT (fakturamodellen): the underlag Skatteverket needs and
where the begäran om utbetalning stands, as plain rows. The
amounts live in the totals block; nothing is repeated here. */}
{showDeduction && (
<>
<DefRow label={t('deduction_personnummer_label')}>
{deductionPersonnummerMasked ? (
<span className="tabular-nums">{deductionPersonnummerMasked}</span>
) : !invoice.deduction_personnummer_last4 ? (
<span className="text-attn">{t('deduction_personnummer_missing')}</span>
) : deductionPersonnummerMasked === undefined ? (
// Stored; the mask is still on its way from the server.
// Never print the last four digits meanwhile.
<Skeleton className="h-4 w-24" />
) : (
// Stored but the server could not read it back.
<span className="text-muted-foreground">{t('deduction_personnummer_unreadable')}</span>
)}
</DefRow>
{hasRot && (
<DefRow label={t('deduction_property_label')}>
{rotHousing ? (
<span>
{rotHousing}
{rotApartment && (
<span className="text-muted-foreground tabular-nums">
{' · '}{t('deduction_apartment_value', { number: rotApartment })}
</span>
)}
</span>
) : rotBrf ? (
<span>
{t('deduction_brf_value', { org: rotBrf })}
{rotApartment && (
<span className="text-muted-foreground tabular-nums">
{' · '}{t('deduction_apartment_value', { number: rotApartment })}
</span>
)}
</span>
) : (
<span className="text-attn">{t('deduction_housing_missing')}</span>
)}
</DefRow>
)}
<DefRow label={t('deduction_status_label')}>
{payoutRequests.length === 0 ? (
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-muted-foreground">{t('deduction_claim_none')}</span>
{skvClaimable && (
<Link href="/invoices?rot-rut=1" className={cn(ROW_ACTION_CLASS, 'whitespace-nowrap')}>
{t('deduction_claim_cta')}
</Link>
)}
</span>
) : (
<span className="flex flex-col gap-1 tabular-nums">
{payoutRequests.map((req) => (
<span key={req.id}>
{tInvoices(`rot_rut_status_${req.status}`)}
{' '}
{formatDate(req.decided_at ?? req.submitted_at ?? req.created_at)}
{req.decided_amount !== null &&
` · ${formatCurrency(req.decided_amount, invoice.currency)}`}
</span>
))}
</span>
)}
</DefRow>
</>
)}
{canBookAfterwards && (
<DefRow label={t('bookkeeping_label')}>
<span className="flex flex-wrap items-center gap-3">
<span className="text-muted-foreground">{t('not_booked_yet')}</span>
{canWrite && (
<Button size="sm" variant="outline" className="-my-1" onClick={openBookConfirm} disabled={isUpdating}>
{isUpdating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('book_action')}
</Button>
)}
</span>
</DefRow>
)}
{invoice.journal_entry_id && (
<DefRow label={t('bookkeeping_label')}>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Link href={`/bookkeeping/${invoice.journal_entry_id}`} className={ROW_ACTION_CLASS}>
{t('view_voucher')}
</Link>
{canWrite && (
<CorrectionAffordance
journalEntryId={invoice.journal_entry_id}
onCorrected={fetchInvoice}
>
{({ open, isLoading }) => (
<button
type="button"
onClick={open}
disabled={isLoading}
className="text-xs text-muted-foreground hover:text-foreground hover:underline disabled:opacity-50"
>
{isLoading ? t('correction_loading') : t('correction_prompt')}
</button>
)}
</CorrectionAffordance>
)}
</span>
</DefRow>
)}
{/* Related documents as rows, not cards: the credit note that
cancels this invoice, the invoice a credit note cancels, the
proforma this invoice was converted from. */}
{creditNote && (
<DefRow label={t('def_credit_note')}>
<Link href={`/invoices/${creditNote.id}`} className="hover:underline">
{creditNote.invoice_number ?? statusLabel('draft')}
</Link>
{creditNote.status === 'draft' && creditNote.invoice_number && (
<span className="text-muted-foreground">{' · '}{statusLabel('draft')}</span>
)}
</DefRow>
)}
{invoice.credited_invoice_id && originalInvoice && (
<DefRow label={t('def_credits')}>
<Link href={`/invoices/${originalInvoice.id}`} className="hover:underline">
{t('title_invoice', { number: originalInvoice.invoice_number ?? '' })}
</Link>
</DefRow>
)}
{convertedFromInvoice && (
<DefRow label={t('def_converted_from')}>
<Link href={`/invoices/${convertedFromInvoice.id}`} className="hover:underline">
{t('title_proforma', { number: convertedFromInvoice.invoice_number ?? '' })}
</Link>
</DefRow>
)}
</DetailSection>
</div>
{/* Invoice lines: the list-page table idiom straight on the panel, with
the totals as a right-aligned block and the amount due in the serif. */}
<DetailSection kicker={t('items_card_title')}>
<table className="hidden w-full border-collapse text-[13px] sm:table">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_quantity')}</th>
<th className={TH_CLASS}>{t('th_unit')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_unit_price')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('th_amount')}</th>
</tr>
</thead>
<tbody>
{invoice.items.map((item) =>
isTextLikeLine(item) ? (
<tr key={item.id}>
<td colSpan={5} className={cn(TD_CLASS, 'pl-0 pr-0 text-muted-foreground')}>
{item.description || ' '}
</td>
</tr>
) : (
<tr key={item.id}>
<td className={cn(TD_CLASS, 'pl-0')}>
{item.description}
{lineSubInfo(item)}
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>{item.quantity}</td>
<td className={cn(TD_CLASS, 'text-muted-foreground')}>{item.unit}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{formatCurrency(item.unit_price, invoice.currency)}
</td>
<td className={cn(TD_CLASS, 'pr-0 text-right tabular-nums')}>
{formatCurrency(item.line_total, invoice.currency)}
</td>
</tr>
)
)}
</tbody>
</table>
{/* Mobile: one flat row per line, no numeric columns to cram. */}
<div className="divide-y divide-border text-sm sm:hidden">
{invoice.items.map((item) =>
isTextLikeLine(item) ? (
<p key={item.id} className="py-3 text-muted-foreground">{item.description || ' '}</p>
) : (
<div key={item.id} className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0">
<p>{item.description}</p>
<p className="text-xs text-muted-foreground tabular-nums">
{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}
</p>
{lineSubInfo(item)}
</div>
<span className="shrink-0 tabular-nums">
{formatCurrency(item.line_total, invoice.currency)}
</span>
</div>
)
)}
</div>
<div className="ml-auto mt-4 w-full max-w-xs space-y-1 text-sm tabular-nums">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('subtotal')}</span>
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
{vatRows.map((row) => (
<div key={row.key} className="flex justify-between gap-4">
<span className="text-muted-foreground">{row.label}</span>
<span>{formatCurrency(row.amount, invoice.currency)}</span>
</div>
))}
{rounding.applies && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('ore_rounding')}</span>
<span>{formatCurrency(rounding.roundingDelta, 'SEK')}</span>
</div>
)}
{/* ROT/RUT (fakturamodellen): the invoice total stands, the
deduction is shown as a reduction and the headline becomes what
the customer actually pays, exactly as on the PDF and in the
invoice email. */}
{showDeduction ? (
<>
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('total')}</span>
<span>{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_row', { kind: deductionKindLabel })}</span>
<span>{formatCurrency(-Math.abs(invoice.deduction_total ?? 0), invoice.currency)}</span>
</div>
<div className="flex items-baseline justify-between gap-4 border-t border-border pt-2">
<span>{t('amount_to_pay')}</span>
<span className="font-display text-xl">{formatCurrency(amountToPay.toPay, invoice.currency)}</span>
</div>
</>
) : (
<div className="flex items-baseline justify-between gap-4 border-t border-border pt-2">
<span>{t('total')}</span>
<span className="font-display text-xl">{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
)}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between gap-4 text-muted-foreground">
<span>{t('in_sek', { rate: invoice.exchange_rate ?? 1 })}</span>
<span>{formatCurrency(invoice.total_sek)}</span>
</div>
)}
</div>
</DetailSection>
{(invoice.notes || invoice.reverse_charge_text) && (
<DetailSection kicker={t('notes_card_title')}>
{invoice.reverse_charge_text && (
<DefRow label={t('reverse_charge_label')}>
<span className="text-muted-foreground">{invoice.reverse_charge_text}</span>
</DefRow>
)}
{invoice.notes && (
<p className="py-2 text-sm text-muted-foreground whitespace-pre-wrap">{invoice.notes}</p>
)}
</DetailSection>
)}
{/* Payment: shown for paid and partially paid alike, so a partly paid
invoice always exposes paid / remaining and the payment events. */}
{(invoice.status === 'paid' || invoice.status === 'partially_paid') && (
<DetailSection
kicker={t('payment_section')}
aside={
invoice.status === 'paid' && invoice.paid_at ? (
<span className="text-[11px] tabular-nums text-muted-foreground">
{t('paid_received_at', { date: formatDate(invoice.paid_at) })}
</span>
) : undefined
}
>
<DefRow label={t('payment_status_paid_label')}>
<span className="tabular-nums">{formatCurrency(invoice.paid_amount ?? 0, invoice.currency)}</span>
</DefRow>
<DefRow label={t('payment_status_remaining_label')}>
<span className={cn('tabular-nums', invoice.status === 'partially_paid' && 'text-attn')}>
{formatCurrency(
invoice.remaining_amount ??
Math.max(0, invoice.total - (invoice.paid_amount ?? 0)),
invoice.currency,
)}
</span>
</DefRow>
<DefRow label={t('payment_status_payments_heading')} className="items-baseline">
{payments.length === 0 ? (
<span className="text-muted-foreground">{t('payment_status_empty')}</span>
) : (
<ul className="divide-y divide-border">
{payments.map((p) => {
const voucherLabel =
p.voucher_series && p.voucher_number != null
? `${p.voucher_series}-${p.voucher_number}`
: null
return (
<li key={p.id} className="flex items-center gap-4 py-1.5 first:pt-0 last:pb-0">
<span className="tabular-nums text-muted-foreground">{formatDate(p.payment_date)}</span>
<span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>
{p.journal_entry_id && voucherLabel ? (
<Link
href={`/bookkeeping/${p.journal_entry_id}`}
className="ml-auto text-xs text-muted-foreground hover:text-foreground hover:underline tabular-nums"
>
{t('payment_status_view_voucher', { label: voucherLabel })}
</Link>
) : (
<span className="ml-auto text-xs text-muted-foreground">
{t('payment_status_view_voucher_unlinked')}
</span>
)}
</li>
)
})}
</ul>
)}
</DefRow>
{/* Betalningsbekräftelse (#1693): a fresh BETALD render the customer
can be handed. It belongs to the payment, not the invoice, and
is a different document from the archived original. */}
{canSendPaymentConfirmation && (
<DefRow label={t('payment_confirmation_label')}>
<span className="flex flex-wrap items-center gap-x-4 gap-y-1">
<button
type="button"
onClick={downloadPaymentConfirmation}
disabled={isDownloadingConfirmation}
title={t('payment_confirmation_hint')}
className={cn(ROW_ACTION_CLASS, 'inline-flex items-center gap-1')}
>
{isDownloadingConfirmation && <Loader2 className="h-3 w-3 animate-spin" />}
{t('payment_confirmation_download_short')}
</button>
<button
type="button"
onClick={() => setShowConfirmationSendDialog(true)}
disabled={!canWrite || !customerHasEmail || !canEmail}
title={
!canWrite
? t('viewer_disabled_tooltip')
: !customerHasEmail
? t('payment_confirmation_no_email')
: t('payment_confirmation_hint')
}
className={ROW_ACTION_CLASS}
>
{t('payment_confirmation_send_short')}
</button>
</span>
</DefRow>
)}
</DetailSection>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue' || reminders.length > 0) && (
<DetailSection
kicker={t('reminders_card_title')}
aside={
autoRemindersEnabled !== null ? (
<span className="text-[11px] tabular-nums text-muted-foreground">
{autoRemindersEnabled
? t('reminders_schedule_aside', {
day1: reminderDays[0],
day2: reminderDays[1],
day3: reminderDays[2],
})
: t('reminders_disabled_aside')}
</span>
) : undefined
}
>
{reminders.length > 0 ? (
<ul className="divide-y divide-border text-sm">
{reminders.map((reminder) => (
<li key={reminder.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
<span className="tabular-nums text-muted-foreground">{formatDate(reminder.sent_at)}</span>
<span>{reminderLevelLabel(reminder.reminder_level as 1 | 2 | 3)}</span>
<span className="min-w-0 truncate text-muted-foreground">{reminder.email_to}</span>
{/* Chips mark exceptions: an objection is the deviation;
"kunden markerat som betald" reads as muted text. */}
{reminder.response_type === 'disputed' ? (
<Badge variant="destructive" className="ml-auto">{t('reminder_objection')}</Badge>
) : reminder.response_type === 'marked_paid' ? (
<span className="ml-auto text-xs text-muted-foreground">{t('reminder_marked_paid')}</span>
) : null}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">{t('reminders_empty')}</p>
)}
</DetailSection>
)}
{/* The legacy empty state asserts "sent before delivery history
existed". A failed read produces the same empty list, so that
claim would be a guess: say what actually happened instead. */}
{latestPeppolDelivery && (
<DetailSection kicker={t('peppol_status_title')}>
<DefRow label={t('peppol_status_recipient')}>
<span className="tabular-nums">
{latestPeppolDelivery.recipient_scheme}:{latestPeppolDelivery.recipient_identifier}
</span>
</DefRow>
<DefRow label={t('peppol_status_label')}>
<span>{peppolStatusLabel(latestPeppolDelivery.status)}</span>
{latestPeppolDelivery.status_detail && (
<span className="block text-xs text-muted-foreground">
{latestPeppolDelivery.status_detail}
</span>
)}
</DefRow>
<DefRow label={t('peppol_status_updated')}>
<span className="tabular-nums">{formatDate(latestPeppolDelivery.status_at)}</span>
</DefRow>
</DetailSection>
)}
{isRealInvoice && !isSelfBilled && deliveriesUnreadable && (
<DetailSection kicker={t('delivery_history_title')}>
<p className="text-sm text-muted-foreground">
{t('delivery_history_unreadable_description')}{' '}
<button
type="button"
onClick={() => void retryLoadDeliveries()}
className={cn(ROW_ACTION_CLASS, 'text-foreground')}
>
{t('delivery_history_unreadable_retry')}
</button>
</p>
</DetailSection>
)}
{isRealInvoice && !isSelfBilled && !deliveriesUnreadable && (
<InvoiceDeliveryHistory
deliveries={deliveries}
showLegacyEmptyState={[
'sent',
'paid',
'partially_paid',
'overdue',
'credited',
].includes(invoice.status)}
/>
)}
{/* Remove/cancel confirmation. An unissued credit-note draft and an
unnumbered invoice draft are hard deleted; other numbered drafts are
retained as cancelled to preserve their number series. */}
{/* Peppol send confirmation (convention 10: confirm up front). */}
<Dialog open={showPeppolSendDialog} onOpenChange={setShowPeppolSendDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('peppol_send_confirm_title')}</DialogTitle>
<DialogDescription>
{t('peppol_send_confirm_description', { recipient: peppolRecipientLabel })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowPeppolSendDialog(false)}
disabled={isSendingPeppol}
>
{t('delete_dialog_cancel')}
</Button>
<Button onClick={() => void sendViaPeppol()} disabled={isSendingPeppol}>
{isSendingPeppol && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSendingPeppol ? t('peppol_sending') : t('peppol_send_confirm_action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isCreditNote
? t('remove_credit_dialog_title')
: invoice.invoice_number
? t('delete_dialog_title')
: t('remove_dialog_title')}
</DialogTitle>
<DialogDescription>
{isCreditNote ? (
t('remove_credit_dialog_desc')
) : invoice.invoice_number ? (
<>
{t('delete_dialog_desc_with_number_1')}
<strong>{t('delete_dialog_status_makulerad')}</strong>
{t('delete_dialog_desc_with_number_2')}
{/* data-ph-mask: interpolates the invoice number */}
<span data-ph-mask="" className="mt-2 block text-muted-foreground">
{t('delete_dialog_number_kept', { number: invoice.invoice_number })}
</span>
</>
) : (
<>
{t('remove_dialog_desc')}
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)} disabled={isDeleting}>
{t('delete_dialog_cancel')}
</Button>
<Button variant="destructive" onClick={deleteInvoice} disabled={isDeleting}>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isCreditNote
? t('remove_credit_dialog_confirm')
: invoice.invoice_number
? t('delete_dialog_confirm')
: t('remove_dialog_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Finalize confirmation, "Granska & skapa". Allocates the F-number and
turns the unnumbered draft into a real, issued invoice. */}
<Dialog open={showFinalizeDialog} onOpenChange={setShowFinalizeDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('finalize_dialog_title')}</DialogTitle>
<DialogDescription>{t('finalize_dialog_desc')}</DialogDescription>
</DialogHeader>
{nextNumberPreview && (
<div className="flex items-center justify-between rounded-lg border border-border bg-secondary/40 px-4 py-3">
<span className="text-sm text-muted-foreground">{t('finalize_dialog_number_label')}</span>
<span className="text-base font-medium tabular-nums">{nextNumberPreview}</span>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowFinalizeDialog(false)} disabled={isFinalizing}>
{t('finalize_dialog_cancel')}
</Button>
<Button onClick={finalizeInvoice} disabled={isFinalizing}>
{isFinalizing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('finalize_dialog_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* The archived PDF the customer received could not be produced. Nothing
has been downloaded at this point: a re-render is a different
document, so the user chooses it deliberately or not at all. */}
<Dialog
open={pdfArchiveIssue !== null}
onOpenChange={(open) => {
if (!open) setPdfArchiveIssue(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('pdf_archive_issue_title')}</DialogTitle>
<DialogDescription>
{pdfArchiveIssue === 'document'
? t('pdf_archive_issue_document_desc')
: t('pdf_archive_issue_history_desc', { appName })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setPdfArchiveIssue(null)}
disabled={isDownloading}
>
{t('pdf_archive_issue_cancel')}
</Button>
<Button
variant="secondary"
onClick={downloadRerenderAnyway}
disabled={isDownloading}
>
{pdfIntent === 'preview'
? t('pdf_archive_issue_rerender_preview')
: t('pdf_archive_issue_rerender')}
</Button>
<Button onClick={retryArchivedDownload} disabled={isDownloading}>
{isDownloading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('pdf_archive_issue_retry')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PaymentBookingDialog
open={showPaymentDialog}
onOpenChange={setShowPaymentDialog}
invoice={invoice}
onSuccess={() => {
fetchInvoice()
toast({
title: t('paid_toast_title'),
description: t('paid_toast_description', { number: invoice.invoice_number ?? '' }),
})
}}
/>
{invoice && (
<SendInvoiceDialog
open={showSendDialog}
onOpenChange={setShowSendDialog}
invoice={invoice}
mode={sendDialogMode}
onSuccess={() => fetchInvoice()}
/>
)}
{/* Confirm-before-posting (convention 10): booking writes an immutable
verifikat, so the outcome is described before the POST, not narrated
in a toast afterwards. */}
<ConfirmDialog
open={showConfirmationSendDialog}
onOpenChange={setShowConfirmationSendDialog}
title={t('payment_confirmation_confirm_title')}
description={t('payment_confirmation_confirm_description', {
number: invoiceDisplayNumber(invoice as Invoice),
email: invoice.customer?.email ?? '',
})}
confirmLabel={t('payment_confirmation_confirm_action')}
onConfirm={sendPaymentConfirmation}
/>
<ConfirmDialog
open={showBookConfirm}
onOpenChange={setShowBookConfirm}
title={t('confirm_book_title')}
description={
bookVoucherPreview
? t('confirm_book_description', {
voucher: bookVoucherPreview,
number: invoiceDisplayNumber(invoice as Invoice),
amount: formatCurrency(
getDisplayTotal(invoice, { ore_rounding: oreRounding }).displayed,
invoice.currency,
),
})
: t('confirm_book_description_generic', {
number: invoiceDisplayNumber(invoice as Invoice),
})
}
confirmLabel={t('book_action')}
onConfirm={handleBook}
/>
</div>
)
}