'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 { computeLineNet, hasLineDiscount } from '@/lib/invoices/line-amounts' 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, Percent } 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 { useBranding } from '@/lib/branding/brand-context' 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 } /** Uppercase hairline section label (the prototype's .seclabel). */ function SectionLabel({ children }: { children: React.ReactNode }) { return (
{children}
) } // 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 { 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') const { appName } = useBranding() // 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(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(), // Rabatt i procent per rad (⋮ menu). null = no discount. discount_percent: z .number() .min(0, t('validation_discount_range')) .max(100, t('validation_discount_range')) .nullable() .optional(), vat_rate: z.number().min(0).max(25), // Article linkage (artikelregister). Optional: free-text lines omit them. article_id: z.string().nullable().optional(), // Kundorder line link: an invoice created from a sales order carries it // per item; the draft editor replaces items wholesale on save, so the // field must round-trip or the order line becomes re-invoiceable. sales_order_item_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(), invoice_marking: 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 // 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(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(null) const [showReview, setShowReview] = useState(false) const [pendingData, setPendingData] = useState(null) const [createdInvoiceId, setCreatedInvoiceId] = useState(null) const [showSendPrompt, setShowSendPrompt] = useState(false) const [isSending, setIsSending] = useState(false) const [isPreviewing, setIsPreviewing] = useState(false) const [, setDefaultNotes] = useState(null) const [isCreateCustomerOpen, setIsCreateCustomerOpen] = useState(false) const [isCreatingCustomer, setIsCreatingCustomer] = useState(false) const [hasBankDetails, setHasBankDetails] = useState(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( typeof initialOreRounding === 'boolean' ? initialOreRounding : true, ) const [vatRegistered, setVatRegistered] = useState(true) const [numberPreview, setNumberPreview] = useState(null) const [logoUrl, setLogoUrl] = useState(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(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>(new Set()) // Rabatt per rad: rows whose discount strip is open (⋮ menu), same // lifecycle as the account override above. A stored discount also opens it. const [discountRows, setDiscountRows] = useState>(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>( initial?.default_dimensions ?? copyInitial?.default_dimensions ?? {}, ) const [dimensionOverrideRows, setDimensionOverrideRows] = useState>(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>(new Set()) const [entryQuery, setEntryQuery] = useState('') const [entryOpen, setEntryOpen] = useState(false) const [entryActiveIdx, setEntryActiveIdx] = useState(-1) const [settleIndex, setSettleIndex] = useState(null) const entryInputRef = useRef(null) const customerTriggerRef = useRef(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(null) const [showLogoPrompt, setShowLogoPrompt] = useState(false) const pendingCustomerRef = useRef(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(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({ 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 ?? '', invoice_marking: initial.invoice_marking ?? '', 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, discount_percent: hasLineDiscount(item.discount_percent) ? item.discount_percent : null, vat_rate: item.vat_rate ?? 25, article_id: item.article_id ?? null, sales_order_item_id: item.sales_order_item_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, invoice_marking: '', 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 watchInvoiceMarking = watch('invoice_marking') 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, discount_percent: null, vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0, article_id: null, sales_order_item_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, discount_percent: null, vat_rate: 0, article_id: null, sales_order_item_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) { 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 + computeLineNet(item.quantity || 0, item.unit_price || 0, item.discount_percent) }, 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() let vatAmount = 0 for (const item of watchItems) { const rate = vatRegistered ? (item.vat_rate ?? (vatRules?.rate || 25)) : 0 const lineTotal = computeLineNet(item.quantity || 0, item.unit_price || 0, item.discount_percent) 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(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, discount_percent: item.discount_percent, 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 per-line discount (⋮ menu). Closing clears the value so // the row books at full price again. function toggleDiscount(index: number) { const isOpen = discountRows.has(index) || hasLineDiscount(watchItems[index]?.discount_percent) if (isOpen) { setValue(`items.${index}.discount_percent`, null, { shouldDirty: true, shouldValidate: true }) setDiscountRows((prev) => { const next = new Set(prev) next.delete(index) return next }) } else { setDiscountRows((prev) => new Set(prev).add(index)) window.setTimeout(() => setFocus(`items.${index}.discount_percent`), 0) } } // 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) { 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, invoice_marking: data.invoice_marking, 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', { appName }), 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, 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 || '', invoiceMarking: watchInvoiceMarking || '', 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 'invoice_marking': return t('chip_invoice_marking', { marking: chip.marking }) 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 (
{!bare && ( )} {titleText} {numberPreview && !isSelfBilled && ( {numberPreview} )}
{isCopyMode && copyInitial && (

{t('copy_notice', { number: copyInitial.source_invoice_number })}

)} {hasBankDetails === false && !isSelfBilled && (

{t('bank_missing_warning')}

)}
{/* ===== Kund ===== */}
{isSelfBilled ? ts('customer_label') : t('customer_card_title')} {selectedCustomer && ( ✓ {t('customer_done')} )} {isSelfBilled && (

{ts('issuer_card_description')}

)} ( )} /> {selectedCustomer && (
{[ [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) => (
{line}
))}
)} {errors.customer_id && (

{errors.customer_id.message}

)} {isSelfBilled && (
{errors.external_invoice_number && (

{errors.external_invoice_number.message}

)}
{/* 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). */}
{errors.invoice_date && (

{errors.invoice_date.message}

)}
{errors.received_date && (

{errors.received_date.message}

)}
)}
{/* ===== Fakturarader ===== */}
{t('items_card_title')} {productRowCount > 0 && ( {t('rows_count', { count: productRowCount })} )}
{/* Header row: offset by the drag-grip gutter (w-8). */}
{t('description_label')}
{t('quantity_label')}
{t('unit_price_label')}
{vatRegistered &&
{t('vat_label')}
}
{t('amount_label')}
{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 (
) } const lineTotal = computeLineNet( item?.quantity || 0, item?.unit_price || 0, item?.discount_percent, ) 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)) // Not offered for a received självfaktura: the self-billed // endpoint's reduced item shape carries no discount, so a // previewed rebate would silently book gross. const discountStripOpen = !isSelfBilled && (discountRows.has(index) || hasLineDiscount(item?.discount_percent)) 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 (
( )} />
{vatRegistered && ( ( )} /> )}
{formatCurrency(lineTotal, watchCurrency)}
toggleArticlePicker(index)} className="py-2"> {t('row_menu_pick_article')} {!isSelfBilled && ( <> toggleDiscount(index)} className="py-2"> {discountStripOpen ? t('row_menu_remove_discount') : t('row_menu_add_discount')} )} {isInvoiceDoc && ( <> {t('deduction_menu_label')} { 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) } }} > {t('deduction_none')} {t('deduction_rot')} {t('deduction_rut')} )} {canUseAccrual && !item?.deduction_type && ( <> toggleAccrual(index)} className="py-2"> {item?.accrual_balance_account != null ? ta('row_menu_remove') : ta('row_menu_add')} )} {isInvoiceDoc && ( <> toggleAccountOverride(index)} className="py-2"> {(accountOverrideRows.has(index) || item?.revenue_account) ? t('row_menu_remove_account') : t('row_menu_set_account')} )} {dimensionsEnabled && isInvoiceDoc && ( <> toggleItemDimensions(index)} className="py-2"> {(dimensionOverrideRows.has(index) || hasDimensionValues(item?.dimensions)) ? t('row_menu_remove_dimensions') : t('row_menu_set_dimensions')} )}
{rowErrorMsg &&

{rowErrorMsg}

} {showSaveAsArticle && (
)} {/* Article re-link strip (row ⋮ menu): article selection stays reachable on every committed row; "Egen rad" detaches the link. */} {articleStripOpen && (
( { applyArticle(index, v) toggleArticlePicker(index) }} freeTextLabel={t('article_free_text')} placeholder={t('article_placeholder')} emptyLabel={t('article_search_empty')} ariaLabel={t('article_label')} /> )} />
)} {/* Rabatt strip: opened via the ⋮ menu; a stored discount keeps it open in edit mode. */} {discountStripOpen && (
{ if (v === '' || v == null) return null const n = Number(v) return Number.isFinite(n) ? n : null }, })} /> %
{hasLineDiscount(item?.discount_percent) && ( −{formatCurrency( roundOre((item?.quantity || 0) * (item?.unit_price || 0)) - lineTotal, watchCurrency, )} )}
{rowErrors?.discount_percent && (

{rowErrors.discount_percent.message}

)}
)} {/* ROT/RUT-avdrag strip: only when a deduction is active on this row (chosen via the ⋮ menu). */} {isInvoiceDoc && item?.deduction_type && (
{item?.deduction_type === 'rot' ? 'ROT 30%' : 'RUT 50%'} { const opts = item?.deduction_type === 'rot' ? ROT_WORK_TYPES : RUT_WORK_TYPES return ( ) }} /> { 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, discount_percent: item?.discount_percent, deduction_type: item?.deduction_type, vat_rate: vatRegistered ? (item?.vat_rate ?? (vatRules?.rate || 25)) : 0, }) return amt > 0 ? ( −{formatCurrency(amt, watchCurrency)} ) : null })()}
{(errors.items?.[index]?.work_type || errors.items?.[index]?.labor_hours) && (

{errors.items?.[index]?.work_type?.message ?? errors.items?.[index]?.labor_hours?.message}

)} {/* Labor-only disclosure (Skatteverket fakturamodellen), muted: the page's single ochre line is the next-step line. */}

{t('deduction_labor_only_warning')}

)} {/* Periodisering (förutbetald intäkt): activated via the row's ⋮ menu. */} {accrualStripOpen && (
{ 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 && (

{errors.items[index].accrual_period_end?.message}

)}
)} {/* Optional posting-account override (engångsartikel). */} {accountStripOpen && (
( accountField.onChange(v || null)} /> )} /> {errors.items?.[index]?.revenue_account && (

{errors.items[index].revenue_account?.message}

)}

{t('revenue_account_hint')}

)} {/* Per-item dimensions override (row ⋮ menu). */} {dimensionStripOpen && (
updateItemDimension(index, dimNo, code)} inputClassName="h-8" />
{hasDimensionValues(defaultDims) && (

{t('row_dimensions_inherit_hint', { dims: compactDims(defaultDims) })}

)}
)}
) })}
{/* 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. */}
{/* Suggestion popover: anchored below the whole table wrap so it never clips inside the horizontal scroll container. */} {entryOpen && (
{/* The hint is a sibling of the listbox (listbox children must be options); the input references it via aria-describedby. */}
{entryMatches.map((a, i) => ( ))}
0 && 'border-t border-border', )} > {entryMatches.length > 0 ? t('entry_hint_matches') : t('entry_hint_free')}
)}
{itemsRootMsg &&

{itemsRootMsg}

} {!isSelfBilled && ( )} {/* Taxed-where-performed disclosure, muted: the page's single ochre line is the next-step line (design decision d). */} {showTaxedWherePerformedHint && (

{t('vat_taxed_where_performed_hint')}

)}
{/* ===== ROT/RUT claim info ===== */} {isInvoiceDoc && hasAnyDeduction && (
{t('deduction_card_title')}

{t('deduction_card_description')}

{/* 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')}

{hasAnyRotLine && (

{t('deduction_housing_hint')}

)} {capWarnings.length > 0 && (
{capWarnings.map((w) => (

{w}

))}
)}
)} {/* ===== Förval ===== */}
{t('section_forval')}
{chipTexts.join(' · ')}
{!isSelfBilled && (
( )} />
)}
( )} />
{/* 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 && (
{errors.invoice_date && (

{errors.invoice_date.message}

)}
)}
{errors.due_date && (

{errors.due_date.message}

)}
{watchDocumentType === 'invoice' && !isSelfBilled && (
)} {!isSelfBilled && ( <>
( )} />
( )} />
{/* Fakturamärkning: one buyer-required marking string (kostnadsställe/projekt/PO), separate from Er referens. Plain input, never comma-split. */}
{/* 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) && (
{errors.payment_link_url ? (

{errors.payment_link_url.message}

) : (

{stripeConnected ? t('payment_link_hint_auto') : t('payment_link_hint')}

)} {stripeConnected && !watchPaymentLinkUrl?.trim() && (
setValue('payment_link_auto', v, { shouldDirty: true })} />
)}
)} {/* Invoice-level default dims (kostnadsställe/projekt). */} {dimensionsEnabled && isInvoiceDoc && (
)} {/* Öresavrundning: display-only, SEK only. Edit mode's draft flag wins over the company setting (state init). */} {watchCurrency === 'SEK' && (

{t('ore_rounding_help')}

)} )}
{/* ===== Anteckningar ===== */}
{t('notes_card_title')} {t('optional_label')}