* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2664 lines
114 KiB
TypeScript
2664 lines
114 KiB
TypeScript
'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 <span className="text-destructive ml-0.5" aria-hidden="true">*</span>
|
||
}
|
||
|
||
// 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 (
|
||
<div className="mb-3 text-[11px] uppercase tracking-[0.07em] text-muted-foreground">
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<Record<string, string>>({})
|
||
const [showNewSupplier, setShowNewSupplier] = useState(false)
|
||
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
|
||
const [pendingSupplierSelect, setPendingSupplierSelect] = useState<string | null>(null)
|
||
const [newSupplier, setNewSupplier] = useState<NewSupplierForm>(EMPTY_NEW_SUPPLIER)
|
||
const [documentFiles, setDocumentFiles] = useState<UploadedFile[]>([])
|
||
const documentFilesRef = useRef<UploadedFile[]>([])
|
||
const createFinishedRef = useRef(false)
|
||
const invoiceNumberInputRef = useRef<HTMLInputElement | null>(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<string | null>(null)
|
||
const uploadedInboxItemIdRef = useRef<string | null>(null)
|
||
const [extractionPhase, setExtractionPhase] = useState<'idle' | 'processing' | 'done'>('idle')
|
||
const extractionPollTokenRef = useRef(0)
|
||
const underlagInputRef = useRef<HTMLInputElement | null>(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<InboxItemData | null>(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<Set<string>>(() => 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<string | null>(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<HTMLButtonElement | null>(null)
|
||
const entryInputRef = useRef<HTMLInputElement | null>(null)
|
||
const [entryResetKey, setEntryResetKey] = useState(0)
|
||
const amountInputRefs = useRef<Record<number, HTMLInputElement | null>>({})
|
||
const [pendingAmountFocus, setPendingAmountFocus] = useState<number | null>(null)
|
||
const accountInputRefs = useRef<Record<number, HTMLInputElement | null>>({})
|
||
|
||
const { register, control, handleSubmit, watch, setValue, getValues, reset, formState: { isDirty } } = useForm<FormData>({
|
||
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<string | null>(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 (
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-xs text-muted-foreground tabular-nums">
|
||
{t('slp_applied_line', { amount: formatAmount(slpAmount) })}
|
||
</p>
|
||
<Button type="button" variant="ghost" size="sm" onClick={() => setSlp(index, false)}>
|
||
{t('slp_remove_action')}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div
|
||
role="status"
|
||
className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3"
|
||
>
|
||
<AlertTriangle className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||
<p className="flex-1 text-sm">
|
||
{t('slp_hint', { amount: formatAmount(slpAmount) })}
|
||
</p>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="shrink-0"
|
||
onClick={() => setSlp(index, true)}
|
||
>
|
||
{t('slp_add_action')}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- 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 (
|
||
<div className="rounded-lg border bg-muted/30 p-3 space-y-1">
|
||
<div className="max-w-md">
|
||
<LineDimensionFields
|
||
dimensions={item.dimensions}
|
||
onChange={(dimNo, code) => updateItemDimension(index, dimNo, code)}
|
||
inputClassName="h-8"
|
||
/>
|
||
</div>
|
||
{defaultDimsSummary && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t('row_dimensions_inherit_hint', { dims: defaultDimsSummary })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function renderAccrualPanel(index: number, idPrefix: string) {
|
||
const item = watchedItems?.[index]
|
||
if (!item || item.accrual_balance_account == null) return null
|
||
return (
|
||
<AccrualPeriodControl
|
||
direction="expense"
|
||
// Entity type picks the regelverk the 5 000 kr hint cites: K1 for
|
||
// enskild firma, K2 for aktiebolag.
|
||
entityType={entityType}
|
||
amount={item.amount || 0}
|
||
// Line amounts are in the invoice's currency; the K2 5 000 kr limit is
|
||
// in SEK. The rate is the Riksbanken/manual one already on the form.
|
||
// An empty field parses to NaN, which the control reads as "unknown"
|
||
// and then hides the hint instead of comparing the wrong unit.
|
||
currency={watchedCurrency}
|
||
exchangeRate={parseFloat(watchedExchangeRate)}
|
||
idPrefix={idPrefix}
|
||
value={{
|
||
start: item.accrual_period_start ?? '',
|
||
end: item.accrual_period_end ?? '',
|
||
balanceAccount: item.accrual_balance_account || '1790',
|
||
}}
|
||
onChange={(next) => {
|
||
setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true })
|
||
setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true })
|
||
setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true })
|
||
}}
|
||
onRemove={() => toggleAccrual(index)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// 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<string, unknown> = {
|
||
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 (
|
||
<div className={bare ? 'relative min-w-0' : 'relative min-w-0 max-w-2xl'}>
|
||
{/* In bare (dialog) mode the dialog renders the title. */}
|
||
{!bare && (
|
||
<div className="mb-6 flex items-center gap-4">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')}
|
||
aria-label={inboxItemId ? t('back_aria_inbox') : t('back_aria')}
|
||
>
|
||
<ArrowLeft className="h-5 w-5" />
|
||
</Button>
|
||
<div>
|
||
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('page_title')}</h1>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isLoadingInbox && (
|
||
<Card className="mb-6">
|
||
<CardContent className="py-4 flex items-center gap-3 text-sm text-muted-foreground">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
{t('loading_inbox')}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<form onSubmit={handleSubmit(onSubmit)}>
|
||
<div className="divide-y divide-border">
|
||
{/* ── Underlag (first: dokument-först) ─────────────────── */}
|
||
<section className="pb-6">
|
||
<SectionLabel>
|
||
{t('section_underlag')}
|
||
<span className="ml-2 normal-case tracking-normal">({t('underlag_recommended')})</span>
|
||
{underlagAttached && (
|
||
<span className="ml-2 normal-case tracking-normal text-success">
|
||
{'✓'} {t('mark_attached')}
|
||
</span>
|
||
)}
|
||
</SectionLabel>
|
||
{inboxItemId ? (
|
||
<div className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-[13px] text-muted-foreground">
|
||
<FileText className="h-4 w-4 shrink-0" />
|
||
{t('underlag_caption_inbox')}
|
||
</div>
|
||
) : underlagFile ? (
|
||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-4 py-3">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
<div className="min-w-0">
|
||
<p className="truncate text-[13px]">{underlagFile.fileName}</p>
|
||
{(underlagFile.status === 'uploading' || extractionPhase === 'processing') && (
|
||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
{underlagFile.status === 'uploading'
|
||
? t('underlag_uploading')
|
||
: t('underlag_processing')}
|
||
</p>
|
||
)}
|
||
{underlagFile.status === 'error' && (
|
||
<p className="text-xs text-destructive">{underlagFile.error}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={cn(QUIET_LINK_CLASS, 'shrink-0 disabled:opacity-50')}
|
||
onClick={handleRemoveUnderlag}
|
||
disabled={underlagFile.status === 'uploading'}
|
||
aria-label={t('underlag_remove_aria')}
|
||
>
|
||
{t('underlag_remove')}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'w-full rounded-lg border border-dashed border-border px-4 py-6 text-center text-[13px] text-muted-foreground transition-colors hover:bg-secondary/60',
|
||
isDraggingUnderlag && 'border-foreground/40 bg-secondary/60',
|
||
)}
|
||
onClick={() => underlagInputRef.current?.click()}
|
||
onDragOver={(e) => {
|
||
e.preventDefault()
|
||
setIsDraggingUnderlag(true)
|
||
}}
|
||
onDragLeave={(e) => {
|
||
e.preventDefault()
|
||
setIsDraggingUnderlag(false)
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault()
|
||
setIsDraggingUnderlag(false)
|
||
const file = e.dataTransfer.files?.[0]
|
||
if (file) void handleUnderlagFile(file)
|
||
}}
|
||
>
|
||
{t('underlag_drop_prompt')}
|
||
</button>
|
||
<input
|
||
ref={underlagInputRef}
|
||
type="file"
|
||
accept={UNDERLAG_ACCEPT_ATTR}
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
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.
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
{t('extraction_ready_line')}{' '}
|
||
<button type="button" className={QUIET_LINK_CLASS} onClick={applyPendingExtraction}>
|
||
{t('extraction_ready_apply')}
|
||
</button>
|
||
</p>
|
||
) : underlagCaption ? (
|
||
<p className="mt-2 text-xs text-muted-foreground">{underlagCaption}</p>
|
||
) : null}
|
||
</section>
|
||
|
||
{/* ── Leverantör ───────────────────────────────────────── */}
|
||
<section className="py-6">
|
||
<SectionLabel>
|
||
{t('section_supplier')}
|
||
<RequiredMark />
|
||
{watchedSupplierId && (
|
||
<span className="ml-2 normal-case tracking-normal text-success">
|
||
{'✓'} {t('mark_selected')}
|
||
</span>
|
||
)}
|
||
</SectionLabel>
|
||
|
||
{showAISupplierHint && (
|
||
<div className="mb-3 rounded-lg border border-border bg-muted/30 p-4">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div className="flex items-start gap-3">
|
||
<MessageCircle className="h-5 w-5 text-primary shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-sm font-medium">
|
||
{t('ai_suggested_supplier', { name: extractedData?.supplier?.name ?? '' })}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{extractedData?.supplier?.orgNumber
|
||
? t('ai_org_number', { orgNumber: extractedData.supplier.orgNumber })
|
||
: t('ai_no_org_number')}
|
||
{t('ai_supplier_not_in_system')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button type="button" size="sm" onClick={openSupplierDialogPrefilled}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
{t('create_and_select')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<DropdownMenu open={supplierMenuOpen} onOpenChange={setSupplierMenuOpen}>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
ref={supplierTriggerRef}
|
||
className={cn(
|
||
'w-full rounded-lg border border-border px-4 py-4 text-left transition-colors hover:bg-secondary/60',
|
||
settledFields.has('supplier_id') && 'prefill-settle',
|
||
)}
|
||
aria-haspopup="menu"
|
||
aria-expanded={supplierMenuOpen}
|
||
>
|
||
<span className="flex items-center justify-between gap-3">
|
||
<span
|
||
className={cn(
|
||
'font-display text-lg leading-snug',
|
||
selectedSupplier ? 'text-foreground' : 'text-muted-foreground',
|
||
)}
|
||
>
|
||
{selectedSupplier?.name ?? t('supplier_placeholder')}
|
||
</span>
|
||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
</span>
|
||
{selectedSupplier?.org_number && (
|
||
<span className="mt-1 block text-[13px] text-muted-foreground">
|
||
{t('org_number_prefix', { number: selectedSupplier.org_number })}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
className="max-h-80 w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
|
||
onCloseAutoFocus={(e) => {
|
||
// 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 && (
|
||
<div className="px-2 py-2 text-[13px] text-muted-foreground">
|
||
{t('no_suppliers_yet')}
|
||
</div>
|
||
)}
|
||
{suppliers.map((s) => (
|
||
<DropdownMenuItem
|
||
key={s.id}
|
||
onSelect={() => {
|
||
setValue('supplier_id', s.id, { shouldDirty: true })
|
||
requestAnimationFrame(() => invoiceNumberInputRef.current?.focus())
|
||
}}
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm">{s.name}</div>
|
||
{s.org_number && (
|
||
<div className="text-xs text-muted-foreground">
|
||
{t('org_number_prefix', { number: s.org_number })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DropdownMenuItem>
|
||
))}
|
||
{/* No separator over an empty list: it reads as a stray line. */}
|
||
{suppliers.length > 0 && <DropdownMenuSeparator />}
|
||
<DropdownMenuItem onSelect={openSupplierDialogBlank} className="text-muted-foreground">
|
||
{t('add_new_supplier')}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</section>
|
||
|
||
{/* ── Fakturauppgifter ─────────────────────────────────── */}
|
||
<section className="py-6">
|
||
<SectionLabel>
|
||
{t('section_details')}
|
||
<RequiredMark />
|
||
{detailsFilled && (
|
||
<span className="ml-2 normal-case tracking-normal text-success">
|
||
{'✓'} {t('mark_filled')}
|
||
</span>
|
||
)}
|
||
</SectionLabel>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label htmlFor="si-invoice-number" className="text-xs font-normal text-muted-foreground">
|
||
{t('supplier_invoice_number_label')}
|
||
<RequiredMark />
|
||
</Label>
|
||
<AiFilledIndicator active={aiFlags.invoiceNumber} label="AI-fyllt" />
|
||
</div>
|
||
{(() => {
|
||
const { ref: rhfRef, ...rest } = register('supplier_invoice_number')
|
||
return (
|
||
<Input
|
||
id="si-invoice-number"
|
||
placeholder={t('supplier_invoice_number_placeholder')}
|
||
autoComplete="off"
|
||
className={cn('h-9', settledFields.has('supplier_invoice_number') && 'prefill-settle')}
|
||
{...rest}
|
||
ref={(el) => {
|
||
rhfRef(el)
|
||
invoiceNumberInputRef.current = el
|
||
}}
|
||
/>
|
||
)
|
||
})()}
|
||
{duplicateWarning && (
|
||
<p className="text-xs text-destructive" data-ph-mask="">
|
||
{t('duplicate_warning_line', { number: duplicateWarning.number })}
|
||
{duplicateWarning.existingId && (
|
||
<>
|
||
{' '}
|
||
<button
|
||
type="button"
|
||
className="underline underline-offset-2"
|
||
onClick={() => router.push(`/supplier-invoices/${duplicateWarning.existingId}`)}
|
||
>
|
||
{t('duplicate_warning_link')}
|
||
</button>
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label htmlFor="si-invoice-date" className="text-xs font-normal text-muted-foreground">
|
||
{t('invoice_date_label')}
|
||
<RequiredMark />
|
||
</Label>
|
||
<AiFilledIndicator active={aiFlags.invoiceDate} label="AI-fyllt" />
|
||
</div>
|
||
<Input
|
||
id="si-invoice-date"
|
||
type="date"
|
||
className={cn('h-9 tabular-nums', settledFields.has('invoice_date') && 'prefill-settle')}
|
||
{...register('invoice_date')}
|
||
/>
|
||
</div>
|
||
{!watchedPaidPrivately && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label htmlFor="si-due-date" className="text-xs font-normal text-muted-foreground">
|
||
{t('due_date_label')}
|
||
</Label>
|
||
<AiFilledIndicator active={aiFlags.dueDate} label="AI-fyllt" />
|
||
</div>
|
||
<Input
|
||
id="si-due-date"
|
||
type="date"
|
||
className={cn('h-9 tabular-nums', settledFields.has('due_date') && 'prefill-settle')}
|
||
{...register('due_date', {
|
||
onChange: () => {
|
||
dueDateManualRef.current = true
|
||
setDueDateCaption(null)
|
||
},
|
||
})}
|
||
/>
|
||
{dueDateCaption && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{dueDateCaption.type === 'terms'
|
||
? t('due_from_terms_caption', { days: dueDateCaption.days })
|
||
: t('due_on_invoice_caption')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label htmlFor="si-payment-reference" className="text-xs font-normal text-muted-foreground">
|
||
{t('payment_reference_label')}
|
||
</Label>
|
||
<AiFilledIndicator active={aiFlags.paymentReference} label="AI-fyllt" />
|
||
</div>
|
||
<Input
|
||
id="si-payment-reference"
|
||
placeholder={t('payment_reference_placeholder')}
|
||
autoComplete="off"
|
||
className={cn('h-9', settledFields.has('payment_reference') && 'prefill-settle')}
|
||
{...register('payment_reference')}
|
||
/>
|
||
{supplierHasGiro && (
|
||
<p className="text-xs text-muted-foreground">{t('ocr_payment_file_caption')}</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{showNoPeriodWarning && (
|
||
<div
|
||
role="alert"
|
||
className="mt-4 flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 p-3"
|
||
>
|
||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 shrink-0" />
|
||
<div className="flex-1 text-sm text-destructive">
|
||
<p className="font-medium">{t('no_period_warning', { date: watchedInvoiceDate })}</p>
|
||
<p className="mt-0.5">{t('no_period_help')}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* ── Kontering ────────────────────────────────────────── */}
|
||
<section className="py-6">
|
||
<SectionLabel>
|
||
{t('section_accounting')}
|
||
<RequiredMark />
|
||
{fields.length > 0 && (
|
||
<span className="ml-2 normal-case tracking-normal">
|
||
{t('rows_count', { count: fields.length })}
|
||
</span>
|
||
)}
|
||
</SectionLabel>
|
||
|
||
{templateAccountNote &&
|
||
(watchedItems ?? []).some((r) => r.account_number === templateAccountNote.account) && (
|
||
<p className="mb-3 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||
<span aria-hidden className="inline-block h-1.5 w-1.5 rounded-full bg-success" />
|
||
{t('account_from_history', {
|
||
account: templateAccountNote.account,
|
||
counterparty: formatCounterpartyName(templateAccountNote.counterparty),
|
||
})}
|
||
</p>
|
||
)}
|
||
|
||
{/* Non-blocking account-range hint for reverse charge (#863 item 2) */}
|
||
{rcAccountWarningRows.length > 0 && (
|
||
<div
|
||
role="status"
|
||
className="mb-3 flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3"
|
||
>
|
||
<AlertTriangle className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||
<p className="text-sm text-muted-foreground">
|
||
{t('rc_account_warning', {
|
||
count: rcAccountWarningRows.length,
|
||
rows: rcAccountWarningRows.map((i) => i + 1).join(', '),
|
||
})}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 && (
|
||
<div
|
||
role="status"
|
||
className="mb-3 flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3"
|
||
>
|
||
<AlertTriangle className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||
<p className="text-sm text-muted-foreground">
|
||
{t('foreign_zero_vat_warning', {
|
||
count: foreignZeroVatRows.length,
|
||
rows: foreignZeroVatRows.map((i) => i + 1).join(', '),
|
||
})}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[560px] border-collapse text-[13px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b border-border text-left">
|
||
<th className="w-28 pb-2 pr-2">{t('col_account')}</th>
|
||
<th className="pb-2 pr-2">{t('col_description')}</th>
|
||
<th className="w-28 pb-2 pr-2 text-right">
|
||
{vatColsVisible ? t('col_amount_excl') : t('col_amount')}
|
||
</th>
|
||
{vatColsVisible && (
|
||
<>
|
||
<th className="w-32 pb-2 pr-2">
|
||
{watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')}
|
||
</th>
|
||
<th className="w-24 pb-2 pr-2 text-right">
|
||
{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}
|
||
</th>
|
||
</>
|
||
)}
|
||
<th className="w-20 pb-2"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{fields.map((field, index) => (
|
||
<Fragment key={field.id}>
|
||
<tr
|
||
className={cn(
|
||
'group align-middle',
|
||
(canUseAccrual && isAccrualOpen(index)) ||
|
||
(dimensionsEnabled && isDimOpen(index)) ||
|
||
slpRowVisible(index)
|
||
? 'border-0'
|
||
: 'border-b border-border',
|
||
settledFields.has('items') && 'prefill-settle',
|
||
)}
|
||
>
|
||
<td className="py-1 pr-2">
|
||
<Controller
|
||
name={`items.${index}.account_number`}
|
||
control={control}
|
||
render={({ field: f }) => (
|
||
<AccountCombobox
|
||
value={f.value}
|
||
accounts={accounts}
|
||
onChange={(val) => handleAccountChange(index, val)}
|
||
className="h-8 px-2 text-[13px]"
|
||
inputRef={(el) => {
|
||
accountInputRefs.current[index] = el
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
</td>
|
||
<td className="py-1 pr-2">
|
||
<Controller
|
||
name={`items.${index}.description`}
|
||
control={control}
|
||
render={({ field }) => (
|
||
<Input
|
||
placeholder={t('description_placeholder')}
|
||
className={CELL_INPUT_CLASS}
|
||
ref={field.ref}
|
||
value={field.value ?? ''}
|
||
onChange={field.onChange}
|
||
onBlur={field.onBlur}
|
||
/>
|
||
)}
|
||
/>
|
||
</td>
|
||
<td className="py-1 pr-2">
|
||
<Controller
|
||
name={`items.${index}.amount`}
|
||
control={control}
|
||
render={({ field }) => (
|
||
<AmountCell
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
className={cn(CELL_INPUT_CLASS, 'text-right tabular-nums')}
|
||
inputRef={(el) => {
|
||
amountInputRefs.current[index] = el
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
</td>
|
||
{vatColsVisible && (
|
||
<>
|
||
<td className="py-1 pr-2">
|
||
{watchedReverseCharge ? (
|
||
<Controller
|
||
name={`items.${index}.reverse_charge_rate`}
|
||
control={control}
|
||
render={({ field: f }) => (
|
||
<RcRateSelect value={f.value ?? 0.25} onChange={f.onChange} />
|
||
)}
|
||
/>
|
||
) : (
|
||
<Controller
|
||
name={`items.${index}.vat_rate`}
|
||
control={control}
|
||
render={({ field: f }) => (
|
||
<VatRateCell value={f.value} onChange={f.onChange} />
|
||
)}
|
||
/>
|
||
)}
|
||
</td>
|
||
<td className="py-1 pr-2 text-right tabular-nums text-muted-foreground">
|
||
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
|
||
</td>
|
||
</>
|
||
)}
|
||
<td className="py-1 text-right whitespace-nowrap">
|
||
<span className={cn('inline-flex items-center justify-end gap-1', HOVER_REVEAL_CLASS)}>
|
||
{dimensionsEnabled && (
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleDimensions(index)}
|
||
aria-label={t('row_dimensions_aria', { index: index + 1 })}
|
||
aria-pressed={isDimOpen(index)}
|
||
title={t('row_dimensions_title')}
|
||
className={ROW_ICON_BTN_CLASS}
|
||
>
|
||
<Tags
|
||
className={cn(
|
||
'h-3.5 w-3.5',
|
||
isDimOpen(index) ? 'text-foreground' : undefined,
|
||
)}
|
||
/>
|
||
</button>
|
||
)}
|
||
{canUseAccrual && (
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleAccrual(index)}
|
||
aria-label={ta('row_toggle_aria', { index: index + 1 })}
|
||
aria-pressed={isAccrualOpen(index)}
|
||
title={ta('row_toggle')}
|
||
className={ROW_ICON_BTN_CLASS}
|
||
>
|
||
<CalendarClock
|
||
className={cn(
|
||
'h-3.5 w-3.5',
|
||
isAccrualOpen(index) ? 'text-foreground' : undefined,
|
||
)}
|
||
/>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(index)}
|
||
aria-label={t('remove_row_named_aria', {
|
||
index: index + 1,
|
||
description:
|
||
watchedItems?.[index]?.description || t('row_no_description'),
|
||
})}
|
||
className={cn(ROW_ICON_BTN_CLASS, 'hover:text-destructive')}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
{canUseAccrual && isAccrualOpen(index) && (
|
||
<tr className={cn(dimensionsEnabled && isDimOpen(index) ? 'border-0' : 'border-b border-border')}>
|
||
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
|
||
{renderAccrualPanel(index, `accrual-${index}`)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{dimensionsEnabled && isDimOpen(index) && (
|
||
<tr className={cn(slpRowVisible(index) ? 'border-0' : 'border-b border-border')}>
|
||
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
|
||
{renderDimensionsPanel(index)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{slpRowVisible(index) && (
|
||
<tr className="border-b border-border">
|
||
<td colSpan={vatColsVisible ? 6 : 4} className="pb-3">
|
||
{renderSlpPanel(index)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
{/* 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. */}
|
||
<tr>
|
||
<td className="py-2 pr-2">
|
||
<AccountCombobox
|
||
key={entryResetKey}
|
||
value=""
|
||
accounts={accounts}
|
||
onChange={() => {}}
|
||
onCommit={commitEntryAccount}
|
||
className="h-8 px-2 text-[13px]"
|
||
inputRef={(el) => {
|
||
entryInputRef.current = el
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="py-2 pr-2 italic text-muted-foreground/50">
|
||
{t('ghost_description')}
|
||
</td>
|
||
<td className="py-2 pr-2 text-right italic tabular-nums text-muted-foreground/50">
|
||
0
|
||
</td>
|
||
{vatColsVisible && (
|
||
<>
|
||
<td className="py-2 pr-2 italic tabular-nums text-muted-foreground/50">
|
||
25 %
|
||
</td>
|
||
<td className="py-2 pr-2 text-right italic text-muted-foreground/50">-</td>
|
||
</>
|
||
)}
|
||
<td></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ── Förval ───────────────────────────────────────────── */}
|
||
<section className="py-6">
|
||
<SectionLabel>{t('section_forval')}</SectionLabel>
|
||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-[13px] text-muted-foreground">
|
||
<span>{forvalChips.join(' · ')}</span>
|
||
<span aria-hidden="true">{'·'}</span>
|
||
<button
|
||
type="button"
|
||
className="whitespace-nowrap rounded-sm text-foreground underline underline-offset-[3px] transition-colors hover:bg-secondary/60"
|
||
aria-expanded={forvalOpen}
|
||
onClick={() => setForvalOpen((open) => !open)}
|
||
>
|
||
{forvalOpen ? `${t('forval_toggle_close')} ▴` : `${t('forval_toggle_open')} ▾`}
|
||
</button>
|
||
</div>
|
||
{forvalOpen && (
|
||
<div className="mt-4 border-t border-border">
|
||
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
|
||
<label htmlFor="paid_with_private_funds" className="cursor-pointer">
|
||
{t('paid_privately_label')}
|
||
<span className="block text-xs text-muted-foreground">
|
||
{isEF ? t('paid_privately_help_ef') : t('paid_privately_help_ab')}
|
||
</span>
|
||
</label>
|
||
<Controller
|
||
name="paid_with_private_funds"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<Switch
|
||
id="paid_with_private_funds"
|
||
checked={field.value}
|
||
onCheckedChange={field.onChange}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
|
||
<label htmlFor="reverse_charge" className="cursor-pointer">
|
||
{t('reverse_charge_label')}
|
||
<span className="block text-xs text-muted-foreground">
|
||
{t('reverse_charge_help')}
|
||
</span>
|
||
</label>
|
||
<Controller
|
||
name="reverse_charge"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<Switch id="reverse_charge" checked={field.value} onCheckedChange={field.onChange} />
|
||
)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
|
||
<span>{t('currency_label')}</span>
|
||
<Controller
|
||
name="currency"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<Select value={field.value} onValueChange={field.onChange}>
|
||
<SelectTrigger className="h-8 w-28">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="SEK">SEK</SelectItem>
|
||
<SelectItem value="EUR">EUR</SelectItem>
|
||
<SelectItem value="USD">USD</SelectItem>
|
||
<SelectItem value="GBP">GBP</SelectItem>
|
||
<SelectItem value="NOK">NOK</SelectItem>
|
||
<SelectItem value="DKK">DKK</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
/>
|
||
</div>
|
||
{watchedCurrency !== 'SEK' && (
|
||
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
|
||
<span>
|
||
{t('exchange_rate_label')}{' '}
|
||
<span className="text-muted-foreground">{t('exchange_rate_to_sek')}</span>
|
||
</span>
|
||
<Input
|
||
type="number"
|
||
step="0.0001"
|
||
inputMode="decimal"
|
||
placeholder={t('exchange_rate_placeholder')}
|
||
className="h-8 w-36 text-right tabular-nums"
|
||
{...register('exchange_rate', {
|
||
onChange: () => { userTouchedRateRef.current = true },
|
||
})}
|
||
/>
|
||
</div>
|
||
)}
|
||
{(watchedCurrency || 'SEK') === 'SEK' && (
|
||
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
|
||
<label htmlFor="ore-rounding" className="cursor-pointer">
|
||
{t('ore_rounding_label')}
|
||
<span className="block text-xs text-muted-foreground">{t('ore_rounding_help')}</span>
|
||
</label>
|
||
<Switch
|
||
id="ore-rounding"
|
||
checked={oreRounding}
|
||
onCheckedChange={setOreRounding}
|
||
aria-label={t('ore_rounding_label')}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
|
||
<Label htmlFor="si-delivery-date" className="text-[13px] font-normal">
|
||
{t('delivery_date_label')}
|
||
</Label>
|
||
<Input
|
||
id="si-delivery-date"
|
||
type="date"
|
||
className="h-8 w-40 tabular-nums"
|
||
{...register('delivery_date')}
|
||
/>
|
||
</div>
|
||
{dimensionsEnabled && (
|
||
<div className="border-b border-border py-3 text-[13px]">
|
||
<span>{t('row_dimensions_title')}</span>
|
||
<div className="mt-2 max-w-md">
|
||
<LineDimensionFields
|
||
dimensions={defaultDims}
|
||
onChange={setDefaultDimension}
|
||
inputClassName="h-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="py-3 text-[13px]">
|
||
<Label htmlFor="si-notes" className="text-[13px] font-normal">
|
||
{t('notes_label')}
|
||
</Label>
|
||
<Textarea
|
||
id="si-notes"
|
||
placeholder={t('notes_placeholder')}
|
||
className="mt-2"
|
||
{...register('notes')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* ── Summering ────────────────────────────────────────── */}
|
||
<section className="pt-6">
|
||
<SectionLabel>{t('section_summary')}</SectionLabel>
|
||
{vatColsVisible && (
|
||
<>
|
||
<div className="flex items-baseline justify-between border-b border-border py-2 text-[13px]">
|
||
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
|
||
<span className="tabular-nums">{formatCurrency(subtotal, watchedCurrency)}</span>
|
||
</div>
|
||
<div className="flex items-baseline justify-between border-b border-border py-2 text-[13px]">
|
||
<span className="text-muted-foreground">
|
||
{watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}
|
||
</span>
|
||
<span className="tabular-nums">{formatCurrency(totalVat, watchedCurrency)}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
{displayRounding.applies && (
|
||
<div className="flex items-baseline justify-between border-b border-border py-2 text-[13px]">
|
||
<span className="text-muted-foreground">{t('ore_rounding_label')}</span>
|
||
<span className="tabular-nums">
|
||
{formatCurrency(displayRounding.roundingDelta, watchedCurrency)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="flex items-baseline justify-between pt-4">
|
||
<span className="font-display text-lg">{t('total_payable_label')}</span>
|
||
<span className="font-display text-xl tabular-nums">
|
||
{formatCurrency(displayRounding.displayed, watchedCurrency)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Total cross-check (client-only, never sent) */}
|
||
<div className="mt-4 flex items-center justify-between gap-4">
|
||
<Label htmlFor="si-faktura-total" className="text-[13px] font-normal text-muted-foreground">
|
||
{t('crosscheck_label')}{' '}
|
||
<span className="text-muted-foreground">({t('crosscheck_optional')})</span>
|
||
</Label>
|
||
<Input
|
||
id="si-faktura-total"
|
||
type="text"
|
||
inputMode="decimal"
|
||
autoComplete="off"
|
||
placeholder="0,00"
|
||
className={cn(
|
||
'h-8 w-32 text-right tabular-nums',
|
||
settledFields.has('faktura_total') && 'prefill-settle',
|
||
)}
|
||
value={fakturaTotalStr}
|
||
onChange={(e) => setFakturaTotalStr(e.target.value)}
|
||
onBlur={() => {
|
||
const parsed = parseFlexibleNumber(fakturaTotalStr)
|
||
if (parsed != null) setFakturaTotalStr(formatAmount(parsed))
|
||
}}
|
||
/>
|
||
</div>
|
||
{crosscheckDiff != null && (
|
||
<p
|
||
className={cn(
|
||
'mt-2 text-right text-xs',
|
||
crosscheckMatches ? 'text-success' : 'text-destructive',
|
||
)}
|
||
>
|
||
{crosscheckMatches
|
||
? t('crosscheck_match')
|
||
: t('crosscheck_diff', {
|
||
diff:
|
||
(crosscheckDiff > 0 ? '+' : '') +
|
||
formatCurrency(crosscheckDiff, watchedCurrency),
|
||
})}
|
||
</p>
|
||
)}
|
||
|
||
{/* AI totals comparison: only when extracted */}
|
||
{extractedData?.totals && (extractedData.totals.subtotal != null || extractedData.totals.total != null) && (
|
||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||
<span className="text-muted-foreground">{t('ai_totals_label')}</span>
|
||
{extractedData.totals.subtotal != null && (
|
||
<span className="px-2 py-1 rounded-sm bg-muted tabular-nums">
|
||
{t('ai_net', { amount: formatAmount(extractedData.totals.subtotal) })}
|
||
</span>
|
||
)}
|
||
{extractedData.totals.vatAmount != null && (
|
||
<span className="px-2 py-1 rounded-sm bg-muted tabular-nums">
|
||
{t('ai_vat', { amount: formatAmount(extractedData.totals.vatAmount) })}
|
||
</span>
|
||
)}
|
||
{extractedData.totals.total != null && (
|
||
<span className="px-2 py-1 rounded-sm bg-muted tabular-nums">
|
||
{t('ai_total', { amount: formatAmount(extractedData.totals.total) })}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* The page's single ochre line: what stands between the user and
|
||
a registered invoice, sage once nothing does. */}
|
||
<p
|
||
aria-live="polite"
|
||
className={cn('pt-6 text-[12.5px]', nextStep ? 'text-attn' : 'text-success')}
|
||
>
|
||
{nextStep ? (
|
||
<>
|
||
{t('next_step_prefix')}
|
||
<button
|
||
type="button"
|
||
className="underline underline-offset-[3px] hover:opacity-80"
|
||
onClick={() => focusMissingField(nextStep.field, nextStep.rowIndex)}
|
||
>
|
||
{nextStep.label}
|
||
</button>
|
||
.
|
||
</>
|
||
) : (
|
||
readyLineText
|
||
)}
|
||
</p>
|
||
</section>
|
||
</div>
|
||
|
||
{/* ── Sticky action bar (binds to the dialog scroll container in bare
|
||
mode, to the page panel scroll on the standalone page) ── */}
|
||
<div
|
||
className={cn(
|
||
'sticky bottom-0 z-10 mt-6 border-t border-border bg-background',
|
||
bare && '-mx-6 -mb-6 rounded-b-xl px-6',
|
||
)}
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-3 py-3">
|
||
<div className="whitespace-nowrap text-[13px] text-muted-foreground">
|
||
{t('total_label')}
|
||
<strong className="ml-2 text-[15px] font-semibold tabular-nums text-foreground">
|
||
{formatCurrency(displayRounding.displayed, watchedCurrency)}
|
||
</strong>
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
className={cn(QUIET_LINK_CLASS, 'disabled:opacity-50')}
|
||
onClick={handleCancel}
|
||
disabled={isSubmitting || documentUploadInProgress}
|
||
>
|
||
{t('cancel')}
|
||
</button>
|
||
{!watchedPaidPrivately && (
|
||
<Button
|
||
type="submit"
|
||
variant="outline"
|
||
disabled={isSubmitting || !canWrite}
|
||
onClick={() => { submitModeRef.current = 'register_and_match' }}
|
||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||
>
|
||
<Link2 className="mr-2 h-4 w-4" />
|
||
{t('register_and_mark_paid')}
|
||
</Button>
|
||
)}
|
||
{/* Always enabled pre-click for writable users: clicking with
|
||
something missing routes focus to the first missing field
|
||
(submit-time hard blocks stay in onSubmit). Viewers keep the
|
||
disabled + lock treatment: authorization, not validation. */}
|
||
<Button
|
||
type="submit"
|
||
disabled={isSubmitting || !canWrite}
|
||
onClick={() => { submitModeRef.current = 'register' }}
|
||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
{t('registering')}
|
||
</>
|
||
) : !canWrite ? (
|
||
<>
|
||
<Lock className="mr-2 h-4 w-4" />
|
||
{primaryLabel}
|
||
</>
|
||
) : (
|
||
primaryLabel
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
{/* Review dialog (AB only: also shown after a bank transaction is picked
|
||
in the register-and-match flow). */}
|
||
{pendingData && !isEF && showReview && (() => {
|
||
const reviewSupplier = suppliers.find((s) => s.id === pendingData.supplier_id)
|
||
if (!reviewSupplier) return null
|
||
return (
|
||
<ConfirmationDialog
|
||
open={showReview}
|
||
onOpenChange={setShowReview}
|
||
onConfirm={handleConfirm}
|
||
isSubmitting={isSubmitting}
|
||
title={t('review_dialog_title')}
|
||
warningText={t('review_dialog_warning')}
|
||
confirmLabel={t('review_dialog_confirm')}
|
||
>
|
||
<SupplierInvoiceReviewContent
|
||
supplier={reviewSupplier}
|
||
invoiceNumber={pendingData.supplier_invoice_number}
|
||
invoiceDate={pendingData.invoice_date}
|
||
dueDate={pendingData.due_date}
|
||
deliveryDate={pendingData.delivery_date || undefined}
|
||
currency={pendingData.currency}
|
||
exchangeRate={pendingData.exchange_rate || undefined}
|
||
reverseCharge={pendingData.reverse_charge}
|
||
paymentReference={pendingData.payment_reference || undefined}
|
||
items={pendingData.items}
|
||
subtotal={subtotal}
|
||
totalVat={totalVat}
|
||
total={total}
|
||
/>
|
||
</ConfirmationDialog>
|
||
)
|
||
})()}
|
||
|
||
{/* Bank transaction picker for "Registrera & markera som betald" */}
|
||
<BankTransactionPicker
|
||
open={showBankPicker}
|
||
onOpenChange={(open) => {
|
||
setShowBankPicker(open)
|
||
if (!open) {
|
||
submitModeRef.current = 'register'
|
||
setPendingTransactionId(null)
|
||
}
|
||
}}
|
||
targetAmount={total}
|
||
targetCurrency={watchedCurrency}
|
||
onPick={handlePickTransaction}
|
||
/>
|
||
|
||
{/* New supplier dialog */}
|
||
<Dialog open={showNewSupplier} onOpenChange={setShowNewSupplier}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('new_supplier_dialog_title')}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>{t('new_supplier_name_label')}<RequiredMark /></Label>
|
||
<Input
|
||
placeholder={t('new_supplier_name_placeholder')}
|
||
value={newSupplier.name}
|
||
onChange={(e) => setNewSupplier((p) => ({ ...p, name: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>{t('new_supplier_type_label')}</Label>
|
||
<Select
|
||
value={newSupplier.supplier_type}
|
||
onValueChange={(v) => setNewSupplier((p) => ({ ...p, supplier_type: v }))}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="swedish_business">{t('supplier_type_swedish')}</SelectItem>
|
||
<SelectItem value="eu_business">{t('supplier_type_eu')}</SelectItem>
|
||
<SelectItem value="non_eu_business">{t('supplier_type_non_eu')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>{t('new_supplier_org_number_label')}</Label>
|
||
<Input
|
||
placeholder="XXXXXX-XXXX"
|
||
value={newSupplier.org_number}
|
||
onChange={(e) => setNewSupplier((p) => ({ ...p, org_number: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>{t('new_supplier_vat_number_label')}</Label>
|
||
<Input
|
||
placeholder="SE..."
|
||
value={newSupplier.vat_number}
|
||
onChange={(e) => setNewSupplier((p) => ({ ...p, vat_number: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>{t('new_supplier_address_label')}</Label>
|
||
<Input
|
||
placeholder={t('new_supplier_address_placeholder')}
|
||
value={newSupplier.address_line1}
|
||
onChange={(e) => setNewSupplier((p) => ({ ...p, address_line1: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>{t('new_supplier_bankgiro_label')}</Label>
|
||
<Input
|
||
placeholder="XXX-XXXX"
|
||
value={newSupplier.bankgiro}
|
||
onChange={(e) => setNewSupplier((p) => ({ ...p, bankgiro: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>{t('new_supplier_plusgiro_label')}</Label>
|
||
<Input
|
||
placeholder="XXXXXX-X"
|
||
value={newSupplier.plusgiro}
|
||
onChange={(e) => setNewSupplier((p) => ({ ...p, plusgiro: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>{t('new_supplier_default_account_label')}</Label>
|
||
<Input
|
||
placeholder={t('new_supplier_default_account_placeholder')}
|
||
value={newSupplier.default_expense_account}
|
||
onChange={(e) => setNewSupplier((p) => ({ ...p, default_expense_account: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowNewSupplier(false)}>
|
||
{t('cancel')}
|
||
</Button>
|
||
<Button onClick={handleCreateSupplier} disabled={isCreatingSupplier}>
|
||
{isCreatingSupplier ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
{t('creating')}
|
||
</>
|
||
) : (
|
||
t('create_supplier_button')
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Duplicate-number conflict dialog */}
|
||
<Dialog open={!!conflict} onOpenChange={(open) => !open && setConflict(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||
{t('duplicate_dialog_title')}
|
||
</DialogTitle>
|
||
{/* data-ph-mask: the conflict message carries the invoice number */}
|
||
<DialogDescription data-ph-mask="">{conflict?.message}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="flex flex-col gap-2">
|
||
{conflict?.existing && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => router.push(`/supplier-invoices/${conflict.existing!.id}`)}
|
||
disabled={isResolvingConflict}
|
||
>
|
||
{t('show_existing_invoice')}
|
||
</Button>
|
||
)}
|
||
{conflict?.existing?.status === 'credited' && (
|
||
<Button onClick={handleUncreditAndRetry} disabled={isResolvingConflict}>
|
||
{isResolvingConflict ? t('processing') : t('uncredit_and_retry')}
|
||
</Button>
|
||
)}
|
||
<Button variant="ghost" onClick={handlePickNewNumber} disabled={isResolvingConflict}>
|
||
{t('use_different_number')}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|