'use client' import { Fragment, useState, useEffect, useRef, useCallback, type ReactNode } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { useForm, Controller, useFieldArray } from 'react-hook-form' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import AiFilledIndicator from '@/components/ui/ai-filled-indicator' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import { useBasReference } from '@/lib/bookkeeping/use-bas-reference' import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' import { getErrorMessage } from '@/lib/errors/get-error-message' import { exceedsHostedUploadLimit } from '@/lib/documents/upload-size' import { uploadViaSignedUrl } from '@/lib/documents/direct-upload' import { cn, formatAmount, formatCurrency, formatDate } from '@/lib/utils' import { getDisplayTotal } from '@/lib/invoices/rounding' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCanWrite } from '@/lib/hooks/use-can-write' import BankTransactionPicker from '@/components/transactions/BankTransactionPicker' import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl' import LineDimensionFields from '@/components/dimensions/LineDimensionFields' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions' import { findReverseChargeAccountWarningRows, findUnflaggedForeignZeroVatRows, } from '@/lib/vat/supplier-invoice-line-checks' import { SLP_RATE, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines' import { roundOre } from '@/lib/money' import { buildSupplierInvoicePayload, type SupplierInvoiceFormData, type SupplierInvoiceLineItem, } from '@/lib/supplier-invoices/form-payload' import { plantedRowTouched, snapshotPlantedRow } from '@/lib/supplier-invoices/planted-rows' import { VatRateCell, RcRateSelect, AmountCell } from '@/components/supplier-invoices/supplier-invoice-cells' import { useSupplierInvoiceData } from '@/components/supplier-invoices/use-supplier-invoice-data' import { useInboxPrefill, type InboxItemData } from '@/components/supplier-invoices/use-inbox-prefill' import { useSupplierInvoiceSubmit } from '@/components/supplier-invoices/use-supplier-invoice-submit' import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, Link2, CalendarClock, Tags, FileText } from 'lucide-react' import type { Supplier, InvoiceExtractionResult } from '@/types' // The form's line/field shapes live in lib/supplier-invoices/form-payload.ts // next to the payload builder they feed, pinned there by parity tests. type FormData = SupplierInvoiceFormData interface NewSupplierForm { name: string supplier_type: string org_number: string vat_number: string address_line1: string bankgiro: string plusgiro: string default_expense_account: string } function RequiredMark() { return } // Accepts sv-SE formatted numbers ("12 345,67" with regular, no-break or // narrow no-break group separators) as well as plain "12345.67". function parseFlexibleNumber(s: string): number | null { const cleaned = s.replace(/[\s  ]/g, '').replace(',', '.') if (!cleaned) return null const n = parseFloat(cleaned) return Number.isFinite(n) ? n : null } const EMPTY_NEW_SUPPLIER: NewSupplierForm = { name: '', supplier_type: 'swedish_business', org_number: '', vat_number: '', address_line1: '', bankgiro: '', plusgiro: '', default_expense_account: '', } // Dense in-table inputs: leaf tier inside the Kontering table surface. const CELL_INPUT_CLASS = 'h-8 rounded-sm border-transparent bg-transparent px-2 text-[13px] hover:bg-secondary/60 focus-visible:bg-card' // Row controls: 24x24 hit area (pre-approved dense-row exception to the 40px // icon-button floor), revealed on hover/focus-within, always on coarse pointers. const ROW_ICON_BTN_CLASS = 'inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground' const UNDERLAG_ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] const UNDERLAG_ACCEPT_ATTR = '.pdf,.jpg,.jpeg,.png,.webp' const UNDERLAG_MAX_SIZE = 10 * 1024 * 1024 // A deferred web upload returns an empty extraction skeleton (confidence 0) // while processing; only apply a prefill that actually carries content. function hasExtractionContent(e: InvoiceExtractionResult | null | undefined): boolean { if (!e) return false return Boolean( e.supplier?.name || e.invoice?.invoiceNumber || e.invoice?.invoiceDate || (e.lineItems && e.lineItems.length > 0) || e.totals?.total != null, ) } function SectionLabel({ children }: { children: ReactNode }) { return (
{children}
) } export interface NewSupplierInvoiceFormProps { /** Invoice-inbox item to convert; prefills the form from its AI extraction. */ inboxItemId?: string | null /** * Render without page chrome (back button + h1) for use inside * NewSupplierInvoiceDialog: the same convention as JournalEntryForm's * `bare`. */ bare?: boolean /** * Called after a successful create instead of the standalone page's * navigation, so a dialog host can close itself and refresh in place. * Receives the new invoice id when the natural next step is its detail * page, undefined when the flow is fully done (e.g. expense registered). */ onCreated?: (invoiceId?: string) => void /** Called by the Avbryt button instead of navigating (dialog mode). */ onCancel?: () => void } export default function NewSupplierInvoiceForm({ inboxItemId = null, bare = false, onCreated, onCancel, }: NewSupplierInvoiceFormProps = {}) { const router = useRouter() const { canWrite } = useCanWrite() const { toast } = useToast() const t = useTranslations('supplier_invoice_editor') // Loads the BAS chart chunk after mount and re-renders once names and // descriptions for non-hardcoded accounts are available. useBasReference() const ta = useTranslations('accruals') // When opened from an invoice-inbox item, every redirect should land the // user back in the inbox so they can pick the next document. Outside the // inbox flow, preserve the original behavior (detail page when we have an // invoice id, otherwise the list). const afterCreate = (invoiceId?: string) => inboxItemId ? '/e/general/invoice-inbox' : invoiceId ? `/supplier-invoices/${invoiceId}` : '/supplier-invoices' // After a successful create: hand control back to the host when mounted // with onCreated (dialog mode), otherwise navigate like the old standalone // page did. const finishCreate = (invoiceId?: string) => { createFinishedRef.current = true if (onCreated) onCreated(invoiceId) else router.push(afterCreate(invoiceId)) } const { suppliers, refreshSuppliers, suppliersLoaded, accounts, entityType, accountingMethod, oreRounding, setOreRounding, dimensionsEnabled, vatRegistered, periods, periodsLoaded, } = useSupplierInvoiceData() const [defaultDims, setDefaultDims] = useState>({}) const [showNewSupplier, setShowNewSupplier] = useState(false) const [isCreatingSupplier, setIsCreatingSupplier] = useState(false) const [pendingSupplierSelect, setPendingSupplierSelect] = useState(null) const [newSupplier, setNewSupplier] = useState(EMPTY_NEW_SUPPLIER) const [documentFiles, setDocumentFiles] = useState([]) const documentFilesRef = useRef([]) const createFinishedRef = useRef(false) const invoiceNumberInputRef = useRef(null) // Dokument-först state: a standalone upload routed through the invoice-inbox // pipeline gets an inbox item id (extraction + convert endpoint); a fallback // upload through /api/documents stays a plain attached document. const [uploadedInboxItemId, setUploadedInboxItemId] = useState(null) const uploadedInboxItemIdRef = useRef(null) const [extractionPhase, setExtractionPhase] = useState<'idle' | 'processing' | 'done'>('idle') const extractionPollTokenRef = useRef(0) const underlagInputRef = useRef(null) const [isDraggingUnderlag, setIsDraggingUnderlag] = useState(false) // Deferred tolkning that landed while the user was already typing: buffered // instead of applied, surfaced as a quiet click-to-apply line (see // applyExtractionIfPristine below). const [pendingExtraction, setPendingExtraction] = useState(null) // Fields the tolkning just filled: they get the brief settle tint (the CSS // animation is gated on prefers-reduced-motion in globals.css). const [settledFields, setSettledFields] = useState>(() => new Set()) // Terms-based due date (change 4): auto-filled from the supplier's // default_payment_terms until the user (or the AI) sets it explicitly. const dueDateManualRef = useRef(false) const lastAutoDueRef = useRef(null) const [dueDateCaption, setDueDateCaption] = useState< { type: 'terms'; days: number } | { type: 'on_invoice' } | null >(null) // Pre-submit duplicate advisory (change 3): debounced lookup against // GET /api/supplier-invoices/exists; the create route's 409 stays the backstop. const [duplicateWarning, setDuplicateWarning] = useState<{ number: string existingId: string | null } | null>(null) const duplicateSeqRef = useRef(0) // Total cross-check (change 2): client-only compare, never in the payload. const [fakturaTotalStr, setFakturaTotalStr] = useState('') // Shell state const [forvalOpen, setForvalOpen] = useState(false) const [supplierMenuOpen, setSupplierMenuOpen] = useState(false) const supplierTriggerRef = useRef(null) const entryInputRef = useRef(null) const [entryResetKey, setEntryResetKey] = useState(0) const amountInputRefs = useRef>({}) const [pendingAmountFocus, setPendingAmountFocus] = useState(null) const accountInputRefs = useRef>({}) const { register, control, handleSubmit, watch, setValue, getValues, reset, formState: { isDirty } } = useForm({ defaultValues: { supplier_id: '', supplier_invoice_number: '', invoice_date: new Date().toISOString().split('T')[0], due_date: '', delivery_date: '', currency: 'SEK', exchange_rate: '', reverse_charge: false, payment_reference: '', notes: '', paid_with_private_funds: false, // The table starts empty: the ghost entry row (never part of form // state) is the only way rows are born, so no silent prefilled expense // account can ever reach a verifikat unnoticed. items: [], }, }) useUnsavedChanges(isDirty) // Live dirty flag for long-lived closures (the 90 s extraction poll): // the destructured `isDirty` in that closure is frozen at poll start. const isDirtyRef = useRef(false) useEffect(() => { isDirtyRef.current = isDirty }, [isDirty]) useEffect(() => { documentFilesRef.current = documentFiles }, [documentFiles]) useEffect(() => { uploadedInboxItemIdRef.current = uploadedInboxItemId }, [uploadedInboxItemId]) // Stop any in-flight extraction poll on unmount. useEffect(() => () => { extractionPollTokenRef.current += 1 }, []) useEffect(() => () => { if (inboxItemId || createFinishedRef.current) return // A standalone upload that went through the inbox pipeline created an // inbox item alongside the document: clean up both (same orphan-cleanup // contract as the plain path; the item DELETE 409s if it was converted). const itemId = uploadedInboxItemIdRef.current if (itemId) { void fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}`, { method: 'DELETE', keepalive: true, }) } for (const file of documentFilesRef.current) { if (file.status === 'uploaded' && file.id) { void fetch(`/api/documents/${file.id}`, { method: 'DELETE', keepalive: true }) } } }, [inboxItemId]) const { fields, append, remove, replace } = useFieldArray({ control, name: 'items' }) const watchedItems = watch('items') const watchedSupplierId = watch('supplier_id') const watchedCurrency = watch('currency') const watchedExchangeRate = watch('exchange_rate') const watchedPaidPrivately = watch('paid_with_private_funds') const watchedReverseCharge = watch('reverse_charge') // Watched values used to decide whether the AI-filled indicator should // still be visible. Once the user edits a field, its value no longer // matches what the extractor wrote, and the dot fades out. const watchedInvoiceNumber = watch('supplier_invoice_number') const watchedInvoiceDate = watch('invoice_date') const watchedDueDate = watch('due_date') const watchedPaymentReference = watch('payment_reference') const watchedDeliveryDate = watch('delivery_date') const watchedNotes = watch('notes') const documentUploadInProgress = documentFiles.some((file) => file.status === 'uploading') const documentUploadFailed = documentFiles.some((file) => file.status === 'error') const uploadedDocumentId = documentFiles.find((file) => file.status === 'uploaded')?.id // Settle-tint bookkeeping: applyInboxItem reports every field it fills. const markSettled = useCallback((field: string) => { setSettledFields((prev) => { const next = new Set(prev) next.add(field) return next }) }, []) const handleFieldPrefilled = useCallback( (field: string) => { if (field === 'due_date') { // An AI-extracted due date is an explicit value: the terms-based // auto-update must never overwrite it. dueDateManualRef.current = true setDueDateCaption(null) } markSettled(field) }, [markSettled], ) useEffect(() => { if (settledFields.size === 0) return const timer = setTimeout(() => setSettledFields(new Set()), 1200) return () => clearTimeout(timer) }, [settledFields]) const { extractedData, originalExtracted, hasMatchedSupplier, setHasMatchedSupplier, isLoadingInbox, applyCount: prefillApplyCount, applyInboxItem, } = useInboxPrefill({ inboxItemId, suppliersLoaded, suppliers, setValue, replace, reset, getValues, toast, t, onFieldPrefilled: handleFieldPrefilled, }) // Returns true when the field currently matches whatever the AI wrote // when the form first loaded. Edits diverge it, hiding the dot. function stillFromAi(value: string | null | undefined, original: string | null | undefined): boolean { if (!original) return false return (value ?? '') === (original ?? '') } const aiFlags = { invoiceNumber: stillFromAi(watchedInvoiceNumber, originalExtracted?.invoice?.invoiceNumber ?? null), invoiceDate: stillFromAi(watchedInvoiceDate, originalExtracted?.invoice?.invoiceDate ?? null), dueDate: stillFromAi(watchedDueDate, originalExtracted?.invoice?.dueDate ?? null), paymentReference: stillFromAi( watchedPaymentReference, originalExtracted?.invoice?.paymentReference ?? null, ), } const isEF = entityType === 'enskild_firma' // Out-of-period guard (mirrors the manual voucher form). A registration JE is // only posted at registration time under the accrual method or when the // invoice is marked paid privately: cash method books at payment, so an // out-of-period date is fine there and we stay quiet. periodsLoaded gates the // warning so it never flashes before the fiscal periods have been fetched. const willBookAtRegistration = accountingMethod === 'accrual' || watchedPaidPrivately const invoiceDateOutsidePeriod = periodsLoaded && !!watchedInvoiceDate && !periods.some((p) => watchedInvoiceDate >= p.period_start && watchedInvoiceDate <= p.period_end) const showNoPeriodWarning = willBookAtRegistration && invoiceDateOutsidePeriod // Advisory nudge (#863 item 2): reverse charge purchases are normally booked // on cost accounts (4xxx/5xxx), so flag lines sitting on a class 1 or 6 // account while omvand skattskyldighet is on. Never blocks submission: class // 6 has legitimate reverse charge uses (e.g. 6540 for EU cloud services). const rcAccountWarningRows = watchedReverseCharge ? findReverseChargeAccountWarningRows(watchedItems ?? []) : [] // Advisory nudge (#1042): a foreign supplier charging no Swedish VAT is // normally omvand skattskyldighet. With the switch off no 26x4 leg and no // 44xx/45xx basis is booked, so ruta 20-24, 30-32 and 48 stay empty. Silent // for Swedish suppliers, where 0 % is a genuine exemption. Never blocks: // a non-EU goods purchase cleared at customs is legitimately 0 % without // reverse charge, and forcing the switch there would book a wrong verifikat. const foreignZeroVatRows = findUnflaggedForeignZeroVatRows( watchedItems ?? [], watchedReverseCharge, suppliers.find((s) => s.id === watchedSupplierId)?.supplier_type, ) // Auto-fill defaults when supplier is selected: but never overwrite a value // the AI already filled in for us. const [templateAccountNote, setTemplateAccountNote] = useState<{ account: string; counterparty: string } | null>(null) // Rows planted by a supplier default or the counterparty-history prefill, so // a supplier SWITCH can un-plant them: without this, supplier A's account // survives into supplier B's invoice and silently blocks B's own // default_expense_account (the fill branches only touch empty rows). Each // row carries a plant-time snapshot (dirtyFields is unreliable for appended // array rows: they are born all-dirty) plus whether the plant created the // row, so un-planting can tell an untouched planted row from one the user // has edited. const plantedRef = useRef<{ account: string rows: { index: number; appended: boolean; snapshot: SupplierInvoiceLineItem }[] } | null>(null) // Automatic fill is requested, not applied inline: handleAccountChange // needs the loaded BAS chart to apply the konto's default moms, and the // requests originate in closures (the supplier effect and its async // template fetch) that may hold a stale empty `accounts`. The applying // effect below re-runs on both the request tick and the chart load with // fresh closures, so whichever arrives last triggers the fill. Filling // early would leave a VAT-free konto on the 25% row default, the exact // mis-booking the fill exists to prevent. const pendingAccountFillRef = useRef<{ account: string; counterparty?: string } | null>(null) const [accountFillTick, setAccountFillTick] = useState(0) function requestAccountFill(account: string, counterparty?: string) { pendingAccountFillRef.current = { account, counterparty } setAccountFillTick((t) => t + 1) } useEffect(() => { if (accounts.length === 0 || !pendingAccountFillRef.current) return const { account, counterparty } = pendingAccountFillRef.current pendingAccountFillRef.current = null const items = getValues('items') const appliedRows: { index: number; appended: boolean }[] = [] if (items.length === 0) { // Dokument-först model: the table starts with no rows, so a supplier // default (or history) account plants the first row instead of filling // an empty one. Same side effects as a manual pick (konto default moms, // description from the account name). appliedRows.push({ index: appendRowForAccount(account), appended: true }) } else { items.forEach((row, i) => { if (!row.account_number) { // Same path as a manual pick: konto default moms rides along. handleAccountChange(i, account) appliedRows.push({ index: i, appended: false }) } }) } // Every plant registers in plantedRef: default_expense_account plants must // follow the same un-plant rules on a supplier switch as history plants // (only the history plant gets the counterparty note). if (appliedRows.length > 0) { plantedRef.current = { account, rows: appliedRows.map(({ index, appended }) => ({ index, appended, snapshot: snapshotPlantedRow(getValues(`items.${index}`)), })), } if (counterparty) setTemplateAccountNote({ account, counterparty }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [accounts, accountFillTick]) useEffect(() => { if (!watchedSupplierId) return const supplier = suppliers.find((s) => s.id === watchedSupplierId) if (!supplier) return setTemplateAccountNote(null) pendingAccountFillRef.current = null if (plantedRef.current) { const { account, rows } = plantedRef.current const planted = getValues('items') // Rows the plant itself created and the user never touched since // (snapshot compare: belopp, beskrivning, moms, periodisering, // dimensioner, SLP) are removed outright: in the empty-start table a // planted row IS the row, not just an account on one. Any user edit, // not just an amount, keeps the row's data and only the stale account // is cleared (handleAccountChange reapplies konto defaults on the next // fill or manual pick). Rows that existed before the fill are never // removed, only un-planted. Descending order keeps the remaining // indices valid while removing. ;[...rows].sort((a, b) => b.index - a.index).forEach(({ index, appended, snapshot }) => { const row = planted[index] if (row?.account_number !== account) return if (appended && !plantedRowTouched(row, snapshot)) { remove(index) } else { setValue(`items.${index}.account_number`, '') } }) plantedRef.current = null } applySupplierTerms(supplier) if (supplier.default_expense_account) { // Fill every row the user hasn't assigned yet (or plant the first row // when the table is empty): an empty account is the only signal needed. // Routed through the fill request so it waits for the BAS chart and the // konto's default moms comes along exactly like a manual pick. requestAccountFill(supplier.default_expense_account) } if (supplier.default_currency && watch('currency') === 'SEK') { setValue('currency', supplier.default_currency) } if (supplier.supplier_type === 'eu_business') { setValue('reverse_charge', true) } // No supplier default: fall back to the company's own booking history for // this counterparty (the same tiered matcher the booking flows use). Fills // empty rows only, never a generic seed, and only from expense-shaped // templates (P&L cost on debit, settlement on credit; the 4-8 gate keeps // private/balance-sheet templates like 2013 or 1630 out). Best-effort: on // any miss the rows simply stay blank, exactly as before. if (!supplier.default_expense_account && supplier.name?.trim()) { let cancelled = false ;(async () => { try { const res = await fetch( `/api/settings/counterparty-templates?counterparty=${encodeURIComponent(supplier.name.trim())}` ) if (!res.ok) return const json = await res.json() if (cancelled) return const match = json?.data const debit: string | undefined = match?.template?.debit_account const credit: string | undefined = match?.template?.credit_account if (!match || (match.confidence ?? 0) < 0.5) return if (!debit || !/^[4-8]/.test(debit) || !credit || !credit.startsWith('19')) return requestAccountFill(debit, match.template.counterparty_name) } catch { // Prefill is best-effort; the rows stay blank. } })() return () => { cancelled = true } } return undefined // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedSupplierId, suppliers]) function computeDueDate(invoiceDate: string, days: number): string { const due = new Date(invoiceDate) due.setDate(due.getDate() + days) return due.toISOString().split('T')[0] } // Terms-based förfallodatum (change 4). Auto-set from the supplier's // default_payment_terms with an honest caption; stops updating the moment // the user (or the AI extraction) supplies an explicit date. function applySupplierTerms(supplier: Supplier) { if (dueDateManualRef.current) return const invoiceDate = watch('invoice_date') const currentDue = watch('due_date') // Never overwrite a due date we did not auto-set ourselves (the AI // prefill flips the manual flag, but keep the value guard as defense // in depth). if (currentDue && currentDue !== lastAutoDueRef.current) return const days = supplier.default_payment_terms if (days && days > 0) { if (invoiceDate) { const due = computeDueDate(invoiceDate, days) setValue('due_date', due) lastAutoDueRef.current = due } setDueDateCaption({ type: 'terms', days }) } else { // default_payment_terms is non-null in the DB (default 30), so 30 IS // terms; only an explicit 0 means the supplier has none and the due // date stands on the invoice. if (currentDue) setValue('due_date', '') lastAutoDueRef.current = null setDueDateCaption({ type: 'on_invoice' }) } } // Re-derive an auto-set due date when the invoice date changes: manual or // AI-set dates are left alone via the same guards as applySupplierTerms. useEffect(() => { if (!watchedSupplierId || dueDateManualRef.current) return const supplier = suppliers.find((s) => s.id === watchedSupplierId) if (!supplier) return const currentDue = getValues('due_date') if (currentDue && currentDue !== lastAutoDueRef.current) return const days = supplier.default_payment_terms if (days && days > 0 && watchedInvoiceDate) { const due = computeDueDate(watchedInvoiceDate, days) setValue('due_date', due) lastAutoDueRef.current = due setDueDateCaption({ type: 'terms', days }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedInvoiceDate]) // Pre-submit duplicate advisory (change 3): debounced, sequence-guarded and // purely advisory: the create route's structured 409 stays the enforcement // point, and the exists endpoint mirrors the unique index's // credited/reversed exclusion so re-issues never warn. useEffect(() => { const supplierId = watchedSupplierId const number = (watchedInvoiceNumber || '').trim() if (!supplierId || !number) { // Advance the seq so an in-flight exists response cannot resurrect the // warning under a field the user just cleared. duplicateSeqRef.current += 1 setDuplicateWarning(null) return } const seq = ++duplicateSeqRef.current const timer = setTimeout(async () => { try { const res = await fetch( `/api/supplier-invoices/exists?supplier_id=${encodeURIComponent(supplierId)}&number=${encodeURIComponent(number)}`, ) if (!res.ok) return const json = await res.json() if (duplicateSeqRef.current !== seq) return setDuplicateWarning( json?.data?.exists ? { number, existingId: json.data.existing?.id ?? null } : null, ) } catch { // Advisory only; the 409 backstop still catches real duplicates. } }, 400) return () => clearTimeout(timer) }, [watchedSupplierId, watchedInvoiceNumber]) // Auto-fetch Riksbanken exchange rate when currency switches to non-SEK and // the user hasn't typed a custom rate yet. Re-fetches when the invoice // date changes too. Never overwrites a user-entered rate. Reuses the // watchedInvoiceDate declared above for the AI-filled-indicator flag. // The "user has manually edited the rate" flag is scoped *per currency*. // Switching from EUR (rate 11.8 edited by hand) to USD must re-fetch: the // EUR rate is meaningless for a USD invoice. Tracking last-fetched currency // lets us reset the touched flag on a currency switch while still honoring // a manual edit when only the invoice date changes within the same currency. const userTouchedRateRef = useRef(false) const lastFxCurrencyRef = useRef(null) useEffect(() => { if (watchedCurrency === 'SEK') { setValue('exchange_rate', '') userTouchedRateRef.current = false lastFxCurrencyRef.current = null return } if (lastFxCurrencyRef.current !== watchedCurrency) { // Currency switched: drop the previous currency's manual-edit flag. userTouchedRateRef.current = false lastFxCurrencyRef.current = watchedCurrency } if (userTouchedRateRef.current) return let cancelled = false ;(async () => { try { const url = `/api/currency/rate?currency=${watchedCurrency}${ watchedInvoiceDate ? `&date=${watchedInvoiceDate}` : '' }` const res = await fetch(url) if (!res.ok) return const { data } = await res.json() if (cancelled || !data?.rate) return // Don't clobber a value the user typed while we were fetching. if (userTouchedRateRef.current) return setValue('exchange_rate', String(Math.round(data.rate * 10000) / 10000)) } catch { // Non-critical: user can type the rate manually. } })() return () => { cancelled = true } }, [watchedCurrency, watchedInvoiceDate, setValue]) // Auto-select newly created supplier once it shows up in the list useEffect(() => { if (pendingSupplierSelect && suppliers.find((s) => s.id === pendingSupplierSelect)) { setValue('supplier_id', pendingSupplierSelect, { shouldDirty: true, shouldValidate: true }) setPendingSupplierSelect(null) } }, [suppliers, pendingSupplierSelect, setValue]) function handleAccountChange(index: number, accountNumber: string) { setValue(`items.${index}.account_number`, accountNumber) const currentDesc = watch(`items.${index}.description`) if (!currentDesc && accountNumber.length === 4) { const desc = getAccountDescription(accountNumber) if (desc) setValue(`items.${index}.description`, desc.name) } // Auto-fill the rad's moms from the konto's configured default (e.g. // öresavrundning 3740 = ingen moms), so a rounding line stops inheriting // the 25 % rad-default. Only when the konto carries an explicit default; // otherwise the user's current rate stands. Reverse charge uses its own // rate field, so leave that flow untouched. PostgREST serialises numeric // columns as strings, so coerce: strict === comparisons on vat_rate // (inferVatTreatment) expect a number. const acct = accounts.find((a) => a.account_number === accountNumber) const defaultRate = acct?.default_vat_rate == null ? null : Number(acct.default_vat_rate) if (vatRegistered && !watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) { setValue(`items.${index}.vat_rate`, defaultRate, { shouldDirty: true }) } // Särskild löneskatt only applies to 741x pension premiums: leaving the // range clears the flag so a stale opt-in can never reach the API. if (watch(`items.${index}.apply_slp`) && !isSlpPensionAccount(accountNumber)) { setValue(`items.${index}.apply_slp`, undefined, { shouldDirty: true }) } } // Append a new kontering row for a committed account: the same side effects // as a manual pick on an existing row (description from the account name, // konto default moms), used by the ghost entry row and the supplier-default // plant. Returns the new row's index. function appendRowForAccount(accountNumber: string): number { const acct = accounts.find((a) => a.account_number === accountNumber) const defaultRate = acct?.default_vat_rate == null ? null : Number(acct.default_vat_rate) const vatRate = !vatRegistered ? 0 : !watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate) ? defaultRate : 0.25 const description = accountNumber.length === 4 ? getAccountDescription(accountNumber)?.name ?? '' : '' const index = getValues('items').length append({ description, amount: 0, account_number: accountNumber, vat_rate: vatRate, reverse_charge_rate: 0.25, }) return index } // Ghost entry row commit: append the row, clear the entry input (remount) // and move focus to the new row's amount cell. function commitEntryAccount(accountNumber: string) { if (!accountNumber) return const index = appendRowForAccount(accountNumber) setEntryResetKey((k) => k + 1) // Focus hand-off via effect, not requestAnimationFrame: the entry input // remounts on commit while focused, and the dialog's focus scope // re-parks focus before any frame callback can win. The effect below // runs after the new row's input is mounted, deterministically. setPendingAmountFocus(index) } useEffect(() => { if (pendingAmountFocus === null) return const el = amountInputRefs.current[pendingAmountFocus] if (!el) return el.focus() el.select() setPendingAmountFocus(null) }, [pendingAmountFocus, fields.length]) // Periodisering per rad: kräver faktureringsmetoden; eget utlägg bokar // kostnaden direkt mot ägarkontot och kan inte periodiseras. Omvänd // skattskyldighet kan inte heller periodiseras: kostnadsraden utgör // momsunderlaget (ruta 20-32) och får inte flyttas till ett interimskonto. const canUseAccrual = accountingMethod === 'accrual' && !watchedPaidPrivately && !watchedReverseCharge // When reverse charge is switched on, clear any per-line periodisering so a // stale AI prefill (or fields set before the toggle) can never reach the // API, which rejects the combination with SI_CREATE_ACCRUAL_REVERSE_CHARGE. useEffect(() => { if (!watchedReverseCharge) return const items = getValues('items') ?? [] items.forEach((item, index) => { if ( item.accrual_period_start !== undefined || item.accrual_period_end !== undefined || item.accrual_balance_account !== undefined ) { setValue(`items.${index}.accrual_period_start`, undefined, { shouldDirty: true }) setValue(`items.${index}.accrual_period_end`, undefined, { shouldDirty: true }) setValue(`items.${index}.accrual_balance_account`, undefined, { shouldDirty: true }) } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedReverseCharge]) // Force every line to 0 % moms for icke momsregistrerade companies: the // default line, AI prefills and konto defaults all assume 25 % otherwise. // Re-runs after EVERY applied extraction (prefillApplyCount bumps per // apply), not just the first: a remove + re-upload applies a fresh // extraction whose 25 % rates would otherwise reach the convert endpoint // silently, with the moms columns hidden. // // The amount is grossed up in the same pass: an amount paired with a // non-zero rate is a NET amount (AI line totals are exkl moms, and the // visible column said "Belopp (exkl.)" while the rate stood). For a company // with no deduction right the moms is part of the cost, so net at 25 % // becomes gross at 0 %; zeroing the rate alone would understate both the // expense and 2440 by exactly the moms. useEffect(() => { if (vatRegistered) return const items = getValues('items') ?? [] items.forEach((item, index) => { if (item.vat_rate !== 0) { if (item.amount) { setValue(`items.${index}.amount`, roundOre(item.amount * (1 + item.vat_rate))) } setValue(`items.${index}.vat_rate`, 0) } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [vatRegistered, prefillApplyCount]) function isAccrualOpen(index: number): boolean { return watchedItems?.[index]?.accrual_balance_account != null } function toggleAccrual(index: number) { if (isAccrualOpen(index)) { setValue(`items.${index}.accrual_period_start`, undefined, { shouldDirty: true }) setValue(`items.${index}.accrual_period_end`, undefined, { shouldDirty: true }) setValue(`items.${index}.accrual_balance_account`, undefined, { shouldDirty: true }) } else { const account = watch(`items.${index}.account_number`) || '' 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`, suggestBalanceAccount('expense', account), { shouldDirty: true }, ) // Periodisering + särskild löneskatt on the same row is rejected by the // API (SI_CREATE_SLP_ACCRUAL): opening the accrual panel wins and the // SLP opt-in is cleared (its confirmation row disappears with it). if (watch(`items.${index}.apply_slp`)) { setValue(`items.${index}.apply_slp`, undefined, { shouldDirty: true }) } } } // --- Särskild löneskatt på pensionskostnader (SLP, 24,26 %) --- // A 741x pension-premium row gets an advisory hint (add the 7533/2514 // pair) or, once opted in, a quiet confirmation line. The pair nets to // zero, so the totals box below is untouched: the invoice total IS the // payable. Hidden while the row's periodisering panel is open (the API // rejects the combination). function slpRowVisible(index: number): boolean { const item = watchedItems?.[index] if (!item) return false if (!isSlpPensionAccount(item.account_number || '')) return false if (canUseAccrual && isAccrualOpen(index)) return false return true } function setSlp(index: number, value: boolean) { setValue(`items.${index}.apply_slp`, value ? true : undefined, { shouldDirty: true }) } function renderSlpPanel(index: number) { const item = watchedItems?.[index] if (!item) return null const slpAmount = roundOre((item.amount || 0) * SLP_RATE) if (item.apply_slp) { return (

{t('slp_applied_line', { amount: formatAmount(slpAmount) })}

) } return (

{t('slp_hint', { amount: formatAmount(slpAmount) })}

) } // --- Dimension tagging (kostnadsställe/projekt, dimensions PR7) --- // Mirrors the accrual pattern: a defined (possibly empty) bag on the item // means the row's panel is open; closing clears the bag entirely so the // payload never carries a stale override. 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 }) } function isDimOpen(index: number): boolean { return watchedItems?.[index]?.dimensions != null } function toggleDimensions(index: number) { if (isDimOpen(index)) { setValue(`items.${index}.dimensions`, undefined, { shouldDirty: true }) } else { setValue(`items.${index}.dimensions`, {}, { shouldDirty: true }) } } function updateItemDimension(index: number, dimNo: string, code: string | null) { const current = { ...(getValues(`items.${index}.dimensions`) ?? {}) } const trimmed = code?.trim() if (trimmed) current[dimNo] = trimmed else delete current[dimNo] setValue(`items.${index}.dimensions`, current, { shouldDirty: true }) } // Compact display of the invoice default, e.g. "KS01 · P001" (dim-number order). const defaultDimsSummary = Object.entries(defaultDims) .filter(([, v]) => v) .sort(([a], [b]) => Number(a) - Number(b)) .map(([, v]) => v) .join(' · ') function renderDimensionsPanel(index: number) { const item = watchedItems?.[index] if (!item || item.dimensions == null) return null return (
updateItemDimension(index, dimNo, code)} inputClassName="h-8" />
{defaultDimsSummary && (

{t('row_dimensions_inherit_hint', { dims: defaultDimsSummary })}

)}
) } function renderAccrualPanel(index: number, idPrefix: string) { const item = watchedItems?.[index] if (!item || item.accrual_balance_account == null) return null return ( { 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)} /> ) } // Reverse charge keeps its rate controls even for icke momsregistrerade // (self-assessment is a separate obligation from deduction); everything // else moms-related disappears when the company isn't VAT-registered. const vatColsVisible = vatRegistered || watchedReverseCharge const itemTotals = (watchedItems || []).map((item) => { const lineTotal = Math.round((item.amount || 0) * 100) / 100 // Reverse charge: VAT is self-assessed at reverse_charge_rate (25% default), // not the line's vat_rate (which is 0: the supplier charged nothing). const effectiveRate = watchedReverseCharge ? (item.reverse_charge_rate ?? 0.25) : (item.vat_rate || 0) const vatAmount = Math.round(lineTotal * effectiveRate * 100) / 100 return { lineTotal, vatAmount } }) const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0) const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0) // Reverse charge: supplier never invoices VAT, so it doesn't roll into the // payable total. The VAT is still accounted for via 2614 / 2645 in // bookkeeping: the line stays in the breakdown for transparency. const payableVat = watchedReverseCharge ? 0 : totalVat const total = Math.round((subtotal + payableVat) * 100) / 100 // Öresavrundning live preview: same helper as the detail page. Display-only; // the registered amount and the booked verifikat keep the exact öre. const displayRounding = getDisplayTotal( { total, currency: watchedCurrency || 'SEK', ore_rounding: oreRounding }, { ore_rounding: false }, ) // Total cross-check (change 2): client-only compare against the displayed // payable; a mismatch renders field-adjacent, never blocks, never travels. const enteredFakturaTotal = parseFlexibleNumber(fakturaTotalStr) const crosscheckDiff = enteredFakturaTotal == null ? null : roundOre(enteredFakturaTotal - displayRounding.displayed) const crosscheckMatches = crosscheckDiff != null && Math.abs(crosscheckDiff) <= 0.005 // Show the AI-suggested supplier card when we have an extraction, the AI // surfaced a supplier name, and we couldn't match it to an existing record. const showAISupplierHint = !!extractedData?.supplier?.name && !hasMatchedSupplier && !watchedSupplierId function openSupplierDialogPrefilled() { setNewSupplier({ name: extractedData?.supplier?.name || '', supplier_type: 'swedish_business', org_number: extractedData?.supplier?.orgNumber || '', vat_number: extractedData?.supplier?.vatNumber || '', address_line1: extractedData?.supplier?.address || '', bankgiro: extractedData?.supplier?.bankgiro || '', plusgiro: extractedData?.supplier?.plusgiro || '', default_expense_account: '', }) setShowNewSupplier(true) } function openSupplierDialogBlank() { setNewSupplier(EMPTY_NEW_SUPPLIER) setShowNewSupplier(true) } async function handleCreateSupplier() { if (!newSupplier.name.trim()) { toast({ title: t('name_missing_title'), description: t('name_missing_description'), variant: 'destructive' }) return } setIsCreatingSupplier(true) const payload: Record = { name: newSupplier.name, supplier_type: newSupplier.supplier_type, } if (newSupplier.org_number) payload.org_number = newSupplier.org_number if (newSupplier.vat_number) payload.vat_number = newSupplier.vat_number if (newSupplier.address_line1) payload.address_line1 = newSupplier.address_line1 if (newSupplier.bankgiro) payload.bankgiro = newSupplier.bankgiro if (newSupplier.plusgiro) payload.plusgiro = newSupplier.plusgiro if (newSupplier.default_expense_account) payload.default_expense_account = newSupplier.default_expense_account const res = await fetch('/api/suppliers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const result = await res.json() if (!res.ok) { toast({ title: t('create_supplier_failed_title'), description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' }) } else { const created = result.data as Supplier // The shared supplier list feeds every picker: refresh it (awaited, so // the pending selection below finds the new row). await refreshSuppliers() setPendingSupplierSelect(created.id) setHasMatchedSupplier(true) setShowNewSupplier(false) setNewSupplier(EMPTY_NEW_SUPPLIER) toast({ title: t('supplier_created_title'), description: created.name }) } setIsCreatingSupplier(false) } // --- Dokument-först upload (change 1) --- // The standalone form's upload tries the invoice-inbox pipeline (upload + // AI tolkning) over plain HTTP first: core never imports the extension, so // a disabled extension just 404s and the upload degrades to the plain // /api/documents path with no extraction. Manual entry is never blocked. function afterUploadExtraction(extracted: InvoiceExtractionResult | null) { setExtractionPhase('done') if (extracted?.totals?.total != null) { setFakturaTotalStr(formatAmount(extracted.totals.total)) markSettled('faktura_total') } } // Standalone-path guard: an extraction can land up to 90 s after upload, // and applyInboxItem overwrites scalars and replaces ALL rows. Auto-apply // only while the form is still pristine; once the user has typed, buffer // the result and offer a quiet click-to-apply line instead of silently // wiping their input. The inbox-arrival path (?inbox_item_id) never routes // through here: there the prefill IS the entry state. function applyExtractionIfPristine(item: InboxItemData) { if (isDirtyRef.current) { setPendingExtraction(item) setExtractionPhase('done') return } applyInboxItem(item) afterUploadExtraction(item.extracted_data) } function applyPendingExtraction() { const item = pendingExtraction if (!item) return setPendingExtraction(null) applyInboxItem(item) afterUploadExtraction(item.extracted_data) } async function pollExtraction(itemId: string, documentId: string) { const token = ++extractionPollTokenRef.current const startedAt = Date.now() while (Date.now() - startedAt < 90_000) { await new Promise((resolve) => setTimeout(resolve, 1500)) if (extractionPollTokenRef.current !== token) return try { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}`) if (!res.ok) break const { data } = await res.json() if (extractionPollTokenRef.current !== token) return if (data?.status !== 'processing') { if (hasExtractionContent(data?.extracted_data)) { applyExtractionIfPristine({ id: itemId, extracted_data: data.extracted_data, matched_supplier_id: data.matched_supplier_id ?? null, document_id: documentId, }) } else { // Extraction failed or read nothing: keep the plain attachment. setExtractionPhase('idle') } return } } catch { break } } if (extractionPollTokenRef.current === token) setExtractionPhase('idle') } async function uploadPlainDocument(entry: UploadedFile) { const formData = new FormData() formData.append('file', entry.file) formData.append('upload_source', 'file_upload') try { const res = await fetch('/api/documents', { method: 'POST', body: formData }) let result: { data?: { id?: string }; error?: unknown } = {} try { result = await res.json() } catch { setDocumentFiles([{ ...entry, status: 'error', error: t('underlag_upload_failed') }]) return } if (!res.ok || result.error || !result.data?.id) { setDocumentFiles([{ ...entry, status: 'error', error: t('underlag_upload_failed') }]) return } setDocumentFiles([{ ...entry, status: 'uploaded', id: result.data.id }]) } catch { setDocumentFiles([{ ...entry, status: 'error', error: t('underlag_upload_failed') }]) } } async function handleUnderlagFile(file: File) { const uploadKey = `underlag-${Date.now()}` const entry: UploadedFile = { file, status: 'uploading', fileName: file.name, fileSize: file.size, uploadKey, } if (!UNDERLAG_ACCEPTED_TYPES.includes(file.type)) { setDocumentFiles([{ ...entry, status: 'error', error: t('underlag_unsupported_type') }]) return } if (file.size > UNDERLAG_MAX_SIZE) { setDocumentFiles([{ ...entry, status: 'error', error: t('underlag_too_large') }]) return } setDocumentFiles([entry]) setExtractionPhase('idle') setPendingExtraction(null) // Above the hosted request-body limit the platform refuses a multipart // body before the route runs (a plain 413, nothing in the logs), so the // bytes go straight to Storage through a signed URL instead. Same // response shape either way. const directToStorage = exceedsHostedUploadLimit(file.size) try { let res: Response if (directToStorage) { res = await uploadViaSignedUrl(file) } else { const formData = new FormData() formData.append('file', file) res = await fetch('/api/extensions/ext/invoice-inbox/upload', { method: 'POST', body: formData, }) } if (res.ok) { const json = await res.json().catch(() => null) const data = json?.data as | { document_id?: string inbox_item_id?: string status?: string extracted_data?: InvoiceExtractionResult | null matched_supplier_id?: string | null } | undefined if (data?.document_id && data.inbox_item_id) { setDocumentFiles([{ ...entry, status: 'uploaded', id: data.document_id }]) setUploadedInboxItemId(data.inbox_item_id) if (data.status === 'processing') { setExtractionPhase('processing') void pollExtraction(data.inbox_item_id, data.document_id) } else if (hasExtractionContent(data.extracted_data)) { applyExtractionIfPristine({ id: data.inbox_item_id, extracted_data: data.extracted_data ?? null, matched_supplier_id: data.matched_supplier_id ?? null, document_id: data.document_id, }) } return } } } catch { // Extension unreachable: fall through to the plain upload below. } if (directToStorage) { // The plain fallback posts a multipart body to /api/documents, which // the platform would refuse the same way: nothing left to try. setDocumentFiles([{ ...entry, status: 'error', error: t('underlag_upload_failed') }]) return } await uploadPlainDocument(entry) } function handleRemoveUnderlag() { extractionPollTokenRef.current += 1 const itemId = uploadedInboxItemId const docIds = documentFiles .filter((file) => file.status === 'uploaded' && file.id) .map((file) => file.id as string) setDocumentFiles([]) setUploadedInboxItemId(null) setExtractionPhase('idle') setPendingExtraction(null) void (async () => { // Delete the inbox item first (it references the document), then the // document. Both best-effort: the same orphan-cleanup contract as before. if (itemId) { try { await fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}`, { method: 'DELETE' }) } catch { // Best-effort cleanup. } } await Promise.allSettled( docIds.map((documentId) => fetch(`/api/documents/${documentId}`, { method: 'DELETE' })), ) })() } function buildPayload(data: FormData) { return buildSupplierInvoicePayload(data, { inboxItemId: effectiveInboxItemId, uploadedDocumentId, oreRounding, defaultDims, canUseAccrual, }) } // An upload that went through the inbox pipeline submits through the same // convert endpoint as an inbox arrival: document linking and the item's // created_supplier_invoice_id stamp come for free, and the inbox never // shows an already-registered document as pending. const effectiveInboxItemId = inboxItemId ?? uploadedInboxItemId function focusMissingField( field: 'supplier' | 'invoice_number' | 'rows' | 'row_account', rowIndex?: number, ) { if (field === 'supplier') { setSupplierMenuOpen(true) supplierTriggerRef.current?.focus() } else if (field === 'invoice_number') { invoiceNumberInputRef.current?.focus() } else if (field === 'rows') { entryInputRef.current?.focus() } else if (field === 'row_account' && rowIndex != null) { accountInputRefs.current[rowIndex]?.focus() } } const { isSubmitting, showReview, setShowReview, pendingData, showBankPicker, setShowBankPicker, setPendingTransactionId, submitModeRef, conflict, setConflict, isResolvingConflict, onSubmit, handleConfirm, handleUncreditAndRetry, handlePickNewNumber, handlePickTransaction, } = useSupplierInvoiceSubmit({ t, ta, toast, isEF, inboxItemId: effectiveInboxItemId, originalExtracted, buildPayload, reset, finishCreate, documentUploadInProgress, documentUploadFailed, showNoPeriodWarning, canUseAccrual, invoiceNumberInputRef, onMissingField: focusMissingField, }) async function discardUploadedDocument() { extractionPollTokenRef.current += 1 const itemId = uploadedInboxItemId if (itemId) { try { await fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}`, { method: 'DELETE' }) } catch { // Best-effort cleanup. } } const ids = documentFiles .filter((file) => file.status === 'uploaded' && file.id) .map((file) => file.id as string) await Promise.allSettled( ids.map((documentId) => fetch(`/api/documents/${documentId}`, { method: 'DELETE' })), ) } function handleCancel() { void discardUploadedDocument().finally(() => { if (onCancel) onCancel() else router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices') }) } // --- Derived shell state --- const selectedSupplier = suppliers.find((s) => s.id === watchedSupplierId) const supplierHasGiro = Boolean(selectedSupplier?.bankgiro || selectedSupplier?.plusgiro) const underlagFile = documentFiles[0] const underlagAttached = Boolean(inboxItemId || uploadedDocumentId) const detailsFilled = Boolean((watchedInvoiceNumber || '').trim() && watchedInvoiceDate) // Next-step line (the page's ONLY ochre line): same predicates and order as // the submit-time checks in useSupplierInvoiceSubmit, so the always-enabled // primary's focus routing and this line always agree. type MissingField = 'supplier' | 'invoice_number' | 'rows' | 'row_account' const nextStep = ((): { field: MissingField; rowIndex?: number; label: string } | null => { if (!watchedSupplierId) { return { field: 'supplier', label: t('next_step_supplier') } } if (!(watchedInvoiceNumber || '').trim()) { return { field: 'invoice_number', label: t('next_step_invoice_number') } } if (!watchedItems || watchedItems.length === 0) { return { field: 'rows', label: t('next_step_add_row') } } const rowMissingAccount = watchedItems.findIndex((item) => !item.account_number) if (rowMissingAccount !== -1) { return { field: 'row_account', rowIndex: rowMissingAccount, label: t('next_step_row_account', { row: rowMissingAccount + 1 }), } } return null })() const readyLineText = watchedPaidPrivately || isEF ? willBookAtRegistration ? t('ready_line_register') : t('ready_line_register_cash') : t('ready_line_review') const forvalChips: string[] = [ willBookAtRegistration ? t('forval_books_at_registration') : t('forval_books_at_payment'), ] if (watchedPaidPrivately) forvalChips.push(t('chip_paid_privately')) if (watchedReverseCharge) forvalChips.push(t('reverse_charge_label')) if ((watchedCurrency || 'SEK') !== 'SEK') { forvalChips.push( watchedExchangeRate ? t('chip_currency_rate', { currency: watchedCurrency, rate: watchedExchangeRate }) : watchedCurrency, ) } if ((watchedCurrency || 'SEK') === 'SEK' && !oreRounding) forvalChips.push(t('chip_ore_rounding_off')) if (watchedDeliveryDate) forvalChips.push(t('chip_delivery_date', { date: formatDate(watchedDeliveryDate) })) if ((watchedNotes || '').trim()) forvalChips.push(t('chip_notes')) if (dimensionsEnabled && defaultDimsSummary) forvalChips.push(defaultDimsSummary) const underlagCaption = inboxItemId ? null : extractionPhase === 'done' ? t('underlag_caption_done') : underlagFile?.status === 'uploaded' && extractionPhase === 'idle' ? t('underlag_caption_attached') : t('underlag_caption_default') const primaryLabel = watchedPaidPrivately ? t('register_expense') : isEF ? t('register_invoice') : t('review_and_register') // min-w-0 on the root: DialogContent is display:grid; without it this grid // item's min-width:auto lets the kontering table's min-w force the whole // column wider than small viewports and the dialog clips it. return (
{/* In bare (dialog) mode the dialog renders the title. */} {!bare && (

{t('page_title')}

)} {isLoadingInbox && ( {t('loading_inbox')} )}
{/* ── Underlag (first: dokument-först) ─────────────────── */}
{t('section_underlag')} ({t('underlag_recommended')}) {underlagAttached && ( {'✓'} {t('mark_attached')} )} {inboxItemId ? (
{t('underlag_caption_inbox')}
) : underlagFile ? (

{underlagFile.fileName}

{(underlagFile.status === 'uploading' || extractionPhase === 'processing') && (

{underlagFile.status === 'uploading' ? t('underlag_uploading') : t('underlag_processing')}

)} {underlagFile.status === 'error' && (

{underlagFile.error}

)}
) : ( <> { const file = e.target.files?.[0] if (file) void handleUnderlagFile(file) if (underlagInputRef.current) underlagInputRef.current.value = '' }} /> )} {pendingExtraction ? ( // Deferred tolkning landed after the user started typing: never // auto-overwrite; offer the fill as a quiet choice instead.

{t('extraction_ready_line')}{' '}

) : underlagCaption ? (

{underlagCaption}

) : null}
{/* ── Leverantör ───────────────────────────────────────── */}
{t('section_supplier')} {watchedSupplierId && ( {'✓'} {t('mark_selected')} )} {showAISupplierHint && (

{t('ai_suggested_supplier', { name: extractedData?.supplier?.name ?? '' })}

{extractedData?.supplier?.orgNumber ? t('ai_org_number', { orgNumber: extractedData.supplier.orgNumber }) : t('ai_no_org_number')} {t('ai_supplier_not_in_system')}

)} { // Picking a supplier routes focus to the invoice-number // field; the menu's default close behavior would yank it // back to the trigger. e.preventDefault() }} > {suppliers.length === 0 && (
{t('no_suppliers_yet')}
)} {suppliers.map((s) => ( { setValue('supplier_id', s.id, { shouldDirty: true }) requestAnimationFrame(() => invoiceNumberInputRef.current?.focus()) }} >
{s.name}
{s.org_number && (
{t('org_number_prefix', { number: s.org_number })}
)}
))} {/* No separator over an empty list: it reads as a stray line. */} {suppliers.length > 0 && } {t('add_new_supplier')}
{/* ── Fakturauppgifter ─────────────────────────────────── */}
{t('section_details')} {detailsFilled && ( {'✓'} {t('mark_filled')} )}
{(() => { const { ref: rhfRef, ...rest } = register('supplier_invoice_number') return ( { rhfRef(el) invoiceNumberInputRef.current = el }} /> ) })()} {duplicateWarning && (

{t('duplicate_warning_line', { number: duplicateWarning.number })} {duplicateWarning.existingId && ( <> {' '} )}

)}
{!watchedPaidPrivately && ( <>
{ dueDateManualRef.current = true setDueDateCaption(null) }, })} /> {dueDateCaption && (

{dueDateCaption.type === 'terms' ? t('due_from_terms_caption', { days: dueDateCaption.days }) : t('due_on_invoice_caption')}

)}
{supplierHasGiro && (

{t('ocr_payment_file_caption')}

)}
)}
{showNoPeriodWarning && (

{t('no_period_warning', { date: watchedInvoiceDate })}

{t('no_period_help')}

)}
{/* ── Kontering ────────────────────────────────────────── */}
{t('section_accounting')} {fields.length > 0 && ( {t('rows_count', { count: fields.length })} )} {templateAccountNote && (watchedItems ?? []).some((r) => r.account_number === templateAccountNote.account) && (

{t('account_from_history', { account: templateAccountNote.account, counterparty: formatCounterpartyName(templateAccountNote.counterparty), })}

)} {/* Non-blocking account-range hint for reverse charge (#863 item 2) */} {rcAccountWarningRows.length > 0 && (

{t('rc_account_warning', { count: rcAccountWarningRows.length, rows: rcAccountWarningRows.map((i) => i + 1).join(', '), })}

)} {/* Non-blocking omvand-skattskyldighet hint for foreign suppliers invoiced at 0 % VAT (#1042). Mutually exclusive with the banner above: that one needs the switch on, this one needs it off. */} {foreignZeroVatRows.length > 0 && (

{t('foreign_zero_vat_warning', { count: foreignZeroVatRows.length, rows: foreignZeroVatRows.map((i) => i + 1).join(', '), })}

)}
{vatColsVisible && ( <> )} {fields.map((field, index) => ( {vatColsVisible && ( <> )} {canUseAccrual && isAccrualOpen(index) && ( )} {dimensionsEnabled && isDimOpen(index) && ( )} {slpRowVisible(index) && ( )} ))} {/* Ghost entry row: never part of form state until committed. The autocomplete opens on focus; Enter (or a full 4-digit number) commits and moves focus to the new row's amount. */} {vatColsVisible && ( <> )}
{t('col_account')} {t('col_description')} {vatColsVisible ? t('col_amount_excl') : t('col_amount')} {watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')} {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}
( handleAccountChange(index, val)} className="h-8 px-2 text-[13px]" inputRef={(el) => { accountInputRefs.current[index] = el }} /> )} /> ( )} /> ( { amountInputRefs.current[index] = el }} /> )} /> {watchedReverseCharge ? ( ( )} /> ) : ( ( )} /> )} {formatAmount(itemTotals[index]?.vatAmount ?? 0)} {dimensionsEnabled && ( )} {canUseAccrual && ( )}
{renderAccrualPanel(index, `accrual-${index}`)}
{renderDimensionsPanel(index)}
{renderSlpPanel(index)}
{}} onCommit={commitEntryAccount} className="h-8 px-2 text-[13px]" inputRef={(el) => { entryInputRef.current = el }} /> {t('ghost_description')} 0 25 % -
{/* ── Förval ───────────────────────────────────────────── */}
{t('section_forval')}
{forvalChips.join(' · ')}
{forvalOpen && (
(
( )} />
{t('currency_label')} ( )} />
{watchedCurrency !== 'SEK' && (
{t('exchange_rate_label')}{' '} {t('exchange_rate_to_sek')} { userTouchedRateRef.current = true }, })} />
)} {(watchedCurrency || 'SEK') === 'SEK' && (
)}
{dimensionsEnabled && (
{t('row_dimensions_title')}
)}