Files
accounted/components/invoices/InvoiceEditor.tsx
T
Jakob Wennberg 40e773548c perf(invoices): the invoice editor renders on the first paint from the session cache (#1937)
"Ny faktura" was the slowest form to fill in: the list page lazy-loaded
NewInvoiceDialog, which lazy-loaded InvoiceEditor (ssr:false), which then
issued four requests on mount (customers, articles, chart of accounts,
company settings) and hid the ENTIRE form behind a spinner until the
customers query alone resolved, even though the other three had landed.
Reopening the dialog paid all of it again.

- InvoiceEditor reads customers, articles, posting accounts and settings
  from lib/reference-data (seeded by the dashboard layout). The whole-form
  spinner gate is gone; the customer picker shows "Hämtar kunder ..." only
  while the list is genuinely uncached. Company settings are applied once
  per editor instance through a guarded effect, so a background
  revalidation can never re-run the create-mode prefills over notes or a
  reference the user has typed. Inline customer/article creation
  invalidates the shared cache (awaited, so the new option resolves before
  the line points at it). Customers now come through /api/customers, which
  masks the personnummer column; nothing in the editor rendered it.
- NewInvoiceDialog imports the editor statically: the dialog is itself a
  next/dynamic chunk on the list page, so this is one deferred chunk
  download when the dialog opens instead of two sequential ones.
- New strings: invoice_editor.loading_customers (sv + en).

Per "Ny faktura": 2 sequential chunk loads -> 1; blocking mount requests
4 -> 0 (cached) with every field populated on the first render.
raw-reference-fetch ratchet: 46 -> 45 files. SendInvoiceDialog and
PaymentBookingDialog (init() flows) stay in the baseline for a later PR.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:43:09 +02:00

3290 lines
153 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useMemo, useId } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { useForm, useFieldArray, Controller, type FieldErrors } from 'react-hook-form'
import { Reorder } from 'framer-motion'
import { SortableRow } from '@/components/ui/sortable-row'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { addDays, format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { TagInput } from '@/components/ui/tag-input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency } from '@/lib/utils'
import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { getVatRules } from '@/lib/invoices/vat-rules'
import {
resolveLineVatRates,
planCustomerSwitchVatSnap,
hasSwedishVatToForeignBusiness,
FALLBACK_VAT_RATE,
} from '@/components/invoices/line-vat-rates'
import {
deriveNextStep,
deriveForvalChips,
deriveRequiresHousing,
filterArticleSuggestions,
type NextStep,
} from '@/components/invoices/invoice-editor-flow'
import { sortArticles } from '@/lib/articles/sort'
import ArticleCombobox from '@/components/invoices/ArticleCombobox'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, X, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Package, Copy } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { openDeferredTab } from '@/lib/browser/deferred-tab'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import CustomerForm from '@/components/customers/CustomerForm'
import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog'
import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { useAccounts, useArticles, useCompanySettings, useCustomers } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import {
ROT_WORK_TYPES,
RUT_WORK_TYPES,
computeDeduction,
deductionCapWarnings,
deductionTypeForWorkType,
parseArticleHouseworkType,
SCHABLON_WORK_TYPES,
type PriorYearDeductions,
} from '@/lib/invoices/rot-rut-rules'
import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK } from '@/lib/customers/mask-personal-number'
import { roundOre } from '@/lib/money'
import { isFiscalYear } from '@/lib/invariants'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import type { InvoiceCopyInitial } from '@/lib/invoices/copy-invoice'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import {
buildInvoiceWritePayload,
buildSelfBilledPayload,
hasDimensionValues,
} from '@/lib/invoices/editor-payload'
import type { Customer, Currency, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem } from '@/types'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
// A draft invoice + its line items, as fetched for the edit flow.
export type InvoiceForEdit = Invoice & { items: InvoiceItem[] }
// `create` is the original "new invoice" flow (unchanged). `edit` pre-fills the
// form from an existing DRAFT and saves via PATCH instead of POST: no review
// dialog, no number allocation, no self-billed tab, no send/logo prompts.
// `bare` renders the editor without page chrome (back button, full-size
// heading, fixed mobile action bar) so it drops into NewInvoiceDialog: the
// same convention as JournalEntryForm's `bare`.
export type InvoiceEditorProps = (
| { mode?: 'create' }
| { mode: 'edit'; initial: InvoiceForEdit }
| { mode: 'copy'; initial: InvoiceCopyInitial }
) & {
bare?: boolean
/** Open with the självfaktura tab preselected (the "Självfaktura" entry in
* the invoice list's split button). Create mode only. */
initialSelfBilled?: boolean
}
// Subset of Article fields the line picker needs to pre-fill a row.
type ArticleOption = Pick<
Article,
'id' | 'article_number' | 'name' | 'unit' | 'price_excl_vat' | 'vat_rate' | 'revenue_account' | 'currency' | 'housework_type'
>
function RequiredMark() {
return <span className="text-destructive ml-0.5" aria-hidden="true">*</span>
}
/** Uppercase hairline section label (the prototype's .seclabel). */
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="mb-3 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
{children}
</div>
)
}
// Borderless in-table cell input: quiet at rest, beige on hover, ringed on
// focus. rounded-sm: nested leaf inside the rows surface (radius ladder).
const CELL_INPUT_CLASS =
'rounded-sm border border-transparent bg-transparent px-2 py-1 text-[13px] transition-colors duration-150 hover:bg-secondary/40 focus-visible:bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/60'
// Row-control icon button: 24px visual hit area in dense rows (per the row
// chrome decision), inflated to a 40px touch target on coarse pointers.
const ROW_ICON_BUTTON_CLASS =
'flex min-h-6 min-w-6 items-center justify-center rounded-sm p-1 text-muted-foreground transition-colors duration-150 hover:bg-secondary/60 hover:text-foreground pointer-coarse:min-h-10 pointer-coarse:min-w-10'
// Compact borderless Select trigger for in-table cells (unit, VAT).
const CELL_SELECT_TRIGGER_CLASS =
'h-7 w-auto gap-1 rounded-sm border-transparent bg-transparent px-2 py-1 text-[13px] shadow-none hover:bg-secondary/40 tabular-nums'
// Förval settings row: flat hairline rows, label left, control right.
const SETTINGS_ROW_CLASS =
'flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]'
// Compact display of a dimensions bag, e.g. "KS01 · P001" (dim-number order).
function compactDims(dims: Record<string, string>): string {
return Object.entries(dims)
.filter(([, v]) => v)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v)
.join(' · ')
}
export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'create' }) {
// Edit mode pre-fills the form from an existing draft and saves via PATCH.
const isEditMode = props.mode === 'edit'
const isCopyMode = props.mode === 'copy'
const initial = props.mode === 'edit' ? props.initial : null
const copyInitial = props.mode === 'copy' ? props.initial : null
const initialOreRounding = initial?.ore_rounding ?? copyInitial?.ore_rounding
const bare = props.bare === true
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const { company } = useCompany()
const hasEmailSend = useCapability(CAPABILITY.email_send)
const supabase = createClient()
const t = useTranslations('invoice_editor')
const ts = useTranslations('self_billing')
const ta = useTranslations('accruals')
const tCommon = useTranslations('common')
// Normal customer invoice (default) or a received self-billing invoice
// (mottagen självfaktura, ML 17 kap 15§). The mode is chosen upstream in
// the Ny faktura split button (?self=1) and is fixed for the editor's
// lifetime: the two flows are separate views, not tabs (founder call
// 2026-08-17). Self-billing is never available when editing a draft.
const mode: 'invoice' | 'self_billed' =
props.initialSelfBilled && !isEditMode ? 'self_billed' : 'invoice'
// Company-wide opt-in from the invoice settings page: the whole payment
// link section (manual field + Stripe auto toggle) stays hidden until the
// company enables it. The send routes enforce the same setting server-side
// (maybeCreatePaymentLinkForInvoice), so this is presentation, not the gate.
const [paymentLinksEnabled, setPaymentLinksEnabled] = useState(false)
// An already-linked invoice keeps showing the section even when the
// setting is off, so the user can still see or clear the old link.
const hasExistingPaymentLink = Boolean(initial?.payment_link_url)
// Standalone pages get a page-level sticky action bar at the viewport
// bottom; body[data-page-bottom-bar] tells the assistant FAB (AgentTrigger)
// to lift above it so it never covers the bar's primary button. Dialog mode
// needs nothing: the veil already sits over the FAB.
useEffect(() => {
if (bare) return
document.body.setAttribute('data-page-bottom-bar', '1')
return () => {
document.body.removeAttribute('data-page-bottom-bar')
}
}, [bare])
// Active Stripe connection: drives the "auto payment link" toggle in the
// payment link section. Absent extension or no connection → toggle hidden.
const [stripeConnected, setStripeConnected] = useState(false)
useEffect(() => {
if (!paymentLinksEnabled) return
if (!ENABLED_EXTENSION_IDS.has('stripe')) return
let cancelled = false
fetch('/api/extensions/ext/stripe/status')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled && data?.connection?.status === 'active') setStripeConnected(true)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [paymentLinksEnabled])
// Edit mode: the draft's stored ROT/RUT personnummer, in display form
// (YYYYMMDD-XXXX). The row only carries ciphertext + last four digits, and
// the mask must come from the server so the browser never holds both
// halves of the number. Read once per draft; the hint shows it.
const storedPersonnummerId = initial?.deduction_personnummer_last4 ? initial.id : null
const [storedPersonnummerMasked, setStoredPersonnummerMasked] = useState<string | null>(null)
useEffect(() => {
if (!storedPersonnummerId) return
let cancelled = false
fetch(`/api/invoices/${encodeURIComponent(storedPersonnummerId)}/rot-rut`)
.then((res) => (res.ok ? res.json() : null))
.then((payload: { data?: { deduction_personnummer_masked?: string | null } } | null) => {
if (!cancelled) setStoredPersonnummerMasked(payload?.data?.deduction_personnummer_masked ?? null)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [storedPersonnummerId])
// The item schema is memoised on translations only; whether ROT/RUT claim
// completeness applies depends on the document type (proformas, delivery
// notes and self-billing have no deduction model and the strip never
// renders), so read that through a ref at validation time.
const rotRutCompletenessAppliesRef = useRef(false)
const schema = useMemo(() => {
const itemSchema = z.object({
// 'text' rows carry only a (possibly empty) description: a free-text or
// blank spacer line. Product rows keep the original requirements,
// enforced in the refine below so the base shape stays uniform.
line_type: z.enum(['product', 'text']).optional(),
description: z.string(),
quantity: z.number(),
unit: z.string(),
unit_price: z.number(),
vat_rate: z.number().min(0).max(25),
// Article linkage (artikelregister). Optional: free-text lines omit them.
article_id: z.string().nullable().optional(),
revenue_account: z
.string()
.regex(INVOICE_POSTING_ACCOUNT_REGEX, t('posting_account_invalid'))
.nullable()
.optional(),
// ROT/RUT-avdrag per line. Optional: null means "no deduction".
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
labor_hours: z.number().nonnegative().nullable().optional(),
work_type: z.string().nullable().optional(),
housing_designation: z.string().nullable().optional(),
apartment_number: z.string().nullable().optional(),
brf_org_number: z.string().nullable().optional(),
// Periodisering (förutbetald intäkt). Active when balance account is
// non-null; both period dates are then required (refine below).
accrual_period_start: z.string().nullable().optional(),
accrual_period_end: z.string().nullable().optional(),
accrual_balance_account: z.string().nullable().optional(),
// Per-item dimensions bag ({sie_dim_no: code}, dimensions PR7). Stored
// as-is; the server merges it over the invoice's default_dimensions on
// the item's revenue line at booking time.
dimensions: z.record(z.string(), z.string()).nullable().optional(),
}).superRefine((item, ctx) => {
if (item.accrual_balance_account != null) {
const start = item.accrual_period_start
const end = item.accrual_period_end
let invalid = !start || !end || end < start
if (!invalid) {
try {
invalid = countCalendarMonths(start as string, end as string) < 2
} catch {
invalid = true
}
}
if (invalid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_end'],
message: ta('validation_period'),
})
}
}
// ROT/RUT claim completeness, mirrored from CreateInvoiceItemSchema:
// arbetstyp + arbetstimmar are what the Skatteverket claim needs, and
// creation is the last moment the line is editable.
if (item.deduction_type && rotRutCompletenessAppliesRef.current && item.line_type !== 'text') {
const workType = item.work_type?.trim() || null
if (!workType) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['work_type'], message: t('deduction_work_type_required') })
} else if (deductionTypeForWorkType(workType) !== item.deduction_type) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['work_type'], message: t('deduction_work_type_mismatch') })
}
const isSchablon = workType != null && SCHABLON_WORK_TYPES.includes(workType)
if (!isSchablon && !(typeof item.labor_hours === 'number' && item.labor_hours > 0)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['labor_hours'], message: t('deduction_hours_required') })
}
}
if (item.line_type === 'text') return
if (item.description.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['description'], message: t('validation_description_required') })
}
if (!(item.quantity >= 0.01)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['quantity'], message: t('validation_quantity_min') })
}
if (item.unit.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['unit'], message: t('validation_unit_required') })
}
// Negative unit prices are allowed: discount lines (e.g. "Rabatt -100")
// are a valid way to reduce an invoice total. The backend schema accepts
// them too (see lib/api/schemas.ts CreateInvoiceItemSchema). An empty
// price field is still rejected by the base `unit_price: z.number()` type
// (NaN), so we only need to allow the sign here.
})
return z.object({
customer_id: z.string().min(1, t('validation_customer_required')),
invoice_date: z.string().min(1, t('validation_invoice_date_required')),
due_date: z.string().min(1, t('validation_due_date_required')),
delivery_date: z.string().optional(),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
// Optional online payment link (pasted from e.g. the Stripe dashboard).
// https-only: mirrors the server-side CreateInvoiceSchema gate.
payment_link_url: z
.string()
.optional()
.refine(
(v) => {
if (!v || !v.trim()) return true
try {
return new URL(v).protocol === 'https:'
} catch {
return false
}
},
{ message: t('validation_payment_link_https') },
),
// Opt-out for the automatic Stripe payment link on send (only rendered
// when the company has an active Stripe connection).
payment_link_auto: z.boolean().optional(),
// Self-billing received (mottagen självfaktura). Present in the form for
// both modes; required only in self_billed mode: enforced in onSubmit.
external_invoice_number: z.string().optional(),
self_billing_agreement_ref: z.string().optional(),
received_date: z.string().optional(),
// Invoice-level ROT/RUT claim info. Personnummer is plaintext on
// the wire; the API encrypts it before storage. The API additionally
// accepts the bostadsrätt pair (deduction_apartment_number +
// deduction_brf_org_number): no editor UI for it yet, rot i
// bostadsrätt data enters via API/MCP until the payout-file UI ships.
deduction_personnummer: z.string().optional(),
deduction_housing_designation: z.string().optional(),
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
})
}, [t, ta])
type FormData = z.infer<typeof schema>
// Reference data from the session cache (lib/reference-data): customers,
// articles, posting accounts and company settings are read from SWR
// instead of four fetches per mount, so a cached editor renders every
// field populated on the first paint and reopening the dialog costs no
// requests. Customers come through /api/customers, which masks the
// personnummer column; nothing here rendered it.
const { customers: cachedCustomers, isLoading: customersLoading, error: customersError } = useCustomers()
// Archived customers (v1 API soft-delete) are not offered in the picker
// (/api/customers filters archived_at IS NULL). An existing draft or copied
// invoice may still point at one (archiving only refuses when open invoices
// exist, drafts do not count), so that single row is fetched on its own and
// kept in the list, or the select would render blank.
const keepCustomerId = initial?.customer_id ?? copyInitial?.customer_id ?? null
const [keptCustomer, setKeptCustomer] = useState<Customer | null>(null)
useEffect(() => {
if (!keepCustomerId || customersLoading) return
if (cachedCustomers.some((c) => c.id === keepCustomerId)) return
let cancelled = false
fetch(`/api/customers/${encodeURIComponent(keepCustomerId)}`)
.then((res) => (res.ok ? res.json() : null))
.then((json: { data?: Customer } | null) => {
if (!cancelled && json?.data) setKeptCustomer(json.data)
})
.catch(() => {
// The picker simply shows no selection for a customer that no longer resolves.
})
return () => {
cancelled = true
}
}, [keepCustomerId, customersLoading, cachedCustomers])
const customers = useMemo(() => {
if (!keptCustomer || cachedCustomers.some((c) => c.id === keptCustomer.id)) return cachedCustomers
return [...cachedCustomers, keptCustomer].sort((a, b) => a.name.localeCompare(b.name))
}, [cachedCustomers, keptCustomer])
const { settings: companySettings } = useCompanySettings()
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSavingDraft, setIsSavingDraft] = useState(false)
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
const [createdInvoiceId, setCreatedInvoiceId] = useState<string | null>(null)
const [showSendPrompt, setShowSendPrompt] = useState(false)
const [isSending, setIsSending] = useState(false)
const [isPreviewing, setIsPreviewing] = useState(false)
const [, setDefaultNotes] = useState<string | null>(null)
const [isCreateCustomerOpen, setIsCreateCustomerOpen] = useState(false)
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
const [hasBankDetails, setHasBankDetails] = useState<boolean | null>(null)
const [showBankSetup, setShowBankSetup] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
// Öresavrundning is display-only. In edit mode the draft's stored flag wins;
// otherwise it defaults to the company-wide setting (loaded below).
const [oreRounding, setOreRounding] = useState<boolean>(
typeof initialOreRounding === 'boolean' ? initialOreRounding : true,
)
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
const [numberPreview, setNumberPreview] = useState<string | null>(null)
const [logoUrl, setLogoUrl] = useState<string | null>(null)
// Artikelregister: active articles for the line picker + which line is mid quick-create.
const { articles: articleRows } = useArticles()
// Numeric-aware order by article number ('2' before '10', unnumbered last):
// the picker should follow the user's own numbering, not the alphabet.
const articles = useMemo(() => sortArticles(articleRows as ArticleOption[]), [articleRows])
const [savingArticleIndex, setSavingArticleIndex] = useState<number | null>(null)
// Active balance-sheet and revenue accounts for the optional per-line
// posting override, plus which rows currently show that picker.
const { accounts: activeAccounts } = useAccounts()
const postingAccounts = useMemo(
() => activeAccounts.filter((account) => account.account_class >= 1 && account.account_class <= 3),
[activeAccounts],
)
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
// Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
// render only when company_settings.dimensions_enabled: a UI-visibility
// gate; a draft that already carries bags still round-trips untouched when
// the toggle is off. defaultDims is the invoice-level default; per-item
// overrides live on the form items and open via the row ⋮ menu (same
// open/close bookkeeping as accountOverrideRows).
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [defaultDims, setDefaultDims] = useState<Record<string, string>>(
initial?.default_dimensions ?? copyInitial?.default_dimensions ?? {},
)
const [dimensionOverrideRows, setDimensionOverrideRows] = useState<Set<number>>(new Set())
// Snabbflöde shell state: the collapsible Förval panel, the per-row article
// re-link strip (opened from the row ⋮ menu; same index-Set bookkeeping as
// accountOverrideRows), the unified entry row's autocomplete, and the brief
// settle wash on a freshly committed row.
const [settingsOpen, setSettingsOpen] = useState(false)
const [articlePickerRows, setArticlePickerRows] = useState<Set<number>>(new Set())
const [entryQuery, setEntryQuery] = useState('')
const [entryOpen, setEntryOpen] = useState(false)
const [entryActiveIdx, setEntryActiveIdx] = useState(-1)
const [settleIndex, setSettleIndex] = useState<number | null>(null)
const entryInputRef = useRef<HTMLInputElement>(null)
const customerTriggerRef = useRef<HTMLButtonElement>(null)
const entryListId = useId()
const settingsPanelId = useId()
// True only when the user had zero invoices when this page loaded. The
// post-create flow uses this to offer a one-shot "upload a logo?" prompt,
// issue #520. Self-limits: once count > 0 it stays false.
const [hadZeroInvoices, setHadZeroInvoices] = useState<boolean | null>(null)
const [showLogoPrompt, setShowLogoPrompt] = useState(false)
const pendingCustomerRef = useRef<Customer | null>(null)
// In edit mode the first time we resolve the pre-filled customer we must NOT
// re-derive due_date / forced VAT rates from it: those came from the saved
// draft. Starts true for create (always derive), false for edit (skip once).
const didInitialCustomerSync = useRef(!isEditMode)
// The DEFAULT VAT rate of the customer currently selected. A customer switch
// compares against it to tell an inherited line rate (follows the new
// customer) from a deliberate one (left alone). Starts at the rate an empty
// form's first line carries, before any customer is picked.
const previousDefaultRateRef = useRef<number>(FALLBACK_VAT_RATE)
// Edit and copy pre-fill the lines from an existing invoice, and the customer
// that resolves first IS that invoice's customer: its rates are already
// correct, so the first resolution must only RECORD the baseline, never snap.
// A fresh form has no such baseline, so there the first pick does snap.
const didSeedVatSnapBaseline = useRef(!(isEditMode || isCopyMode))
// Edit mode: the claim card's property fields are restored from the first
// rot line (they're stamped onto every rot line server-side at save time).
const initialRotLine = initial?.items?.find((i) => i.deduction_type === 'rot') ?? null
const {
register,
control,
handleSubmit,
watch,
setValue,
setError,
setFocus,
getValues,
formState: { errors, isDirty, dirtyFields, isSubmitting: isFormSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
// Edit mode pre-fills from the existing draft (header + every line incl.
// line_type, article link, ROT/RUT and periodisering). The personnummer
// can't be restored (stored encrypted): the user re-enters it if the
// draft carries a ROT/RUT claim. Create mode keeps the original empty form.
defaultValues: initial
? {
customer_id: initial.customer_id,
invoice_date: initial.invoice_date,
due_date: initial.due_date,
delivery_date: initial.delivery_date ?? '',
currency: initial.currency,
document_type: (initial.document_type ?? 'invoice') as InvoiceDocumentType,
your_reference: initial.your_reference ?? '',
our_reference: initial.our_reference ?? '',
notes: initial.notes ?? '',
payment_link_url: initial.payment_link_url ?? '',
payment_link_auto: initial.payment_link_auto ?? true,
external_invoice_number: '',
self_billing_agreement_ref: '',
received_date: '',
deduction_personnummer: '',
deduction_housing_designation: initialRotLine?.housing_designation ?? '',
items: (initial.items ?? []).map((item) => ({
line_type: (item.line_type ?? 'product') as 'product' | 'text',
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
vat_rate: item.vat_rate ?? 25,
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
deduction_type: item.deduction_type ?? null,
labor_hours: item.labor_hours ?? null,
work_type: item.work_type ?? null,
housing_designation: item.housing_designation ?? null,
apartment_number: item.apartment_number ?? null,
brf_org_number: item.brf_org_number ?? null,
accrual_period_start: item.accrual_period_start ?? null,
accrual_period_end: item.accrual_period_end ?? null,
accrual_balance_account: item.accrual_balance_account ?? null,
dimensions: hasDimensionValues(item.dimensions) ? item.dimensions ?? null : null,
})),
}
: copyInitial
? {
customer_id: copyInitial.customer_id,
invoice_date: '',
due_date: '',
delivery_date: '',
currency: copyInitial.currency,
document_type: 'invoice' as InvoiceDocumentType,
your_reference: '',
our_reference: copyInitial.our_reference,
notes: copyInitial.notes,
payment_link_url: '',
payment_link_auto: true,
external_invoice_number: '',
self_billing_agreement_ref: '',
received_date: '',
deduction_personnummer: '',
deduction_housing_designation: '',
items: copyInitial.items,
}
: {
customer_id: '',
invoice_date: '',
due_date: '',
currency: 'SEK',
document_type: 'invoice' as InvoiceDocumentType,
payment_link_url: '',
payment_link_auto: true,
external_invoice_number: '',
self_billing_agreement_ref: '',
received_date: '',
// The unified entry row (tfoot input) is how lines are born: a fresh
// form starts with zero committed rows and the schema's min(1) plus
// the next-step line ask for the first one.
items: [],
},
})
useUnsavedChanges(isDirty)
// Set date defaults on client only to avoid hydration mismatch. Skipped when
// editing: the draft's own dates are already loaded into the form.
useEffect(() => {
if (isEditMode) return
setValue('invoice_date', format(new Date(), 'yyyy-MM-dd'))
setValue('received_date', format(new Date(), 'yyyy-MM-dd'))
setValue('due_date', format(addDays(new Date(), 30), 'yyyy-MM-dd'))
}, [])
const { fields, append, remove, move } = useFieldArray({
control,
name: 'items',
})
// Drag-to-reorder (grip handle left of each row). framer-motion hands back
// the fully reordered array; we translate the single displacement into a
// react-hook-form move() so the registered inputs follow. The persisted
// sort_order is the array index at create time, so reordering here is all
// that's needed: no extra payload.
const handleItemsReorder = (newOrder: typeof fields) => {
const movedAt = newOrder.findIndex((f, i) => f.id !== fields[i]?.id)
if (movedAt === -1) return
const from = fields.findIndex((f) => f.id === newOrder[movedAt].id)
if (from !== -1 && from !== movedAt) move(from, movedAt)
}
const watchItems = watch('items')
const watchCurrency = watch('currency')
const watchCustomerId = watch('customer_id')
const watchDocumentType = watch('document_type') as InvoiceDocumentType
// Subscribed at render level so the Förval chip line and the next-step line
// stay live while the settings panel is collapsed.
const watchInvoiceDate = watch('invoice_date')
const watchDueDate = watch('due_date')
const watchReceivedDate = watch('received_date')
const watchDeliveryDate = watch('delivery_date')
const watchYourReference = watch('your_reference')
const watchPaymentLinkUrl = watch('payment_link_url')
const watchPaymentLinkAuto = watch('payment_link_auto')
const watchPersonnummer = watch('deduction_personnummer')
const watchHousingDesignation = watch('deduction_housing_designation')
const watchExternalNumber = watch('external_invoice_number')
// After customers state updates with the new customer, select it
useEffect(() => {
const pending = pendingCustomerRef.current
if (pending && customers.some((c) => c.id === pending.id)) {
setValue('customer_id', pending.id, { shouldValidate: true, shouldDirty: true })
setSelectedCustomer(pending)
pendingCustomerRef.current = null
}
}, [customers, setValue])
useEffect(() => {
if (!customersError) return
toast({
title: t('load_customers_failed_title'),
description: t('load_customers_failed_description'),
variant: 'destructive',
})
}, [customersError, toast, t])
// Apply a chosen article's defaults onto a line. Selecting "none" detaches the
// article link (and its account override) but keeps the typed text/price so the
// row becomes an editable free-text line.
function applyArticle(index: number, articleId: string) {
if (articleId === 'none') {
setValue(`items.${index}.article_id`, null, { shouldDirty: true })
setValue(`items.${index}.revenue_account`, null, { shouldDirty: true })
return
}
const a = articles.find((x) => x.id === articleId)
if (!a) return
setValue(`items.${index}.article_id`, a.id, { shouldDirty: true })
setValue(`items.${index}.description`, a.name, { shouldValidate: true, shouldDirty: true })
if (a.unit) setValue(`items.${index}.unit`, a.unit, { shouldDirty: true })
setValue(`items.${index}.unit_price`, Number(a.price_excl_vat) || 0, { shouldValidate: true, shouldDirty: true })
// Only adopt the article's VAT rate when it belongs to the customer's
// DEFAULT set, never to the wider permitted set. An article's stored rate is
// its domestic rate; nothing on it says the supply is one of the ML 6 kap.
// ones taxed where performed. Adopting 25% because the article says 25%
// would silently put Swedish VAT on a reverse-charge invoice, so a foreign
// business customer (single locked 0% default) keeps the line's rate and the
// user picks 12%/6% explicitly when it really is a hotel night or a ticket.
if (!vatRatePlan.hasSingleDefault && vatRatePlan.defaultRates.some((r) => r.rate === a.vat_rate)) {
setValue(`items.${index}.vat_rate`, a.vat_rate, { shouldValidate: true, shouldDirty: true })
}
// The account override rides along regardless of rate; the engine ignores it
// for reverse-charge/export and validates it against the chart of accounts.
setValue(`items.${index}.revenue_account`, a.revenue_account ?? null, { shouldDirty: true })
// ROT/RUT: the article's housework_type decides the line's deduction kind
// and, when it is a Skatteverket arbetstypskod, its work type too. Legacy
// articles carry only the kind (`ROT`/`RUT`): those pre-fill the deduction
// and keep a same-kind arbetstyp already chosen on the row. An article
// WITHOUT any housework flag re-defaults the row to no deduction, the same
// overwrite semantics as description/price above: a material article
// picked onto a previously RUT-flagged row must not keep claiming a
// deduction on material. Proformas/delivery notes/self-billing have no
// deduction model (their rows keep no ⋮ menu either), so they are left
// untouched.
if (isInvoiceDoc) {
const { deductionType: kind, workType } = parseArticleHouseworkType(a.housework_type)
const currentWorkType = getValues(`items.${index}.work_type`) ?? null
const keepCurrentWorkType =
kind != null && !workType && deductionTypeForWorkType(currentWorkType) === kind
setValue(`items.${index}.deduction_type`, kind, { shouldDirty: true })
setValue(
`items.${index}.work_type`,
workType ?? (keepCurrentWorkType ? currentWorkType : null),
{ shouldDirty: true },
)
if (kind) {
// Same rule as the manual ⋮ menu: ROT/RUT och periodisering
// kombineras aldrig på samma rad; avdraget vinner.
if (getValues(`items.${index}.accrual_balance_account`) != null) {
setValue(`items.${index}.accrual_period_start`, null)
setValue(`items.${index}.accrual_period_end`, null)
setValue(`items.${index}.accrual_balance_account`, null)
}
} else {
setValue(`items.${index}.labor_hours`, null)
setValue(`items.${index}.housing_designation`, null)
setValue(`items.${index}.apartment_number`, null)
}
}
// Pre-fill the invoice's (single) currency from the article ONLY on the
// first priced line, and only while the user hasn't chosen a currency
// themselves. Never flip an in-progress invoice's currency on a later pick:
// an invoice carries one currency for all its lines, so overwriting it would
// relabel existing line amounts (or the user's explicit choice) as another
// currency with no FX conversion, producing a legally wrong faktura and
// wrong VAT (ML 17 kap). The article's currency comes from the currencies
// reference table.
const currencyUserSet = Boolean(dirtyFields.currency)
const invoiceHasOtherContent = (watchItems ?? []).some(
(it, i) => i !== index && (Boolean(it?.article_id) || Number(it?.unit_price) > 0)
)
if (
a.currency &&
currencies.includes(a.currency as Currency) &&
a.currency !== getValues('currency') &&
!currencyUserSet &&
!invoiceHasOtherContent
) {
setValue('currency', a.currency as Currency, { shouldDirty: true })
}
}
// "Spara som artikel": persist the current free-text line into the register and
// back-fill the article_id so the row is now catalog-linked.
async function saveLineAsArticle(index: number) {
const item = watchItems[index]
if (!item?.description?.trim()) {
toast({ title: t('save_article_need_description'), variant: 'destructive' })
return
}
setSavingArticleIndex(index)
try {
const response = await fetch('/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: item.description.trim(),
unit: item.unit || 'st',
price_excl_vat: Number(item.unit_price) || 0,
vat_rate: item.vat_rate ?? 25,
// The typed unit price is in the invoice's currency: without this an
// EUR invoice line becomes an SEK article with the EUR number.
currency: getValues('currency'),
// Round-trip the ROT/RUT flag so the saved article pre-fills the
// deduction the next time it is picked: the arbetstypskod when the
// row has one, otherwise the bare kind.
housework_type: item.deduction_type
? deductionTypeForWorkType(item.work_type) === item.deduction_type
? item.work_type
: item.deduction_type.toUpperCase()
: null,
}),
})
const result = await response.json()
if (!response.ok) {
throw new Error(getErrorMessage(result, { context: 'article', statusCode: response.status }))
}
const created = result.data as ArticleOption
// Every article picker on the page reads the shared cache: refresh it
// (awaited, so the new option resolves before the line points at it).
await invalidateReferenceData('ref:articles')
setValue(`items.${index}.article_id`, created.id, { shouldDirty: true })
toast({ title: t('article_saved_title'), description: created.name })
} catch (error) {
toast({
title: t('save_article_failed'),
description: getErrorMessage(error, { context: 'article' }),
variant: 'destructive',
})
} finally {
setSavingArticleIndex(null)
}
}
// ---------------------------------------------------------------------
// Unified entry row: one input in the table's ghost last row. Typing
// searches the artikelregister; Enter on a highlighted match commits an
// article line (same side effects as picking the article on a row), Enter
// on free text commits a free-text product line and moves focus to its
// price cell. The entry row is never part of the field array until
// committed, so sort_order and the index-keyed override sets stay stable.
// ---------------------------------------------------------------------
// Byte-identical to the previous "Lägg till rad" append defaults: the
// DEFAULT VAT rate, never the widest permitted one (0% for a
// reverse-charge / export customer, 25% domestically).
function appendProductRow(description: string) {
append({
line_type: 'product',
description,
quantity: 1,
unit: 'st',
unit_price: 0,
vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0,
article_id: null,
revenue_account: null,
deduction_type: null,
labor_hours: null,
work_type: null,
housing_designation: null,
apartment_number: null,
brf_org_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: null,
})
}
function commitEntryArticle(articleId: string) {
const index = fields.length
appendProductRow('')
// applyArticle drives the exact same side effects as picking the article
// on an existing row: description/unit/price, conditional VAT adoption,
// revenue-account override, ROT/RUT work type, first-line currency.
applyArticle(index, articleId)
setEntryQuery('')
setEntryOpen(false)
setEntryActiveIdx(-1)
markRowSettled(index)
entryInputRef.current?.focus()
}
function commitEntryFreeText(text: string) {
const index = fields.length
appendProductRow(text)
setEntryQuery('')
setEntryOpen(false)
setEntryActiveIdx(-1)
markRowSettled(index)
// Enter commits and the caret lands in the new row's à-pris cell with
// the 0 selected. The input mounts on the next commit, hence the timeout.
window.setTimeout(() => setFocus(`items.${index}.unit_price`, { shouldSelect: true }), 0)
}
// Free-text / blank row: explanatory text under an item, or an empty
// spacer. Carries no amounts and never books. Not offered for a received
// självfaktura (the self-billed endpoint has no line_type and rejects
// zero-amount rows).
function addTextRow() {
const index = fields.length
append({
line_type: 'text',
description: '',
quantity: 0,
unit: '',
unit_price: 0,
vat_rate: 0,
article_id: null,
revenue_account: null,
deduction_type: null,
labor_hours: null,
work_type: null,
housing_designation: null,
apartment_number: null,
brf_org_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: null,
})
markRowSettled(index)
window.setTimeout(() => setFocus(`items.${index}.description`), 0)
}
// Brief background settle on a freshly committed row; the CSS animation
// (globals.css .row-settle) collapses under prefers-reduced-motion. The
// timeout clears the class even if animationend never fires.
function markRowSettled(index: number) {
setSettleIndex(index)
window.setTimeout(() => {
setSettleIndex((current) => (current === index ? null : current))
}, 800)
}
function handleEntryKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
const matches = filterArticleSuggestions(articles, entryQuery)
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!entryOpen) setEntryOpen(true)
if (matches.length) setEntryActiveIdx((i) => Math.min(i + 1, matches.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setEntryActiveIdx((i) => Math.max(i - 1, -1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (entryOpen && entryActiveIdx >= 0 && matches[entryActiveIdx]) {
commitEntryArticle(matches[entryActiveIdx].id)
} else if (entryQuery.trim()) {
commitEntryFreeText(entryQuery.trim())
}
} else if (e.key === 'Escape') {
// Close only the suggestions; the host dialog ignores Escape anyway.
e.stopPropagation()
setEntryOpen(false)
setEntryActiveIdx(-1)
}
}
// Open/close the per-row article re-link strip (row ⋮ menu). Closing never
// clears the article link: unlike the account override, the link is data on
// the row; detaching goes through the strip's "Egen rad" option instead.
function toggleArticlePicker(index: number) {
setArticlePickerRows((prev) => {
const next = new Set(prev)
if (next.has(index)) next.delete(index)
else next.add(index)
return next
})
}
// Apply the company settings ONCE per editor instance. The settings row is
// a live SWR value: a background revalidation must not re-run the
// create-mode prefills below over notes or a reference the user has since
// typed. The gates read each time (vatRegistered, dimensions, payment
// links) are cheap and deliberately re-applied too, they are not user input.
const settingsAppliedRef = useRef(false)
useEffect(() => {
const data = companySettings
if (!data || settingsAppliedRef.current) return
settingsAppliedRef.current = true
if (data?.invoice_default_notes) {
setDefaultNotes(data.invoice_default_notes)
if (!isEditMode && !isCopyMode) {
setValue('notes', data.invoice_default_notes)
}
}
// Pre-fill "Vår referens" from the company default: only when creating a
// fresh invoice, so an edited draft's own reference is never overwritten.
if (!isEditMode && !isCopyMode && data?.default_our_reference) {
setValue('our_reference', data.default_our_reference)
}
setHasBankDetails(
!!(data?.clearing_number && data?.account_number) || !!data?.bankgiro
)
if (data?.accounting_method === 'cash' || data?.accounting_method === 'accrual') {
setAccountingMethod(data.accounting_method)
}
// An explicit per-invoice flag (edit mode) wins; only fall back to the
// company-wide setting when creating or when the draft never set one.
if (typeof data?.ore_rounding === 'boolean' && initialOreRounding == null) {
setOreRounding(data.ore_rounding)
}
setLogoUrl(data?.logo_url ?? null)
if (typeof data?.vat_registered === 'boolean') {
setVatRegistered(data.vat_registered)
}
// Gates the dimension affordances (header default + per-row override).
setDimensionsEnabled(data?.dimensions_enabled === true)
// Gates the payment-link section (opt-in on the invoice settings page).
setPaymentLinksEnabled(data?.invoice_payment_links_enabled === true)
// The initial-mode flags and the draft's own rounding are fixed for the
// editor's lifetime; setValue is stable (react-hook-form).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companySettings])
// First-invoice detection (issue #520): captured at page load so the
// post-create flow can offer the logo prompt for genuinely first-time
// invoices only. head:true keeps it cheap: no rows pulled.
useEffect(() => {
if (!company?.id) return
let cancelled = false
;(async () => {
const { count } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', company.id)
if (!cancelled) setHadZeroInvoices(count === 0 || count === null)
})()
return () => {
cancelled = true
}
// supabase is a stable reference from createClient() at top of component
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [company?.id])
// Preview the next invoice number so the user can catch a mis-set
// sequence/prefix before committing. The actual allocator still runs
// atomically at create time; this is read-only.
useEffect(() => {
if (!company?.id) return
// Editing an existing draft: it already has (or will keep) its own number,
// never show the "next number" preview.
if (isEditMode || watchDocumentType === 'delivery_note') {
setNumberPreview(null)
return
}
let cancelled = false
fetch(`/api/invoices/next-number?document_type=${encodeURIComponent(watchDocumentType)}`)
.then((r) => (r.ok ? r.json() : null))
.then((res) => {
if (!cancelled) setNumberPreview(res?.data?.preview ?? null)
})
.catch(() => {
if (!cancelled) setNumberPreview(null)
})
return () => {
cancelled = true
}
}, [company?.id, watchDocumentType])
useEffect(() => {
if (watchCustomerId) {
const customer = customers.find((c) => c.id === watchCustomerId)
setSelectedCustomer(customer || null)
// Skip the derived side-effects (due_date, VAT rate snap) the first time
// we resolve a pre-filled customer in edit mode: those values came from
// the saved draft and must not be overwritten. Applied normally on every
// subsequent (user-initiated) customer change, and always in create mode.
if (customer) {
const nextDefaultRate = resolveLineVatRates(customer).defaultRate
if (didInitialCustomerSync.current) {
// Update due date based on customer payment terms
if (customer.default_payment_terms) {
setValue(
'due_date',
format(addDays(new Date(), customer.default_payment_terms), 'yyyy-MM-dd')
)
}
// Move only the lines still sitting on the OLD customer's default
// rate onto the new one: the switch must not leave a stale 25% on a
// reverse-charge invoice, nor a stale 0% on a domestic one. A line
// the user moved off that default stays put: 12% on a Stockholm
// hotel night sold to a German company is lawful (taxed where
// performed, ML 6 kap.) and snapping it to 0% would destroy it.
if (didSeedVatSnapBaseline.current) {
for (const snap of planCustomerSwitchVatSnap({
items: watchItems ?? [],
previousDefaultRate: previousDefaultRateRef.current,
nextDefaultRate,
})) {
setValue(`items.${snap.index}.vat_rate`, snap.rate)
}
}
}
previousDefaultRateRef.current = nextDefaultRate
didSeedVatSnapBaseline.current = true
didInitialCustomerSync.current = true
}
}
}, [watchCustomerId, customers, setValue])
async function handleCreateCustomer(data: CreateCustomerInput) {
setIsCreatingCustomer(true)
const response = await fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
if (!response.ok) {
toast({
title: t('create_customer_failed_title'),
description: getErrorMessage(result, { context: 'customer' }),
variant: 'destructive',
})
} else {
toast({
title: t('customer_created_title'),
description: t('customer_created_description', { name: data.name }),
})
pendingCustomerRef.current = result.data
// The selection effect above picks the pending customer up as soon as
// the refreshed shared list contains it.
await invalidateReferenceData('ref:customers')
setIsCreateCustomerOpen(false)
}
setIsCreatingCustomer(false)
}
const subtotal = watchItems.reduce((sum, item) => {
return sum + (item.quantity || 0) * (item.unit_price || 0)
}, 0)
const vatRules = selectedCustomer
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
: null
// Rendered options and the default are deliberately two different sets:
// `options` is what may LAWFULLY appear on a line (getPermittedVatRates),
// `defaultRates` / `defaultRate` is what the form OFFERS by itself
// (getAvailableVatRates). See components/invoices/line-vat-rates.ts.
const vatRatePlan = resolveLineVatRates(selectedCustomer)
// One ochre sentence, and only once a Swedish rate is actually selected on an
// invoice to a foreign business: 0% is the rule, a non-zero rate is lawful
// only for the ML 6 kap. supplies taxed where they are performed.
const showTaxedWherePerformedHint =
vatRegistered && hasSwedishVatToForeignBusiness({ plan: vatRatePlan, items: watchItems ?? [] })
// A non-momsregistrerad company never charges VAT: hide the Moms column and
// book every line momsfritt. `vatRegistered` is the single switch the whole
// form keys off: no rate picker, no warning, no VAT in the totals/preview.
// The API enforces the same (forces 0% server-side), so a stale hidden field
// value can't smuggle VAT onto the invoice. With VAT hidden the description
// column widens into the freed Moms column.
const rowGridClass = vatRegistered
? 'grid grid-cols-[minmax(7rem,1fr)_7.5rem_5.5rem_4.5rem_6rem_3.5rem] items-center gap-1'
: 'grid grid-cols-[minmax(7rem,1fr)_7.5rem_5.5rem_6rem_3.5rem] items-center gap-1'
// Calculate per-item VAT. When not VAT-registered every rate is forced to 0
// so vatAmount stays 0 and total === subtotal.
const vatByRate = new Map<number, { base: number; vat: number }>()
let vatAmount = 0
for (const item of watchItems) {
const rate = vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0
const lineTotal = (item.quantity || 0) * (item.unit_price || 0)
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatAmount += lineVat
const existing = vatByRate.get(rate) || { base: 0, vat: 0 }
existing.base += lineTotal
existing.vat += lineVat
vatByRate.set(rate, existing)
}
const total = subtotal + vatAmount
// ROT/RUT-avdrag live preview. Computed client-side for instant feedback;
// the API recomputes server-side as the source of truth. Skipped for
// non-invoice document types (proformas and delivery notes don't book
// a deduction).
const isSelfBilled = mode === 'self_billed'
// ROT/RUT is an own-issued, B2C concept: never shown for a received self-bill.
const isInvoiceDoc = watchDocumentType === 'invoice' && !isSelfBilled
rotRutCompletenessAppliesRef.current = isInvoiceDoc
// ROT/RUT yearly-ceiling context: what this customer has already been
// granted in the invoice's calendar year (SEK), across issued invoices with
// a deduction. Per customer, not per personnummer (the number is only ever
// ciphertext here), and blind to other providers, so it feeds a warning,
// never a block: the customer still owns their remaining headroom.
const [priorYearDeductions, setPriorYearDeductions] = useState<PriorYearDeductions | null>(null)
const invoiceYear = (watchInvoiceDate || '').slice(0, 4)
useEffect(() => {
if (!isInvoiceDoc || !company?.id || !watchCustomerId || !isFiscalYear(invoiceYear)) {
setPriorYearDeductions(null)
return
}
let cancelled = false
// The ceiling follows the year the buyer PAID (Skatteverket attributes the
// skattereduktion to the payment year), so paid invoices count by paid_at
// and open ones by invoice_date. A customer has few deduction invoices, so
// fetch them all (paginated: PostgREST caps plain selects) and pick the
// year here rather than through a runtime-built OR filter.
fetchAllRows<{
id: string
currency: string | null
exchange_rate: number | null
paid_at: string | null
invoice_date: string
invoice_items: Array<{ deduction_type: 'rot' | 'rut' | null; deduction_amount: number | null }> | null
}>(({ from, to }) =>
supabase
.from('invoices')
.select('id, currency, exchange_rate, paid_at, invoice_date, invoice_items(deduction_type, deduction_amount)')
.eq('company_id', company.id)
.eq('customer_id', watchCustomerId)
.eq('document_type', 'invoice')
.is('credited_invoice_id', null)
.not('status', 'in', '(draft,cancelled,credited)')
.gt('deduction_total', 0)
.order('id')
.range(from, to),
)
.then((data) => {
if (cancelled) return
const totals: PriorYearDeductions = { rot: 0, rut: 0 }
for (const inv of data) {
if (initial?.id && inv.id === initial.id) continue
if ((inv.paid_at ?? inv.invoice_date ?? '').slice(0, 4) !== invoiceYear) continue
const isSek = !inv.currency || inv.currency === 'SEK'
const rate = inv.exchange_rate
if (!isSek && !(typeof rate === 'number' && rate > 0)) continue
for (const it of inv.invoice_items ?? []) {
if (!it.deduction_type || !it.deduction_amount) continue
const sek = isSek ? it.deduction_amount : it.deduction_amount * (rate as number)
totals[it.deduction_type] += roundOre(sek)
}
}
setPriorYearDeductions(totals)
})
.catch(() => {
// A failed lookup must not leave a stale total from another customer or
// year on screen; no prior context = per-invoice check only.
if (!cancelled) setPriorYearDeductions(null)
})
return () => {
cancelled = true
}
// supabase client is stable; initial?.id only changes with the invoice.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isInvoiceDoc, company?.id, watchCustomerId, invoiceYear, initial?.id])
const deductionByKind = { rot: 0, rut: 0 }
if (isInvoiceDoc) {
for (const item of watchItems) {
if (!item.deduction_type) continue
const amount = computeDeduction({
unit_price: item.unit_price || 0,
quantity: item.quantity || 0,
deduction_type: item.deduction_type,
// Same rate resolution as the VAT totals loop above: the deduction
// base is the line total inkl. moms (HUSFL 6-9 §§).
vat_rate: vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0,
})
if (item.deduction_type === 'rot') deductionByKind.rot += amount
else deductionByKind.rut += amount
}
}
const deductionTotal = Math.round((deductionByKind.rot + deductionByKind.rut) * 100) / 100
// Same helper as the server validator (validateInvoice → deductionCapWarnings):
// per-kind and shared yearly ceilings, on top of what this customer already
// has this year. Statutory Swedish text (Skatteverket wording, stays Swedish
// in both locales like the server warnings it mirrors).
const capWarnings = isInvoiceDoc && deductionTotal > 0
? deductionCapWarnings(deductionByKind, { currency: watchCurrency }, priorYearDeductions)
: []
// Any flagged row, not only rows with a positive amount yet: the payload
// sanitizer and the server both key on deduction_type, so the card (with
// the personnummer the server will demand) must appear on the same predicate.
const hasAnyDeduction = deductionTotal > 0 || (isInvoiceDoc && watchItems.some((i) => Boolean(i.deduction_type)))
const hasAnyRotLine = isInvoiceDoc && watchItems.some((i) => i.deduction_type === 'rot')
// The kundkort's personnummer reaches this component as ciphertext (direct
// table read) or as the masked display form (rows from the API), so the
// editor can only know THAT the customer has one, never render it. Presence
// is enough: the server falls back to it when the field is left empty, so
// the field stops being required and the hint says where the number will
// come from. The undecryptable placeholder is not presence.
const customerHasPersonalNumber = Boolean(
selectedCustomer?.personal_number &&
selectedCustomer.personal_number !== UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
)
// Öresavrundning live preview: same helper as the PDF/email, so the summary
// shows exactly what the customer will see. Display-only; the saved invoice
// keeps the exact öre.
const { rounding: displayRounding, toPay: displayedToPay } = getAmountToPay(
{ total, currency: watchCurrency, ore_rounding: oreRounding, deduction_total: deductionTotal },
null,
)
// Periodisering per rad: kräver faktureringsmetoden och en riktig faktura.
// EU-/exportkunder bokas på 3308/3305 (omvänd skattskyldighet/export) och
// kan inte periodiseras: ruta 39/40 ska spegla hela försäljningen.
const customerBlocksAccrual =
selectedCustomer?.customer_type === 'eu_business' ||
selectedCustomer?.customer_type === 'non_eu_business'
const canUseAccrual = isInvoiceDoc && accountingMethod === 'accrual' && !customerBlocksAccrual
function toggleAccrual(index: number) {
if (watchItems[index]?.accrual_balance_account != null) {
setValue(`items.${index}.accrual_period_start`, null, { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, null, { shouldDirty: true })
setValue(`items.${index}.accrual_balance_account`, null, { shouldDirty: true })
} else {
setValue(`items.${index}.accrual_period_start`, watch('invoice_date') || '', { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, '', { shouldDirty: true })
setValue(
`items.${index}.accrual_balance_account`,
DEFAULT_DEFERRED_REVENUE_ACCOUNT,
{ shouldDirty: true },
)
}
}
// Open/close the optional per-line posting-account override. Closing clears
// the value so the engine falls back to the VAT-rate-derived revenue account.
function toggleAccountOverride(index: number) {
const isOpen = accountOverrideRows.has(index) || !!watchItems[index]?.revenue_account
if (isOpen) {
setValue(`items.${index}.revenue_account`, null, { shouldDirty: true })
setAccountOverrideRows((prev) => {
const next = new Set(prev)
next.delete(index)
return next
})
} else {
setAccountOverrideRows((prev) => new Set(prev).add(index))
}
}
// Open/close the optional per-item dimensions override (⋮ menu). Closing
// clears the bag so the row falls back to the invoice's default_dimensions.
function toggleItemDimensions(index: number) {
const isOpen = dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)
if (isOpen) {
setValue(`items.${index}.dimensions`, null, { shouldDirty: true })
setDimensionOverrideRows((prev) => {
const next = new Set(prev)
next.delete(index)
return next
})
} else {
setDimensionOverrideRows((prev) => new Set(prev).add(index))
}
}
function updateItemDimension(index: number, dimNo: string, code: string | null) {
const current = { ...(watchItems[index]?.dimensions ?? {}) }
const trimmed = code?.trim()
if (trimmed) current[dimNo] = trimmed
else delete current[dimNo]
setValue(
`items.${index}.dimensions`,
Object.keys(current).length > 0 ? current : null,
{ shouldDirty: true },
)
// Keep the sub-row open after the user clears the last value: it closes
// only via the ⋮ menu (same lifecycle as the account override).
setDimensionOverrideRows((prev) => (prev.has(index) ? prev : new Set(prev).add(index)))
}
function setDefaultDimension(dimNo: string, code: string | null) {
setDefaultDims((prev) => {
const next = { ...prev }
const trimmed = code?.trim()
if (trimmed) next[dimNo] = trimmed
else delete next[dimNo]
return next
})
}
// Self-billing path: no review dialog, no PDF, no send: it arrives already
// booked. POST straight to the dedicated endpoint and open the verifikat.
// Body mapping lives in lib/invoices/editor-payload.ts (payload-parity
// tested), together with the shared create/draft/edit body builder.
async function handleSelfBilledSubmit(data: FormData) {
setIsSubmitting(true)
try {
const response = await fetch('/api/invoices/self-billed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildSelfBilledPayload(data)),
})
const result = await response.json()
if (!response.ok) {
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
toast({
title: ts('created_title'),
description: ts('created_description', { number: data.external_invoice_number ?? '' }),
})
router.replace(`/invoices/${result.data.id}`)
} catch (error) {
toast({
title: ts('create_failed_title'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
async function onSubmit(data: FormData) {
if (isEditMode) {
// Editing a draft: no review dialog, straight to PATCH.
await saveEdit(data)
return
}
if (isSelfBilled) {
// The two self-billing-only fields are optional in the shared schema:
// enforce them here so the inline errors render under the right inputs.
let valid = true
if (!data.external_invoice_number?.trim()) {
setError('external_invoice_number', { message: ts('validation_external_number_required') })
valid = false
}
if (!data.received_date) {
setError('received_date', { message: ts('validation_received_date_required') })
valid = false
}
if (!valid) return
await handleSelfBilledSubmit(data)
return
}
// The review dialog only mounts once the picked customer resolves against
// the loaded customers list. Without this guard a click while the list is
// still loading (or failed to load) set showReview on an unmounted dialog:
// the button then silently did nothing (support: cbysea.se).
if (!selectedCustomer) {
toast({
title: t('review_customer_missing_title'),
description: t('review_customer_missing_description'),
variant: 'destructive',
})
return
}
setPendingData(data)
// Re-fetch the preview right before review so the displayed number
// reflects any concurrent invoice creations. Skip for delivery notes.
// Bounded: this blocks the review dialog from opening, and a hung fetch
// must not be able to freeze the flow (the catch below eats the abort).
if (data.document_type !== 'delivery_note') {
try {
const r = await fetch(
`/api/invoices/next-number?document_type=${encodeURIComponent(data.document_type)}`,
{ signal: AbortSignal.timeout(5000) },
)
if (r.ok) {
const json = await r.json()
setNumberPreview(json?.data?.preview ?? null)
}
} catch {
// Preview is best-effort; the allocator at create time is the source of truth.
}
}
if (hasBankDetails === false && watchDocumentType === 'invoice') {
setShowBankSetup(true)
return
}
setShowReview(true)
}
function handleBankSetupComplete() {
setHasBankDetails(true)
setShowBankSetup(false)
if (pendingData) {
setShowReview(true)
}
}
function getDocLabel(type: InvoiceDocumentType): string {
if (type === 'proforma') return t('doc_label_proforma')
if (type === 'delivery_note') return t('doc_label_delivery_note')
return t('doc_label_invoice')
}
function handleLogoPromptClose() {
setShowLogoPrompt(false)
// Resume the post-create flow that was deferred by the logo prompt.
// The send-now dialog only emails: skipped without the email_send
// capability (the invoice page's SendInvoiceDialog carries the upsell).
if (selectedCustomer?.email && createdInvoiceId && hasEmailSend) {
setShowSendPrompt(true)
} else if (createdInvoiceId) {
router.replace(`/invoices/${createdInvoiceId}`)
}
}
async function handleConfirm() {
if (!pendingData) return
setIsSubmitting(true)
// Shared body builder (lib/invoices/editor-payload.ts): dimension pruning,
// ROT/RUT privacy strip, self-billing-carrier removal and the always-sent
// ore_rounding / default_dimensions all live there, pinned by parity tests.
const sanitizedPayload = buildInvoiceWritePayload(pendingData, {
oreRounding,
defaultDims,
})
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sanitizedPayload),
})
const result = await response.json()
if (!response.ok) {
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
const docLabel = getDocLabel(watchDocumentType)
toast({
title: t('doc_created_title', { docLabel }),
description: t('doc_created_description', { docLabel, number: result.data.invoice_number }),
})
setShowReview(false)
setCreatedInvoiceId(result.data.id)
// First-invoice-only logo prompt (issue #520) takes priority over the
// send-now dialog so a fresh upload makes it onto the just-sent PDF
// (pdf-template reads logo_url live from company_settings). Once the
// prompt closes, handleLogoPromptClose resumes the regular flow.
if (hadZeroInvoices === true && !logoUrl) {
setShowLogoPrompt(true)
} else if (selectedCustomer?.email && hasEmailSend) {
setShowSendPrompt(true)
} else {
router.replace(`/invoices/${result.data.id}`)
}
} catch (error) {
toast({
title: t('create_invoice_failed_title'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
// A failed Zod validation must never read as a dead button (the primary is
// enabled pre-click for writable users), so an invalid click routes focus
// to the first missing field, in the same order as the next-step line,
// which announces the problem via its aria-live region. Server-side
// failures keep their toasts: this replaces the client-side validation
// toast only.
function focusSettingsField(
name: 'invoice_date' | 'due_date' | 'received_date' | 'payment_link_url',
) {
// In self-billed mode fakturadatum and mottagningsdatum render uncollapsed
// next to the external number: focus directly, no panel to expand.
if (isSelfBilled && (name === 'invoice_date' || name === 'received_date')) {
setFocus(name)
return
}
// The field lives in the collapsed Förval panel: expand first, focus once
// the panel is visible (focus() is a no-op inside visibility: hidden).
setSettingsOpen(true)
window.setTimeout(() => setFocus(name), 60)
}
function scrollRowIntoView(index: number) {
document
.getElementById(`invoice-editor-row-${index}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function focusStep(step: NextStep) {
switch (step.kind) {
case 'customer':
customerTriggerRef.current?.focus()
break
case 'invoice_date':
case 'due_date':
case 'received_date':
focusSettingsField(step.kind)
break
case 'rows_empty':
entryInputRef.current?.focus()
break
case 'row_incomplete':
// The unit cell is a Radix Select (not focusable via RHF): bring the
// row into view instead.
if (step.field === 'unit') scrollRowIntoView(step.index)
else setFocus(`items.${step.index}.${step.field}`)
break
case 'payment_link':
focusSettingsField('payment_link_url')
break
case 'personnummer':
setFocus('deduction_personnummer')
break
case 'housing':
setFocus('deduction_housing_designation')
break
case 'external_number':
setFocus('external_invoice_number')
break
case 'ready':
break
}
}
function onInvalidSubmit(errs: FieldErrors<FormData>) {
if (nextStep.kind !== 'ready') {
focusStep(nextStep)
return
}
// Field-adjacent problems the coarse next-step model does not cover
// (accrual period, posting-account format): route by error order.
const itemErrs = errs.items
if (Array.isArray(itemErrs)) {
for (let i = 0; i < itemErrs.length; i++) {
const rowErr = itemErrs[i]
if (!rowErr) continue
for (const field of ['description', 'quantity', 'unit', 'unit_price'] as const) {
if (rowErr[field]) {
setFocus(`items.${i}.${field}`)
return
}
}
scrollRowIntoView(i)
return
}
}
if (errs.payment_link_url) {
focusSettingsField('payment_link_url')
return
}
if (errs.customer_id) customerTriggerRef.current?.focus()
else if (errs.invoice_date) focusSettingsField('invoice_date')
else if (errs.due_date) focusSettingsField('due_date')
}
// "Spara som utkast": save an unnumbered draft (save_as_draft) without the
// review dialog. The invoice gets no F-number and fires no invoice.created
// until the user opens it and clicks "Granska & skapa" (finalize). Same
// ROT/RUT privacy sanitization as handleConfirm.
async function saveDraftData(data: FormData) {
setIsSavingDraft(true)
const payload = buildInvoiceWritePayload(data, {
saveAsDraft: true,
oreRounding,
defaultDims,
})
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
toast({
title: t('toast_draft_saved_title'),
description: t('toast_draft_saved_description'),
})
// replace (here and in every post-save navigation): the editor page must
// drop out of history, or the detail page's back arrow reopens a fresh
// editor instead of returning to the list (issue #1053).
router.replace(`/invoices/${result.data.id}`)
} catch (error) {
toast({
title: t('save_draft_failed_title'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
setIsSavingDraft(false)
}
}
// Edit mode: PATCH the existing draft (header + items). Same ROT/RUT privacy
// sanitization as create: personal-data fields only ride along when a
// deduction is actually claimed. No review dialog, no number allocation, no
// send/logo prompt; on success go back to the invoice detail page.
async function saveEdit(data: FormData) {
if (!initial) return
setIsSubmitting(true)
const payload = buildInvoiceWritePayload(data, {
oreRounding,
defaultDims,
})
try {
const response = await fetch(`/api/invoices/${initial.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
toast({
title: t('toast_draft_updated_title'),
description: t('toast_draft_updated_description'),
})
router.replace(`/invoices/${initial.id}`)
} catch (error) {
toast({
title: t('update_failed_title'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
async function handleSendNow() {
if (!createdInvoiceId) return
setIsSending(true)
try {
const response = await fetch(`/api/invoices/${createdInvoiceId}/send`, {
method: 'POST',
})
if (!response.ok) {
const result = await response.json()
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
toast({
title: t('invoice_sent_title'),
description: t('invoice_sent_description', { email: selectedCustomer?.email ?? '' }),
})
} catch (error) {
toast({
title: t('send_invoice_failed_title'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
setIsSending(false)
setShowSendPrompt(false)
router.replace(`/invoices/${createdInvoiceId}`)
}
}
// Preview from the review dialog (no arg: uses the pending review data) or
// from the sticky bar (validated form data passed by handleSubmit).
async function handlePreviewPDF(dataOverride?: FormData) {
const data = dataOverride ?? pendingData
if (!data) return
setIsPreviewing(true)
// Open the tab synchronously inside the click's user activation. A
// window.open after the awaits below is popup-blocked whenever generation
// outlives the activation window (~5s): exactly the slow cold-start case,
// where the preview then silently did nothing (support: cbysea.se).
const tab = openDeferredTab(t('preview_pdf_generating'))
try {
const response = await fetch('/api/invoices/preview-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: data.customer_id,
invoice_date: data.invoice_date,
due_date: data.due_date,
currency: data.currency,
document_type: data.document_type,
items: data.items,
your_reference: data.your_reference,
our_reference: data.our_reference,
notes: data.notes,
payment_link_url: data.payment_link_url,
invoice_number: numberPreview,
// ROT/RUT claim card: the preview shows the same masked personnummer
// and fastighetsbeteckning in its deduction box as the created
// invoice will. Only sent when a line actually claims a deduction
// (same privacy rule as buildInvoiceWritePayload).
...(data.items.some((i) => i.deduction_type)
? {
deduction_personnummer: data.deduction_personnummer,
deduction_housing_designation: data.deduction_housing_designation,
}
: {}),
}),
})
if (!response.ok) {
const result = await response.json()
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
if (!tab.navigate(url)) {
tab.close()
window.URL.revokeObjectURL(url)
toast({
title: t('preview_pdf_failed'),
description: tCommon('popup_blocked_description'),
variant: 'destructive',
})
return
}
// The blob URL must outlive the tab's load; revoke on a generous delay
// instead of leaking it for the page's lifetime.
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000)
} catch (error) {
tab.close()
toast({
title: t('preview_pdf_failed'),
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
setIsPreviewing(false)
}
}
const titleText = isEditMode
? t('title_edit')
: isCopyMode
? t('title_copy')
: isSelfBilled
? ts('title')
: watchDocumentType === 'proforma'
? t('title_proforma')
: watchDocumentType === 'delivery_note'
? t('title_delivery_note')
: t('title_invoice')
// In bare (dialog) mode the dialog owns the accessible title (sr-only
// DialogTitle) and the page already has its own h1, so the visible heading
// steps down to h2: it still tracks document type and number preview live.
const Heading = bare ? 'h2' : 'h1'
// Derived snabbflöde state: entry-row suggestions, the Förval chip line and
// the single next-step line (components/invoices/invoice-editor-flow.ts).
const entryMatches = filterArticleSuggestions(articles, entryQuery)
const productRowCount = (watchItems ?? []).filter((i) => i?.line_type !== 'text').length
const paymentLinkMode: 'auto' | 'manual' | null =
watchDocumentType === 'invoice' && !isSelfBilled
? watchPaymentLinkUrl?.trim()
? 'manual'
: stripeConnected && paymentLinksEnabled && (watchPaymentLinkAuto ?? true)
? 'auto'
: null
: null
const nextStep = deriveNextStep({
isSelfBilled,
customerSelected: Boolean(watchCustomerId),
invoiceDate: watchInvoiceDate || '',
dueDate: watchDueDate || '',
receivedDate: watchReceivedDate || '',
externalInvoiceNumber: watchExternalNumber || '',
items: watchItems ?? [],
paymentLinkInvalid: Boolean(errors.payment_link_url),
requiresPersonnummer:
hasAnyDeduction && !(initial?.deduction_personnummer_last4 || customerHasPersonalNumber),
personnummer: watchPersonnummer || '',
// Gated on the claimed amount to match the claim card's mount condition
// (hasAnyDeduction): a ROT-flagged line with a zero amount renders no
// card, so the housing field the next-step link would focus does not
// exist yet.
requiresHousing: deriveRequiresHousing({ hasRotLine: hasAnyRotLine, deductionTotal }),
housingDesignation: watchHousingDesignation || '',
})
const nextStepLabels: Record<Exclude<NextStep['kind'], 'ready' | 'row_incomplete'>, string> = {
customer: t('next_step_customer'),
invoice_date: t('next_step_invoice_date'),
due_date: t('next_step_due_date'),
rows_empty: t('next_step_add_row'),
payment_link: t('next_step_payment_link'),
personnummer: t('next_step_personnummer'),
housing: t('next_step_housing'),
external_number: t('next_step_external_number'),
received_date: t('next_step_received_date'),
}
const nextStepText =
nextStep.kind === 'ready'
? isEditMode
? t('ready_edit')
: isSelfBilled
? t('ready_self_billed')
: t('ready_create')
: nextStep.kind === 'row_incomplete'
? t('next_step_row_incomplete', { index: nextStep.index + 1 })
: nextStepLabels[nextStep.kind]
const forvalChips = deriveForvalChips({
isSelfBilled,
documentType: watchDocumentType,
currency: watchCurrency,
invoiceDate: watchInvoiceDate || '',
dueDate: watchDueDate || '',
receivedDate: watchReceivedDate || '',
deliveryDate: watchDeliveryDate || '',
yourReference: watchYourReference || '',
paymentLink: paymentLinkMode,
oreRounding,
dims: hasDimensionValues(defaultDims) ? compactDims(defaultDims) : null,
})
const chipTexts = forvalChips.map((chip) => {
switch (chip.kind) {
case 'doc_type':
return chip.documentType === 'proforma' ? t('doctype_proforma') : t('doctype_delivery_note')
case 'currency':
return t('chip_currency', { currency: chip.currency })
case 'invoice_date':
return t('chip_invoice_date', { date: chip.date })
case 'due_days':
return t('chip_due_days', { days: chip.days, date: chip.date })
case 'due_date':
return t('chip_due_date', { date: chip.date })
case 'received':
return t('chip_received', { date: chip.date })
case 'delivery':
return t('chip_delivery', { date: chip.date })
case 'your_reference':
return t('chip_your_reference', { reference: chip.reference })
case 'payment_link':
return chip.mode === 'auto' ? t('chip_stripe_auto') : t('chip_payment_link')
case 'ore_off':
return t('chip_ore_off')
case 'dims':
return t('chip_dims', { dims: chip.dims })
}
})
const itemsRootError = errors.items as unknown as
| { root?: { message?: string }; message?: string }
| undefined
const itemsRootMsg =
itemsRootError?.root?.message ??
(typeof itemsRootError?.message === 'string' ? itemsRootError.message : undefined)
const inFlight = isSubmitting || isSavingDraft || isFormSubmitting
const showDraftAction = !isEditMode && !isSelfBilled && watchDocumentType === 'invoice'
const primaryLabel = isEditMode
? t('save_changes')
: isSelfBilled
? ts('register')
: t('review_and_create')
// min-w-0 on the root: DialogContent is display:grid; without it this grid
// item's min-width:auto lets the row grid's min-w force the whole column
// wider than small viewports and the dialog clips it.
return (
<div className={bare ? 'min-w-0' : 'mx-auto w-full min-w-0 max-w-2xl'}>
<div className={bare ? 'px-6 pt-6 pr-10' : undefined}>
<div className="flex items-center gap-3">
{!bare && (
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<Heading className={bare ? 'font-display text-xl tracking-tight' : 'font-display text-2xl leading-8 tracking-tight'}>
{titleText}
{numberPreview && !isSelfBilled && (
<span className="ml-2 align-baseline font-sans text-[13px] font-normal tracking-normal text-muted-foreground tabular-nums">
{numberPreview}
</span>
)}
</Heading>
</div>
{isCopyMode && copyInitial && (
<div className="mt-4 flex items-start gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
<Copy className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">
{t('copy_notice', { number: copyInitial.source_invoice_number })}
</p>
</div>
)}
{hasBankDetails === false && !isSelfBilled && (
<div className="mt-4 flex items-center gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
<Landmark className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">{t('bank_missing_warning')}</p>
<Button variant="link" size="sm" className="ml-auto shrink-0 px-0" onClick={() => setShowBankSetup(true)}>
{t('bank_add_now')}
</Button>
</div>
)}
</div>
<form onSubmit={handleSubmit(onSubmit, onInvalidSubmit)}>
<div className={cn('mt-8', bare && 'px-6')}>
{/* ===== Kund ===== */}
<section>
<SectionLabel>
{isSelfBilled ? ts('customer_label') : t('customer_card_title')}
<RequiredMark />
{selectedCustomer && (
<span className="ml-2 normal-case tracking-normal text-success">
&#10003; {t('customer_done')}
</span>
)}
</SectionLabel>
{isSelfBilled && (
<p className="-mt-2 mb-3 text-xs text-muted-foreground">{ts('issuer_card_description')}</p>
)}
<Controller
name="customer_id"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
ref={customerTriggerRef}
className="h-12 font-display text-base"
aria-required="true"
>
<SelectValue placeholder={t('select_customer_placeholder')} />
</SelectTrigger>
<SelectContent>
{/* Radix renders an empty viewport as a bare few-pixel
sliver; give the zero-customer state real content. */}
{customers.length === 0 && (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{customersLoading ? t('loading_customers') : t('no_customers_yet')}
</div>
)}
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{selectedCustomer && (
<div className="mt-2 text-[13px] leading-5 text-muted-foreground" data-ph-mask="">
{[
[selectedCustomer.address_line1, selectedCustomer.postal_code, selectedCustomer.city]
.filter(Boolean)
.join(', '),
selectedCustomer.org_number ? `Org.nr ${selectedCustomer.org_number}` : '',
selectedCustomer.email ?? '',
]
.filter(Boolean)
.map((line) => (
<div key={line}>{line}</div>
))}
</div>
)}
{errors.customer_id && (
<p className="mt-2 text-sm text-destructive">{errors.customer_id.message}</p>
)}
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'mt-3 inline-block')}
onClick={() => setIsCreateCustomerOpen(true)}
>
+ {t('create_customer')}
</button>
{isSelfBilled && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{ts('external_number_label')}<RequiredMark /></Label>
<Input placeholder={ts('external_number_placeholder')} {...register('external_invoice_number')} />
{errors.external_invoice_number && (
<p className="text-sm text-destructive">{errors.external_invoice_number.message}</p>
)}
</div>
<div className="space-y-2">
<Label>{ts('agreement_ref_label')}</Label>
<Input placeholder={ts('agreement_ref_placeholder')} {...register('self_billing_agreement_ref')} />
</div>
{/* The counterparty's issue date and our received date are
mandatory transcription fields, not defaults: keep them
visible instead of collapsed into Förval, where the
silent today-default registered wrong dates on immutable
self-billed invoices (issue #1820). */}
<div className="space-y-2">
<Label>{ts('invoice_date_label')}<RequiredMark /></Label>
<Input
type="date"
{...register('invoice_date')}
aria-required="true"
className="tabular-nums"
/>
{errors.invoice_date && (
<p className="text-sm text-destructive">{errors.invoice_date.message}</p>
)}
</div>
<div className="space-y-2">
<Label>{ts('received_date_label')}<RequiredMark /></Label>
<Input
type="date"
{...register('received_date')}
aria-required="true"
className="tabular-nums"
/>
{errors.received_date && (
<p className="text-sm text-destructive">{errors.received_date.message}</p>
)}
</div>
</div>
)}
</section>
{/* ===== Fakturarader ===== */}
<section className="mt-7 border-t border-border pt-7">
<SectionLabel>
{t('items_card_title')}
<RequiredMark />
{productRowCount > 0 && (
<span className="ml-2 normal-case tracking-normal">
{t('rows_count', { count: productRowCount })}
</span>
)}
</SectionLabel>
<div className="relative">
<div className="overflow-x-auto">
<div className="min-w-[540px]">
{/* Header row: offset by the drag-grip gutter (w-8). */}
<div className="pl-8">
<div className={cn(rowGridClass, 'border-b border-border pb-2 text-[11px] uppercase tracking-[0.08em] text-muted-foreground')}>
<div className="px-2">{t('description_label')}</div>
<div className="px-2 text-right">{t('quantity_label')}</div>
<div className="px-2 text-right">{t('unit_price_label')}</div>
{vatRegistered && <div className="px-2">{t('vat_label')}</div>}
<div className="px-2 text-right">{t('amount_label')}</div>
<div />
</div>
</div>
<Reorder.Group as="div" axis="y" values={fields} onReorder={handleItemsReorder}>
{fields.map((field, index) => {
const item = watchItems[index]
const isTextRow = item?.line_type === 'text'
const rowDescription = item?.description?.trim()
const removeLabel = t('remove_row_aria_named', {
description: rowDescription || t('row_label', { index: index + 1 }),
})
if (isTextRow) {
return (
<SortableRow
key={field.id}
value={field}
handleLabel={t('drag_handle_aria')}
disabled={fields.length === 1}
>
<div
id={`invoice-editor-row-${index}`}
className={cn('group border-b border-border', settleIndex === index && 'row-settle')}
>
<div className="flex items-center gap-1 py-1">
<input
{...register(`items.${index}.description`)}
placeholder={t('text_row_placeholder')}
aria-label={t('text_row_label')}
className={cn(CELL_INPUT_CLASS, 'w-full italic text-muted-foreground')}
/>
<span className={cn('flex items-center', HOVER_REVEAL_CLASS)}>
<button
type="button"
className={cn(ROW_ICON_BUTTON_CLASS, 'hover:text-destructive')}
onClick={() => remove(index)}
aria-label={removeLabel}
>
<X className="h-4 w-4" />
</button>
</span>
</div>
</div>
</SortableRow>
)
}
const lineTotal = (item?.quantity || 0) * (item?.unit_price || 0)
const rowErrors = errors.items?.[index]
const rowErrorMsg =
rowErrors?.description?.message ??
rowErrors?.quantity?.message ??
rowErrors?.unit?.message ??
(rowErrors?.unit_price ? t('validation_price_invalid') : undefined)
const articleStripOpen = articlePickerRows.has(index)
const accountStripOpen =
isInvoiceDoc && (accountOverrideRows.has(index) || Boolean(item?.revenue_account))
const dimensionStripOpen =
dimensionsEnabled &&
isInvoiceDoc &&
(dimensionOverrideRows.has(index) || hasDimensionValues(item?.dimensions))
const accrualStripOpen = canUseAccrual && item?.accrual_balance_account != null
const showSaveAsArticle = canWrite && !item?.article_id && Boolean(rowDescription)
return (
<SortableRow
key={field.id}
value={field}
handleLabel={t('drag_handle_aria')}
disabled={fields.length === 1}
>
<div
id={`invoice-editor-row-${index}`}
className={cn('group border-b border-border', settleIndex === index && 'row-settle')}
>
<div className={cn(rowGridClass, 'py-1')}>
<input
{...register(`items.${index}.description`)}
placeholder={t('description_placeholder')}
aria-label={t('description_label')}
aria-invalid={rowErrors?.description ? true : undefined}
className={cn(
CELL_INPUT_CLASS,
'w-full',
rowErrors?.description && 'border-destructive',
)}
/>
<div className="flex items-center justify-end gap-1">
<input
type="number"
step="0.01"
inputMode="decimal"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
aria-label={t('quantity_label')}
aria-invalid={rowErrors?.quantity ? true : undefined}
className={cn(
CELL_INPUT_CLASS,
'w-14 text-right tabular-nums',
rowErrors?.quantity && 'border-destructive',
)}
/>
<Controller
name={`items.${index}.unit`}
control={control}
render={({ field: unitField }) => (
<Select value={unitField.value} onValueChange={unitField.onChange}>
<SelectTrigger
className={cn(
CELL_SELECT_TRIGGER_CLASS,
'text-muted-foreground',
rowErrors?.unit && 'border-destructive',
)}
aria-label={t('unit_label')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<input
type="number"
step="any"
inputMode="decimal"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
aria-label={t('unit_price_label')}
aria-invalid={rowErrors?.unit_price ? true : undefined}
className={cn(
CELL_INPUT_CLASS,
'w-full text-right tabular-nums',
rowErrors?.unit_price && 'border-destructive',
)}
/>
{vatRegistered && (
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: vatField }) => (
<Select
value={String(vatField.value ?? 25)}
onValueChange={(v) => vatField.onChange(Number(v))}
disabled={vatRatePlan.isPickerLocked}
>
<SelectTrigger className={CELL_SELECT_TRIGGER_CLASS} aria-label={t('vat_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* The lawful set, not the default one: a
foreign business customer gets 0% first
(and preselected) plus 25/12/6 for the
supplies taxed where they are performed. */}
{vatRatePlan.options.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
)}
<div className="whitespace-nowrap px-2 text-right text-[13px] tabular-nums">
{formatCurrency(lineTotal, watchCurrency)}
</div>
<span className={cn('flex items-center justify-end gap-1', HOVER_REVEAL_CLASS)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={ROW_ICON_BUTTON_CLASS} aria-label={t('row_actions_aria')}>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuItem onSelect={() => toggleArticlePicker(index)} className="py-2">
<Package className="h-4 w-4" />
{t('row_menu_pick_article')}
</DropdownMenuItem>
{isInvoiceDoc && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('deduction_menu_label')}</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={item?.deduction_type ?? 'none'}
onValueChange={(v) => {
const next = v === 'none' ? null : (v as 'rot' | 'rut')
setValue(`items.${index}.deduction_type`, next, { shouldDirty: true })
// The arbetstyp lists are per kind: a ROT code
// must not survive a switch to RUT (the select
// would show it as empty while the payload kept
// the wrong code).
if (
next !== null &&
deductionTypeForWorkType(item?.work_type) !== next
) {
setValue(`items.${index}.work_type`, null)
}
if (next === null) {
setValue(`items.${index}.work_type`, null)
setValue(`items.${index}.labor_hours`, null)
setValue(`items.${index}.housing_designation`, null)
setValue(`items.${index}.apartment_number`, null)
} else if (item?.accrual_balance_account != null) {
// ROT/RUT och periodisering kombineras aldrig
// på samma rad: avdraget vinner.
setValue(`items.${index}.accrual_period_start`, null)
setValue(`items.${index}.accrual_period_end`, null)
setValue(`items.${index}.accrual_balance_account`, null)
}
}}
>
<DropdownMenuRadioItem value="none" className="py-2">{t('deduction_none')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rot" className="py-2">{t('deduction_rot')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rut" className="py-2">{t('deduction_rut')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</>
)}
{canUseAccrual && !item?.deduction_type && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => toggleAccrual(index)} className="py-2">
<CalendarClock className="h-4 w-4" />
{item?.accrual_balance_account != null
? ta('row_menu_remove')
: ta('row_menu_add')}
</DropdownMenuItem>
</>
)}
{isInvoiceDoc && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => toggleAccountOverride(index)} className="py-2">
<Landmark className="h-4 w-4" />
{(accountOverrideRows.has(index) || item?.revenue_account)
? t('row_menu_remove_account')
: t('row_menu_set_account')}
</DropdownMenuItem>
</>
)}
{dimensionsEnabled && isInvoiceDoc && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => toggleItemDimensions(index)} className="py-2">
<Tags className="h-4 w-4" />
{(dimensionOverrideRows.has(index) || hasDimensionValues(item?.dimensions))
? t('row_menu_remove_dimensions')
: t('row_menu_set_dimensions')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
className={cn(ROW_ICON_BUTTON_CLASS, 'hover:text-destructive')}
onClick={() => remove(index)}
aria-label={removeLabel}
>
<X className="h-4 w-4" />
</button>
</span>
</div>
{rowErrorMsg && <p className="px-2 pb-2 text-xs text-destructive">{rowErrorMsg}</p>}
{showSaveAsArticle && (
<div className="px-2 pb-2">
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => saveLineAsArticle(index)}
disabled={savingArticleIndex === index}
>
{savingArticleIndex === index && (
<Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
)}
{t('save_as_article')}
</button>
</div>
)}
{/* Article re-link strip (row ⋮ menu): article
selection stays reachable on every committed
row; "Egen rad" detaches the link. */}
{articleStripOpen && (
<div className="max-w-sm px-2 pb-3">
<Label className="mb-1 block text-xs text-muted-foreground">{t('article_label')}</Label>
<Controller
name={`items.${index}.article_id`}
control={control}
render={({ field: articleField }) => (
<ArticleCombobox
value={articleField.value ?? null}
articles={articles}
onChange={(v) => {
applyArticle(index, v)
toggleArticlePicker(index)
}}
freeTextLabel={t('article_free_text')}
placeholder={t('article_placeholder')}
emptyLabel={t('article_search_empty')}
ariaLabel={t('article_label')}
/>
)}
/>
</div>
)}
{/* ROT/RUT-avdrag strip: only when a deduction is
active on this row (chosen via the ⋮ menu). */}
{isInvoiceDoc && item?.deduction_type && (
<div className="px-2 pb-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium tabular-nums text-muted-foreground">
{item?.deduction_type === 'rot' ? 'ROT 30%' : 'RUT 50%'}
</span>
<Controller
name={`items.${index}.work_type`}
control={control}
render={({ field: workField }) => {
const opts =
item?.deduction_type === 'rot' ? ROT_WORK_TYPES : RUT_WORK_TYPES
return (
<Select
value={workField.value ?? ''}
onValueChange={(v) => workField.onChange(v || null)}
>
<SelectTrigger
className="h-8 w-56"
aria-label={t('deduction_work_type_placeholder')}
aria-invalid={Boolean(errors.items?.[index]?.work_type) || undefined}
>
<SelectValue placeholder={t('deduction_work_type_placeholder')} />
</SelectTrigger>
<SelectContent>
{opts.map((w) => (
<SelectItem key={w.code} value={w.code}>
{w.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}}
/>
<Input
type="number"
step="0.5"
inputMode="decimal"
placeholder={t('deduction_hours_placeholder')}
className="h-8 w-32 text-right tabular-nums"
aria-label={t('deduction_hours_placeholder')}
aria-invalid={Boolean(errors.items?.[index]?.labor_hours) || undefined}
{...register(`items.${index}.labor_hours`, {
// valueAsNumber would override setValueAs and
// turn an emptied field into NaN, which the
// schema rejects with no visible error.
setValueAs: (v) => {
if (v === '' || v == null) return null
const n = Number(v)
return Number.isFinite(n) ? n : null
},
})}
/>
{(() => {
const amt = computeDeduction({
unit_price: item?.unit_price || 0,
quantity: item?.quantity || 0,
deduction_type: item?.deduction_type,
vat_rate: vatRegistered
? (item?.vat_rate ?? (vatRules?.rate || 25))
: 0,
})
return amt > 0 ? (
<span className="text-xs tabular-nums text-muted-foreground">
&minus;{formatCurrency(amt, watchCurrency)}
</span>
) : null
})()}
</div>
{(errors.items?.[index]?.work_type || errors.items?.[index]?.labor_hours) && (
<p className="mt-1 text-sm text-destructive">
{errors.items?.[index]?.work_type?.message ?? errors.items?.[index]?.labor_hours?.message}
</p>
)}
{/* Labor-only disclosure (Skatteverket
fakturamodellen), muted: the page's single
ochre line is the next-step line. */}
<div className="mt-2 flex items-start gap-2 text-xs text-muted-foreground">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>{t('deduction_labor_only_warning')}</p>
</div>
</div>
)}
{/* Periodisering (förutbetald intäkt): activated via
the row's ⋮ menu. */}
{accrualStripOpen && (
<div className="px-2 pb-3">
<AccrualPeriodControl
direction="revenue"
/* Entity type picks the regelverk the
5 000 kr hint cites: K1 for enskild
firma, K2 for aktiebolag. */
entityType={company?.entity_type}
amount={lineTotal}
/* The customer-invoice editor carries no FX rate
(the form has no exchange_rate field), so the
currency alone is passed: it keeps the preview
honest and suppresses the SEK-only K2 hint on
foreign-currency lines. */
currency={watchCurrency}
idPrefix={`accrual-invoice-${index}`}
value={{
start: item?.accrual_period_start ?? '',
end: item?.accrual_period_end ?? '',
balanceAccount:
item?.accrual_balance_account || DEFAULT_DEFERRED_REVENUE_ACCOUNT,
}}
onChange={(next) => {
setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true })
setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true })
}}
onRemove={() => toggleAccrual(index)}
/>
{errors.items?.[index]?.accrual_period_end && (
<p className="mt-1 text-sm text-destructive">
{errors.items[index].accrual_period_end?.message}
</p>
)}
</div>
)}
{/* Optional posting-account override (engångsartikel). */}
{accountStripOpen && (
<div className="px-2 pb-3">
<div className="max-w-sm space-y-1">
<Label className="text-xs text-muted-foreground">{t('revenue_account_label')}</Label>
<Controller
name={`items.${index}.revenue_account`}
control={control}
render={({ field: accountField }) => (
<AccountCombobox
value={accountField.value ?? ''}
accounts={postingAccounts}
onChange={(v) => accountField.onChange(v || null)}
/>
)}
/>
{errors.items?.[index]?.revenue_account && (
<p className="text-sm text-destructive">
{errors.items[index].revenue_account?.message}
</p>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
</div>
)}
{/* Per-item dimensions override (row ⋮ menu). */}
{dimensionStripOpen && (
<div className="px-2 pb-3">
<div className="max-w-md">
<LineDimensionFields
dimensions={item?.dimensions ?? undefined}
onChange={(dimNo, code) => updateItemDimension(index, dimNo, code)}
inputClassName="h-8"
/>
</div>
{hasDimensionValues(defaultDims) && (
<p className="mt-1 text-xs text-muted-foreground">
{t('row_dimensions_inherit_hint', { dims: compactDims(defaultDims) })}
</p>
)}
</div>
)}
</div>
</SortableRow>
)
})}
</Reorder.Group>
{/* Unified entry row: never part of the field array until
committed (sort_order and the index-keyed override sets
stay stable). Ghost cells preview the append defaults. */}
<div className="flex">
<div className="w-8 shrink-0" aria-hidden="true" />
<div className={cn(rowGridClass, 'flex-1 border-b border-border py-1')}>
<input
ref={entryInputRef}
value={entryQuery}
onChange={(e) => {
setEntryQuery(e.target.value)
setEntryActiveIdx(-1)
if (!entryOpen) setEntryOpen(true)
}}
onFocus={() => setEntryOpen(true)}
onBlur={() =>
window.setTimeout(() => {
setEntryOpen(false)
setEntryActiveIdx(-1)
}, 120)
}
onKeyDown={handleEntryKeyDown}
placeholder={t('entry_placeholder')}
autoComplete="off"
role="combobox"
aria-expanded={entryOpen}
aria-controls={entryOpen ? entryListId : undefined}
aria-autocomplete="list"
aria-activedescendant={
entryOpen && entryActiveIdx >= 0 && entryMatches[entryActiveIdx]
? `${entryListId}-opt-${entryActiveIdx}`
: undefined
}
aria-label={t('entry_aria')}
aria-describedby={entryOpen ? `${entryListId}-hint` : undefined}
className={cn(CELL_INPUT_CLASS, 'w-full')}
/>
<div className="whitespace-nowrap px-2 text-right text-[13px] italic text-muted-foreground/50 tabular-nums">
1 st
</div>
<div className="px-2 text-right text-[13px] italic text-muted-foreground/50 tabular-nums">0</div>
{vatRegistered && (
<div className="whitespace-nowrap px-2 text-[13px] italic text-muted-foreground/50 tabular-nums">
{vatRatePlan.defaultRate} %
</div>
)}
<div />
<div />
</div>
</div>
</div>
</div>
{/* Suggestion popover: anchored below the whole table wrap so it
never clips inside the horizontal scroll container. */}
{entryOpen && (
<div className="absolute left-0 right-0 top-full z-30 mt-1 overflow-hidden rounded-lg border border-input bg-card shadow-md">
{/* The hint is a sibling of the listbox (listbox children
must be options); the input references it via
aria-describedby. */}
<div id={entryListId} role="listbox" className="max-h-72 overflow-y-auto">
{entryMatches.map((a, i) => (
<button
key={a.id}
id={`${entryListId}-opt-${i}`}
type="button"
role="option"
aria-selected={i === entryActiveIdx}
tabIndex={-1}
className={cn(
'flex w-full items-baseline justify-between gap-3 px-3 py-2 text-left',
i === entryActiveIdx ? 'bg-secondary/60' : 'hover:bg-secondary/40',
)}
onMouseDown={(e) => {
e.preventDefault()
commitEntryArticle(a.id)
}}
onMouseEnter={() => setEntryActiveIdx(i)}
>
<span className="text-[13px]">
{a.article_number ? `${a.article_number}: ${a.name}` : a.name}
</span>
<span className="whitespace-nowrap text-xs text-muted-foreground tabular-nums">
{formatCurrency(Number(a.price_excl_vat) || 0, (a.currency as Currency) || watchCurrency)}/{a.unit || 'st'} &middot; {a.vat_rate} %
</span>
</button>
))}
</div>
<div
id={`${entryListId}-hint`}
className={cn(
'px-3 py-2 text-[11px] text-muted-foreground',
// Border only when a list renders above; alone it reads
// as a stray hairline at the top of the popover.
entryMatches.length > 0 && 'border-t border-border',
)}
>
{entryMatches.length > 0 ? t('entry_hint_matches') : t('entry_hint_free')}
</div>
</div>
)}
</div>
{itemsRootMsg && <p className="mt-2 text-sm text-destructive">{itemsRootMsg}</p>}
{!isSelfBilled && (
<button type="button" className={cn(QUIET_LINK_CLASS, 'mt-3 inline-block')} onClick={addTextRow}>
+ {t('add_text_row')}
</button>
)}
{/* Taxed-where-performed disclosure, muted: the page's single
ochre line is the next-step line (design decision d). */}
{showTaxedWherePerformedHint && (
<p className="mt-3 text-[12.5px] leading-5 text-muted-foreground">
{t('vat_taxed_where_performed_hint')}
</p>
)}
</section>
{/* ===== ROT/RUT claim info ===== */}
{isInvoiceDoc && hasAnyDeduction && (
<section className="mt-7 border-t border-border pt-7">
<SectionLabel>{t('deduction_card_title')}</SectionLabel>
<p className="-mt-1 mb-4 text-xs text-muted-foreground">{t('deduction_card_description')}</p>
<div className="max-w-md space-y-4">
<div className="space-y-2">
<Label htmlFor="deduction_personnummer">
{t('deduction_personnummer_label')}
{!(initial?.deduction_personnummer_last4 || customerHasPersonalNumber) && <RequiredMark />}
</Label>
<Input
id="deduction_personnummer"
placeholder={t('deduction_personnummer_placeholder')}
autoComplete="off"
{...register('deduction_personnummer')}
/>
<p className="text-xs text-muted-foreground">
{/* Stored pn exists only as ciphertext: an empty field on
edit keeps it server-side instead of failing validation.
Otherwise, a kundkort with a personnummer covers an
empty field via the server-side fallback. */}
{initial?.deduction_personnummer_last4
? storedPersonnummerMasked
? t('deduction_personnummer_kept_hint', { masked: storedPersonnummerMasked })
: t('deduction_personnummer_kept_hint_pending')
: customerHasPersonalNumber
? t('deduction_personnummer_customer_hint')
: t('deduction_personnummer_hint')}
</p>
</div>
{hasAnyRotLine && (
<div className="space-y-2">
<Label htmlFor="deduction_housing_designation">
{t('deduction_housing_label')}<RequiredMark />
</Label>
<Input
id="deduction_housing_designation"
placeholder={t('deduction_housing_placeholder')}
{...register('deduction_housing_designation')}
/>
<p className="text-xs text-muted-foreground">{t('deduction_housing_hint')}</p>
</div>
)}
{capWarnings.length > 0 && (
<div className="space-y-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
{capWarnings.map((w) => (
<p key={w}>{w}</p>
))}
</div>
)}
</div>
</section>
)}
{/* ===== Förval ===== */}
<section className="mt-7 border-t border-border pt-7">
<SectionLabel>{t('section_forval')}</SectionLabel>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-[13px] text-muted-foreground">
<span>{chipTexts.join(' · ')}</span>
<span aria-hidden="true">·</span>
<button
type="button"
className="whitespace-nowrap text-foreground underline underline-offset-4 transition-colors duration-150 hover:text-muted-foreground"
onClick={() => setSettingsOpen((open) => !open)}
aria-expanded={settingsOpen}
aria-controls={settingsPanelId}
>
{settingsOpen ? <>{t('close')} &#9652;</> : <>{t('forval_edit')} &#9662;</>}
</button>
</div>
<div
id={settingsPanelId}
className="grid transition-[grid-template-rows] duration-200 motion-reduce:transition-none"
style={{ gridTemplateRows: settingsOpen ? '1fr' : '0fr' }}
>
<div className={cn('min-h-0 overflow-hidden', !settingsOpen && 'invisible')} aria-hidden={!settingsOpen}>
<div className="mt-4 border-t border-border">
{!isSelfBilled && (
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">{t('document_type_label')}</Label>
<Controller
name="document_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="h-8 w-44 text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="invoice">{t('doctype_invoice')}</SelectItem>
<SelectItem value="proforma">{t('doctype_proforma')}</SelectItem>
<SelectItem value="delivery_note">{t('doctype_delivery_note')}</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
)}
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">{t('currency_label')}</Label>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="h-8 w-28 text-[13px] tabular-nums">
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency} value={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
{/* Self-billed mode renders fakturadatum and mottagningsdatum
uncollapsed next to the external number instead: they are
transcription fields there, and registering the same RHF
field twice would desync the inputs. */}
{!isSelfBilled && (
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
{t('invoice_date_label')}<RequiredMark />
</Label>
<div>
<Input
type="date"
{...register('invoice_date')}
aria-required="true"
className="h-8 w-40 text-[13px] tabular-nums"
/>
{errors.invoice_date && (
<p className="mt-1 text-xs text-destructive">{errors.invoice_date.message}</p>
)}
</div>
</div>
)}
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">
{t('due_date_label')}<RequiredMark />
</Label>
<div>
<Input
type="date"
{...register('due_date')}
aria-required="true"
className="h-8 w-40 text-[13px] tabular-nums"
/>
{errors.due_date && (
<p className="mt-1 text-xs text-destructive">{errors.due_date.message}</p>
)}
</div>
</div>
{watchDocumentType === 'invoice' && !isSelfBilled && (
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">{t('delivery_date_label')}</Label>
<Input
type="date"
{...register('delivery_date')}
className="h-8 w-40 text-[13px] tabular-nums"
/>
</div>
)}
{!isSelfBilled && (
<>
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">{t('our_reference_label')}</Label>
<div className="w-56">
<Controller
name="our_reference"
control={control}
render={({ field }) => (
<TagInput
value={field.value ?? ''}
onChange={field.onChange}
placeholder={t('our_reference_placeholder')}
/>
)}
/>
</div>
</div>
<div className={SETTINGS_ROW_CLASS}>
<Label className="text-[13px] font-normal">{t('your_reference_label')}</Label>
<div className="w-56">
<Controller
name="your_reference"
control={control}
render={({ field }) => (
<TagInput
value={field.value ?? ''}
onChange={field.onChange}
placeholder={t('your_reference_placeholder')}
/>
)}
/>
</div>
</div>
{/* Online payment link: manual paste or the Stripe auto
toggle. Only real invoices; hidden unless the company
opted in, except when the draft already carries a link. */}
{watchDocumentType === 'invoice' && (paymentLinksEnabled || hasExistingPaymentLink) && (
<div className="border-b border-border py-3 text-[13px]">
<Label htmlFor="payment_link_url" className="text-[13px] font-normal">
{t('payment_link_label')}
</Label>
<Input
id="payment_link_url"
type="url"
inputMode="url"
placeholder={t('payment_link_placeholder')}
className="mt-2 h-8 text-[13px]"
{...register('payment_link_url')}
/>
{errors.payment_link_url ? (
<p className="mt-1 text-sm text-destructive">{errors.payment_link_url.message}</p>
) : (
<p className="mt-1 text-xs text-muted-foreground">
{stripeConnected ? t('payment_link_hint_auto') : t('payment_link_hint')}
</p>
)}
{stripeConnected && !watchPaymentLinkUrl?.trim() && (
<div className="mt-2 flex items-center gap-2">
<Switch
id="payment_link_auto"
checked={watchPaymentLinkAuto ?? true}
onCheckedChange={(v) => setValue('payment_link_auto', v, { shouldDirty: true })}
/>
<Label
htmlFor="payment_link_auto"
className="text-sm font-normal text-muted-foreground"
>
{t('payment_link_auto_label')}
</Label>
</div>
)}
</div>
)}
{/* Invoice-level default dims (kostnadsställe/projekt). */}
{dimensionsEnabled && isInvoiceDoc && (
<div className="border-b border-border py-3">
<LineDimensionFields
dimensions={defaultDims}
onChange={setDefaultDimension}
inputClassName="h-8"
/>
</div>
)}
{/* Öresavrundning: display-only, SEK only. Edit mode's
draft flag wins over the company setting (state init). */}
{watchCurrency === 'SEK' && (
<div className="flex items-center justify-between gap-4 py-3 text-[13px]">
<div>
<Label htmlFor="ore-rounding" className="text-[13px] font-normal">
{t('ore_rounding_label')}
</Label>
<p className="text-xs text-muted-foreground">{t('ore_rounding_help')}</p>
</div>
<Switch
id="ore-rounding"
checked={oreRounding}
onCheckedChange={setOreRounding}
aria-label={t('ore_rounding_label')}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
</section>
{/* ===== Anteckningar ===== */}
<section className="mt-7 border-t border-border pt-7">
<SectionLabel>
{t('notes_card_title')}
<span className="ml-2 normal-case tracking-normal">{t('optional_label')}</span>
</SectionLabel>
<Textarea placeholder={t('notes_placeholder')} rows={2} className="min-h-16" {...register('notes')} />
</section>
{/* ===== Summering ===== */}
<section className="mt-7 border-t border-border pt-7">
<SectionLabel>{t('summary_card_title')}</SectionLabel>
<div className="text-[13px]">
<div className="flex items-baseline justify-between border-b border-border py-2">
<span className="text-muted-foreground">{t('subtotal_label')}</span>
<span className="tabular-nums">{formatCurrency(subtotal, watchCurrency)}</span>
</div>
{/* VAT rows: only when momsregistrerad. A non-registered company
shows no moms line at all (subtotal === total). */}
{vatRegistered &&
Array.from(vatByRate.entries())
.sort(([a], [b]) => b - a)
.map(([rate, group]) => (
<div key={rate}>
{vatByRate.size > 1 && (
<div className="flex items-baseline justify-between border-b border-border py-2">
<span className="text-muted-foreground">{t('net_at_rate', { rate })}</span>
<span className="tabular-nums">{formatCurrency(group.base, watchCurrency)}</span>
</div>
)}
{group.vat > 0 && (
<div className="flex items-baseline justify-between border-b border-border py-2">
<span className="text-muted-foreground">{t('vat_at_rate', { rate })}</span>
<span className="tabular-nums">{formatCurrency(group.vat, watchCurrency)}</span>
</div>
)}
</div>
))}
{vatRegistered && vatByRate.size === 0 && (
<div className="flex items-baseline justify-between border-b border-border py-2">
<span className="text-muted-foreground">{t('vat_label_short')}</span>
<span className="tabular-nums">{formatCurrency(0, watchCurrency)}</span>
</div>
)}
{displayRounding.applies && (
<div className="flex items-baseline justify-between border-b border-border py-2">
<span className="text-muted-foreground">{t('ore_rounding_label')}</span>
<span className="tabular-nums">{formatCurrency(displayRounding.roundingDelta, watchCurrency)}</span>
</div>
)}
{hasAnyDeduction && (
<div className="flex items-baseline justify-between border-b border-border py-2">
<span className="text-muted-foreground">{t('deduction_summary_label')}</span>
<span className="tabular-nums">&minus;{formatCurrency(deductionTotal, watchCurrency)}</span>
</div>
)}
<div className="flex items-baseline justify-between pt-4">
<span className="font-display text-xl">
{hasAnyDeduction ? t('to_pay_label') : t('total_label')}
</span>
<span className="font-display text-xl tabular-nums">
{formatCurrency(displayedToPay, watchCurrency)}
</span>
</div>
{hasAnyDeduction && (
<div className="mt-1 flex items-baseline justify-between text-xs text-muted-foreground">
<span>{t('total_incl_vat_label')}</span>
<span className="tabular-nums">{formatCurrency(total, watchCurrency)}</span>
</div>
)}
</div>
</section>
{/* Next step: the page's single ochre line, sage once everything
needed is in place. aria-live so the announcement follows the
form state without stealing focus. */}
<p
className={cn('mt-7 text-[13px]', nextStep.kind === 'ready' ? 'text-success' : 'text-attn')}
aria-live="polite"
>
{nextStep.kind === 'ready' ? (
nextStepText
) : (
<>
{t('next_step_prefix')}{' '}
<button
type="button"
className="underline underline-offset-4"
onClick={() => focusStep(nextStep)}
>
{nextStepText}
</button>
.
</>
)}
</p>
</div>
{/* Sticky action bar: position sticky, NEVER fixed (DialogContent's
transform re-anchors fixed children in bare mode). It binds to the
dialog scroll container in bare mode and to the page panel /
window otherwise; the mobile page offset clears the bottom nav. */}
<div
className={cn(
'sticky z-20 mt-7 border-t border-border bg-background',
bare ? 'bottom-0 px-6' : 'bottom-[calc(4rem+env(safe-area-inset-bottom,0px))] md:bottom-0',
)}
>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-3">
<div className="whitespace-nowrap text-[13px] text-muted-foreground">
{hasAnyDeduction ? t('to_pay_label') : t('total_label')}
<span className="ml-2 text-[15px] font-semibold tabular-nums text-foreground">
{formatCurrency(displayedToPay, watchCurrency)}
</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
{showDraftAction && (
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'disabled:cursor-not-allowed disabled:opacity-50')}
disabled={inFlight || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : t('save_as_draft_tooltip')}
onClick={handleSubmit(saveDraftData, onInvalidSubmit)}
>
{!canWrite && <Lock className="mr-1 inline h-3 w-3" />}
{isSavingDraft && <Loader2 className="mr-1 inline h-3 w-3 animate-spin" />}
{t('save_as_draft')}
</button>
)}
{!isSelfBilled && !isEditMode && (
<Button
type="button"
variant="outline"
disabled={isPreviewing || inFlight}
onClick={handleSubmit((data) => handlePreviewPDF(data), onInvalidSubmit)}
>
{isPreviewing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Eye className="mr-2 h-4 w-4" />
)}
{isPreviewing ? t('preview_pdf_generating') : t('preview_pdf')}
</Button>
)}
<Button
type="submit"
disabled={inFlight || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
{isFormSubmitting && !isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryLabel}
</Button>
</div>
</div>
</div>
</form>
{selectedCustomer && vatRules && (
<ConfirmationDialog
open={showReview}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title={watchDocumentType === 'proforma'
? t('review_dialog_title_proforma')
: watchDocumentType === 'delivery_note'
? t('review_dialog_title_delivery_note')
: t('review_dialog_title_invoice')}
warningText={watchDocumentType === 'invoice'
? accountingMethod === 'cash'
? t('review_warning_invoice_cash')
: t('review_warning_invoice_accrual')
: watchDocumentType === 'proforma'
? t('review_warning_proforma')
: t('review_warning_delivery_note')}
confirmLabel={watchDocumentType === 'proforma'
? t('confirm_create_proforma')
: watchDocumentType === 'delivery_note'
? t('confirm_create_delivery_note')
: t('confirm_create_invoice')}
extraActions={
<Button
variant="outline"
onClick={() => handlePreviewPDF()}
disabled={isPreviewing || isSubmitting}
>
{isPreviewing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Eye className="mr-2 h-4 w-4" />
)}
{isPreviewing ? t('preview_pdf_generating') : t('preview_pdf')}
</Button>
}
>
<InvoiceReviewContent
customer={selectedCustomer}
invoiceDate={pendingData?.invoice_date || ''}
dueDate={pendingData?.due_date || ''}
currency={(pendingData?.currency || 'SEK') as Currency}
items={(pendingData?.items || []).map((item) => ({
...item,
vat_rate: vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0,
}))}
subtotal={subtotal}
vatAmount={vatAmount}
total={total}
yourReference={pendingData?.your_reference}
ourReference={pendingData?.our_reference}
notes={pendingData?.notes}
numberPreview={numberPreview}
oreRounding={oreRounding}
vatRegistered={vatRegistered}
paymentLink={paymentLinkMode}
/>
</ConfirmationDialog>
)}
{/* Create customer dialog */}
<Dialog open={isCreateCustomerOpen} onOpenChange={setIsCreateCustomerOpen}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('create_customer_dialog_title')}</DialogTitle>
</DialogHeader>
<CustomerForm
onSubmit={handleCreateCustomer}
isLoading={isCreatingCustomer}
/>
</DialogContent>
</Dialog>
{/* Bank details setup dialog */}
<BankDetailsSetupDialog
open={showBankSetup}
onOpenChange={setShowBankSetup}
onComplete={handleBankSetupComplete}
/>
{/* First-invoice logo prompt (issue #520) */}
<FirstInvoiceLogoPrompt
open={showLogoPrompt}
onClose={handleLogoPromptClose}
logoUrl={logoUrl}
onLogoUpdate={(url) => setLogoUrl(url)}
/>
{/* Send now prompt dialog */}
<Dialog open={showSendPrompt} onOpenChange={(open) => {
if (!open && createdInvoiceId) {
setShowSendPrompt(false)
router.replace(`/invoices/${createdInvoiceId}`)
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('send_now_dialog_title')}</DialogTitle>
{/* data-ph-mask: the customer email is user data */}
<DialogDescription data-ph-mask="">
{t('send_now_dialog_description', { email: selectedCustomer?.email ?? '' })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowSendPrompt(false)
if (createdInvoiceId) router.replace(`/invoices/${createdInvoiceId}`)
}}
disabled={isSending}
>
{t('send_later')}
</Button>
<Button onClick={handleSendNow} disabled={isSending}>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{isSending ? t('send_now_sending') : t('send_now')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}