'use client' import { useState, useEffect, useRef, useMemo } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { useForm, useFieldArray, Controller } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 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 { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' import { getVatRules } from '@/lib/invoices/vat-rules' import { resolveLineVatRates, planCustomerSwitchVatSnap, hasSwedishVatToForeignBusiness, FALLBACK_VAT_RATE, } from '@/components/invoices/line-vat-rates' import { AttnLine } from '@/components/ui/attn-line' import { sortArticles } from '@/lib/articles/sort' import { getAmountToPay } from '@/lib/invoices/rounding' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, 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 { CAPABILITY } from '@/lib/entitlements/keys' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import { ROT_WORK_TYPES, RUT_WORK_TYPES, ROT_MAX, RUT_MAX, computeDeduction, } from '@/lib/invoices/rot-rut-rules' 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 type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } 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' > function RequiredMark() { return } // True when a dimensions bag ({sie_dim_no: code}) carries at least one value. function hasDimensionValues(dims: Record | null | undefined): boolean { return !!dims && Object.keys(dims).length > 0 } // 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') // Toggle between a normal customer invoice (default) and registering a // self-billing invoice we received (mottagen självfaktura, ML 17 kap 15§). // Self-billing is never available when editing an existing draft. const [mode, setMode] = useState<'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) // 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]) 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'), }) } } 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 const [customers, setCustomers] = useState([]) const [isLoading, setIsLoading] = useState(true) 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, setArticles] = useState([]) 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 [postingAccounts, setPostingAccounts] = useState([]) const [accountOverrideRows, setAccountOverrideRows] = 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()) // 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, 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 ?? '', 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: '', items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25, 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, }], }, }) 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 // 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 (!company?.id) return fetchCustomers() fetchDefaultNotes() fetchArticles() fetchRevenueAccounts() }, [company?.id]) async function fetchArticles() { if (!company?.id) return const { data } = await supabase .from('articles') .select('id, article_number, name, unit, price_excl_vat, vat_rate, revenue_account, currency') .eq('company_id', company.id) .eq('active', true) // Numeric-aware order by article number ('2' before '10', unnumbered last): // the picker should follow the user's own numbering, not the alphabet. setArticles(sortArticles((data ?? []) as ArticleOption[])) } async function fetchRevenueAccounts() { if (!company?.id) return try { const res = await fetch('/api/bookkeeping/accounts') const body = await res.json() const accounts = ((body?.data as BASAccount[]) || []) .filter((account) => account.account_class >= 1 && account.account_class <= 3) setPostingAccounts(accounts) } catch { // Non-fatal: the override picker degrades to free 4-digit entry. } } // 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 }) // 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'), }), }) const result = await response.json() if (!response.ok) { throw new Error(getErrorMessage(result, { context: 'article', statusCode: response.status })) } const created = result.data as ArticleOption setArticles((prev) => sortArticles([...prev, created])) 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) } } async function fetchDefaultNotes() { if (!company?.id) return const { data } = await supabase .from('company_settings') .select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered, dimensions_enabled, invoice_payment_links_enabled') .eq('company_id', company.id) .single() 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) } // 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 fetchCustomers() { if (!company?.id) return const { data, error } = await supabase .from('customers') .select('*') .eq('company_id', company.id) .order('name', { ascending: true }) if (error) { toast({ title: t('load_customers_failed_title'), description: t('load_customers_failed_description'), variant: 'destructive', }) } else { setCustomers(data || []) } setIsLoading(false) } 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 setCustomers(prev => [...prev, result.data]) 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 shown the description // keeps its 3/12 width; when hidden it widens to fill the freed columns. const descColSpan = vatRegistered ? 'md:col-span-3' : 'md:col-span-5' // 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 = (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 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 const hasAnyDeduction = deductionTotal > 0 const hasAnyRotLine = isInvoiceDoc && watchItems.some((i) => i.deduction_type === 'rot') // Ö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 }) } // Per-item bags ride the payload only when they carry values: the server // treats an absent bag as "inherit the invoice's default_dimensions". function pruneItemDimensions | null }>( items: T[], ): T[] { return items.map((item) => hasDimensionValues(item.dimensions) ? item : { ...item, dimensions: undefined }, ) } // The form always carries the self-billing fields (they default to '' in both // create and edit mode). This editor's normal create/draft/edit flows never // use self-billing, that goes through the dedicated /api/invoices/self-billed // path, so drop these empty carriers before spreading the form data into the // /api/invoices (or PATCH) body: a bare external_invoice_number: '' otherwise // trips the shared CreateInvoiceSchema's min(1). Belt-and-suspenders; the // server schema also coerces '' to undefined for these fields. function stripSelfBillingFields(data: FormData): FormData { const { external_invoice_number: _ein, self_billing_agreement_ref: _sbar, received_date: _rd, ...rest } = data return rest } // Self-billing path: no review dialog, no PDF, no send: it arrives already // booked. POST straight to the dedicated endpoint and open the verifikat. 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({ customer_id: data.customer_id, external_invoice_number: data.external_invoice_number, self_billing_agreement_ref: data.self_billing_agreement_ref || undefined, invoice_date: data.invoice_date, received_date: data.received_date, due_date: data.due_date, currency: data.currency, notes: data.notes, items: data.items.map((i) => ({ description: i.description, quantity: i.quantity, unit: i.unit, unit_price: i.unit_price, vat_rate: i.vat_rate, })), }), }) 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) // Privacy by default: ROT/RUT line fields and the invoice-level // personnummer / housing designation are only sent to the API when the // user actually claims a deduction. Defaults are pre-instantiated as // null in the form state, but null personal-data fields shouldn't ride // along on every regular invoice. const anyDeduction = pendingData.items.some((i) => i.deduction_type) const sanitizedItems = pruneItemDimensions(pendingData.items).map((item) => { if (item.deduction_type) return item const { deduction_type: _dt, labor_hours: _lh, work_type: _wt, housing_designation: _hd, apartment_number: _an, brf_org_number: _bn, ...rest } = item return rest }) const sanitizedPayload: CreateInvoiceInput & { default_dimensions: Record } = { ...(stripSelfBillingFields(pendingData) as CreateInvoiceInput), ore_rounding: oreRounding, // Invoice-level default dims: always sent so an edited draft can clear // them; {} means "no defaults". default_dimensions: defaultDims, items: sanitizedItems as CreateInvoiceInput['items'], ...(anyDeduction ? {} : { deduction_personnummer: undefined, deduction_housing_designation: undefined }), } 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) } } // "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 anyDeduction = data.items.some((i) => i.deduction_type) const sanitizedItems = pruneItemDimensions(data.items).map((item) => { if (item.deduction_type) return item const { deduction_type: _dt, labor_hours: _lh, work_type: _wt, housing_designation: _hd, apartment_number: _an, brf_org_number: _bn, ...rest } = item return rest }) const payload: CreateInvoiceInput & { default_dimensions: Record } = { ...(stripSelfBillingFields(data) as CreateInvoiceInput), save_as_draft: true, ore_rounding: oreRounding, default_dimensions: defaultDims, items: sanitizedItems as CreateInvoiceInput['items'], ...(anyDeduction ? {} : { deduction_personnummer: undefined, deduction_housing_designation: undefined }), } 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 anyDeduction = data.items.some((i) => i.deduction_type) const sanitizedItems = pruneItemDimensions(data.items).map((item) => { if (item.deduction_type) return item const { deduction_type: _dt, labor_hours: _lh, work_type: _wt, housing_designation: _hd, apartment_number: _an, brf_org_number: _bn, ...rest } = item return rest }) const payload: CreateInvoiceInput & { default_dimensions: Record } = { ...(stripSelfBillingFields(data) as CreateInvoiceInput), ore_rounding: oreRounding, default_dimensions: defaultDims, items: sanitizedItems as CreateInvoiceInput['items'], ...(anyDeduction ? {} : { deduction_personnummer: undefined, deduction_housing_designation: undefined }), } 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}`) } } async function handlePreviewPDF() { if (!pendingData) 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: pendingData.customer_id, invoice_date: pendingData.invoice_date, due_date: pendingData.due_date, currency: pendingData.currency, document_type: pendingData.document_type, items: pendingData.items, your_reference: pendingData.your_reference, our_reference: pendingData.our_reference, notes: pendingData.notes, payment_link_url: pendingData.payment_link_url, invoice_number: numberPreview, }), }) 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) } } if (isLoading) { return (
) } 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') const subtitleText = isEditMode ? t('subtitle_edit') : isCopyMode ? t('subtitle_copy') : isSelfBilled ? ts('subtitle') : watchDocumentType === 'proforma' ? t('subtitle_proforma') : watchDocumentType === 'delivery_note' ? t('subtitle_delivery_note') : t('subtitle_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' return (
{!bare && ( )}
{titleText} {numberPreview && !isSelfBilled && ( ({numberPreview}) )} {!bare &&

{subtitleText}

}
{isCopyMode && copyInitial && (

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

)} {!isEditMode && !isCopyMode && ( setMode(v as 'invoice' | 'self_billed')}> {t('mode_invoice')} {t('mode_self_billed')} )} {hasBankDetails === false && !isSelfBilled && (

{t('bank_missing_warning')}

)}
{/* Main content */}
{/* Customer selection */} {isSelfBilled ? <>{ts('customer_label')} : <>{t('customer_card_title')}} {isSelfBilled && {ts('issuer_card_description')}} ( )} /> {errors.customer_id && (

{errors.customer_id.message}

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

{errors.external_invoice_number.message}

)}
)}
{/* Invoice items */} {t('items_card_title')} {t('items_card_description')}
{fields.map((field, index) => { const isTextRow = watchItems[index]?.line_type === 'text' const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0) const lineVat = vatRegistered && !isTextRow ? Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100 : 0 // Free-text / blank row: just a description field (may be left // empty for a spacer) and a delete button. if (isTextRow) { return (
) } // Per-row action button. On real invoices it's a ⋮ menu that // holds both the ROT/RUT skattereduktion choice and delete; // proformas/delivery notes have no deduction model, so they // keep a plain trash button (a one-item menu would be noise). const renderRowActions = (triggerClassName: string) => isInvoiceDoc ? ( {t('deduction_menu_label')} { const next = v === 'none' ? null : (v as 'rot' | 'rut') setValue(`items.${index}.deduction_type`, next, { shouldDirty: true }) 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 (watchItems[index]?.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 && !watchItems[index]?.deduction_type && ( <> toggleAccrual(index)} className="py-2"> {watchItems[index]?.accrual_balance_account != null ? ta('row_menu_remove') : ta('row_menu_add')} )} {watchItems[index]?.line_type !== 'text' && ( <> toggleAccountOverride(index)} className="py-2"> {(accountOverrideRows.has(index) || watchItems[index]?.revenue_account) ? t('row_menu_remove_account') : t('row_menu_set_account')} )} {dimensionsEnabled && watchItems[index]?.line_type !== 'text' && ( <> toggleItemDimensions(index)} className="py-2"> {(dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)) ? t('row_menu_remove_dimensions') : t('row_menu_set_dimensions')} )} remove(index)} > {t('remove_row')} ) : ( ) return (
{/* Article picker (artikelregister). Optional: leave on "Egen rad" to type a free-text line. Selecting an article pre-fills description, unit, price, VAT and any revenue-account override. */}
( )} />
{canWrite && ( )}
{/* Description + mobile delete button */}
{errors.items?.[index]?.description && (

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

)}
{renderRowActions('shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden')}
{/* Antal, Enhet, à-pris */}
( )} />
{/* Moms: hidden entirely when the company is not momsregistrerad (no VAT may be charged). */} {vatRegistered && (
( )} />
)} {/* Desktop row actions (⋮ menu or trash). An invisible label spacer mirrors the field columns (same Label + space-y-2), so the button sits on the input row, not high against the labels, nor low at the row bottom. */}
{renderRowActions('')}
{/* ROT/RUT-avdrag strip: only when a deduction is active on this row (chosen via the ⋮ menu). A leading tag shows which reduction applies; the work-type + hours are required for the Skatteverket claim. Rows with no deduction render nothing here and stay clean. */} {isInvoiceDoc && watchItems[index]?.deduction_type && (
{watchItems[index]?.deduction_type === 'rot' ? 'ROT 30%' : 'RUT 50%'} { const opts = watchItems[index]?.deduction_type === 'rot' ? ROT_WORK_TYPES : RUT_WORK_TYPES return ( ) }} /> v === '' || Number.isNaN(v) ? null : Number(v), })} /> {(() => { const amt = computeDeduction({ unit_price: watchItems[index]?.unit_price || 0, quantity: watchItems[index]?.quantity || 0, deduction_type: watchItems[index]?.deduction_type, vat_rate: vatRegistered ? (watchItems[index]?.vat_rate ?? (vatRules?.rate || 25)) : 0, }) return amt > 0 ? ( −{formatCurrency(amt, watchCurrency)} ) : null })()}
{/* Labor-only disclosure (Skatteverket fakturamodellen). 30%/50% applies to the full line total: the seller must ensure the line is 100% labor; material has to be invoiced separately. */}

{t('deduction_labor_only_warning')}

)} {/* Periodisering (förutbetald intäkt): activated via the row's ⋮ menu. Intäkten krediteras 29xx vid bokning och löses upp månadsvis över perioden; momsen påverkas inte. */} {canUseAccrual && watchItems[index]?.accrual_balance_account != null && (
{ 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). When unset the engine derives the revenue account from the VAT rate; reverse-charge/export lines ignore the override. */} {isInvoiceDoc && watchItems[index]?.line_type !== 'text' && (accountOverrideRows.has(index) || watchItems[index]?.revenue_account) && (
( field.onChange(v || null)} /> )} /> {errors.items?.[index]?.revenue_account && (

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

)}

{t('revenue_account_hint')}

)} {/* Per-item dimensions override (dimensions PR7): opened via the row's ⋮ menu. The bag is stored as-is; the server merges it over the invoice's default_dimensions for this item's revenue line at booking time. */} {dimensionsEnabled && isInvoiceDoc && watchItems[index]?.line_type !== 'text' && (dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)) && (
updateItemDimension(index, dimNo, code)} inputClassName="h-8" />
{hasDimensionValues(defaultDims) && (

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

)}
)} {/* Mobile summary row */}
{t('row_label', { index: index + 1 })} {formatCurrency(lineTotal + lineVat, watchCurrency)}
) })}
{/* 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: that is a faithful revenue-only transcription, and the self-billed endpoint (SelfBillingInvoiceItemSchema) has no line_type and rejects zero-amount rows. */} {!isSelfBilled && ( )}
{/* Attention is one ochre sentence, not a banner (UI convention 6). Silent for the normal 0% case; renders only when a Swedish rate is actually picked for a customer whose default is 0%, where it is lawful for taxed-where-performed supplies only. */} {showTaxedWherePerformedHint && ( {t('vat_taxed_where_performed_hint')} )}
{/* ROT/RUT-avdrag claim info. Surfaces only when any item has a deduction_type set: keeps the form quiet for the 90%+ of users who don't sell ROT/RUT-eligible services. */} {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. */} {initial?.deduction_personnummer_last4 ? t('deduction_personnummer_kept_hint', { last4: initial.deduction_personnummer_last4 }) : t('deduction_personnummer_hint')}

{hasAnyRotLine && (

{t('deduction_housing_hint')}

)} {(deductionByKind.rot > ROT_MAX || deductionByKind.rut > RUT_MAX) && (
{t('deduction_cap_over')} {deductionByKind.rot > ROT_MAX && ` (ROT ${ROT_MAX.toLocaleString('sv-SE')} kr)`} {deductionByKind.rut > RUT_MAX && ` (RUT ${RUT_MAX.toLocaleString('sv-SE')} kr)`} {'. '} {t('deduction_cap_check')}
)}
)} {/* Notes */} {t('notes_card_title')}