diff --git a/app/api/supplier-invoices/exists/__tests__/route.test.ts b/app/api/supplier-invoices/exists/__tests__/route.test.ts new file mode 100644 index 00000000..4cfb7008 --- /dev/null +++ b/app/api/supplier-invoices/exists/__tests__/route.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' + +describe('GET /api/supplier-invoices/exists', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/supplier-invoices/exists', { + searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' }, + }) + const response = await GET(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when supplier_id is not a uuid', async () => { + const request = createMockRequest('/api/supplier-invoices/exists', { + searchParams: { supplier_id: 'not-a-uuid', number: 'F-2026-881' }, + }) + const response = await GET(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ type: string }>(response) + + expect(status).toBe(400) + expect(body.type).toBe('validation_error') + }) + + it('returns 400 when number is missing', async () => { + const request = createMockRequest('/api/supplier-invoices/exists', { + searchParams: { supplier_id: VALID_UUID }, + }) + const response = await GET(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ type: string }>(response) + + expect(status).toBe(400) + expect(body.type).toBe('validation_error') + }) + + it('returns exists: false when no matching invoice', async () => { + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/exists', { + searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' }, + }) + const response = await GET(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { exists: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ exists: false }) + }) + + it('returns exists: true with the existing invoice details', async () => { + const existing = { id: 'si-1', supplier_invoice_number: 'F-2026-881', status: 'approved' } + enqueue({ data: existing, error: null }) + + const request = createMockRequest('/api/supplier-invoices/exists', { + searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' }, + }) + const response = await GET(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + data: { exists: boolean; existing: typeof existing } + }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ exists: true, existing }) + }) + + it('mirrors the partial unique index predicate: excludes credited/reversed', async () => { + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/exists', { + searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' }, + }) + await GET(request, { params: Promise.resolve({}) }) + + expect(findCall('supplier_invoices', 'not')).toEqual([ + 'status', + 'in', + '(credited,reversed)', + ]) + expect(findCall('supplier_invoices', 'eq')).toEqual(['company_id', 'company-1']) + }) + + it('returns 500 on database error', async () => { + enqueue({ data: null, error: { message: 'DB error' } }) + + const request = createMockRequest('/api/supplier-invoices/exists', { + searchParams: { supplier_id: VALID_UUID, number: 'F-2026-881' }, + }) + const response = await GET(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(500) + }) +}) diff --git a/app/api/supplier-invoices/exists/route.ts b/app/api/supplier-invoices/exists/route.ts new file mode 100644 index 00000000..bbe2376a --- /dev/null +++ b/app/api/supplier-invoices/exists/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' +import { SupplierInvoiceExistsQuerySchema } from '@/lib/api/schemas' +import { errorResponse } from '@/lib/errors/get-structured-error' + +/** + * GET /api/supplier-invoices/exists?supplier_id=&number= + * + * Index-only pre-submit duplicate lookup for the supplier-invoice editor's + * advisory warning. Mirrors the partial unique index + * idx_supplier_invoices_company_supplier_number: + * (company_id, supplier_id, supplier_invoice_number) + * WHERE supplier_invoice_number IS NOT NULL AND status NOT IN + * ('credited','reversed'). The create route's structured 409 remains the + * authoritative backstop; this endpoint only powers the early warning. + */ +export const GET = withRouteContext( + 'supplier_invoice.exists', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const validation = validateQuery(request, SupplierInvoiceExistsQuerySchema, { + log, + operation: 'supplier_invoice.exists', + }) + if (!validation.success) return validation.response + const { supplier_id, number } = validation.data + + const { data, error } = await supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, status') + .eq('company_id', companyId) + .eq('supplier_id', supplier_id) + .eq('supplier_invoice_number', number) + .not('status', 'in', '(credited,reversed)') + .limit(1) + .maybeSingle() + + if (error) { + log.error('supplier_invoice exists lookup failed', error) + return errorResponse(error, log, { requestId }) + } + + return NextResponse.json({ + data: data ? { exists: true, existing: data } : { exists: false }, + }) + }, +) diff --git a/app/globals.css b/app/globals.css index 2ee91ceb..cc1dd1d4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -825,3 +825,16 @@ input[type="number"] { opacity: 0.35; } } + +/* Brief sage settle tint on tolkning-filled fields (supplier-invoice + dokument-först flow): the field flashes softly as the extraction lands and + fades back to its normal surface. Runs once per class application; the + global prefers-reduced-motion block collapses the animation, so reduced + motion sees the filled value with no flash. */ +@keyframes prefill-settle { + 0% { background-color: hsl(var(--success) / 0.16); } + 100% { background-color: transparent; } +} +.prefill-settle { + animation: prefill-settle 0.9s ease-out both; +} diff --git a/components/supplier-invoices/NewSupplierInvoiceDialog.tsx b/components/supplier-invoices/NewSupplierInvoiceDialog.tsx index 6054c3fd..3e5a6d2c 100644 --- a/components/supplier-invoices/NewSupplierInvoiceDialog.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceDialog.tsx @@ -37,7 +37,7 @@ export default function NewSupplierInvoiceDialog({ return ( - // Särskild löneskatt på pensionskostnader: booking injects a self-balancing - // 7533 D / 2514 K pair at 24.26 % of the line amount. Only offered on 741x - // pension-premium accounts; never changes the invoice total. - apply_slp?: boolean -} - -// The existing invoice surfaced on a duplicate-number conflict, used to drive -// the resolution dialog (open it / uncredit-and-retry). -interface ExistingSupplierInvoice { - id: string - supplier_invoice_number: string - status: string - credit_note_id: string | null -} - -// Canonical create/convert response. On failure `error` is the structured -// envelope's inner object ({ code, message, details }); a few legacy convert -// paths still return a flat string, so accept both. -interface CreateResult { - data?: { id: string; arrival_number: number } - warnings?: Array<{ code: string; message: string }> - error?: - | string - | { - code?: string - message?: string - message_en?: string - details?: { existing?: ExistingSupplierInvoice | null } - } -} - -interface FormData { - supplier_id: string - supplier_invoice_number: string - invoice_date: string - due_date: string - delivery_date: string - currency: string - exchange_rate: string - reverse_charge: boolean - payment_reference: string - notes: string - paid_with_private_funds: boolean - items: LineItem[] -} +// 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 @@ -125,137 +79,13 @@ function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } -function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatment { - if (reverseCharge) return 'reverse_charge' - - const rates = new Set(items.map((i) => i.vat_rate)) - if (rates.size === 1) { - const rate = rates.values().next().value! - if (rate === 0.25) return 'standard_25' - if (rate === 0.12) return 'reduced_12' - if (rate === 0.06) return 'reduced_6' - if (rate === 0) return 'exempt' - } - - return 'standard_25' -} - -// AI returns VAT as integer percent (25, 12, 6, 0). The form stores decimals. -function vatRateFromAi(rate: number | null | undefined): number { - if (rate == null) return 0.25 - if (rate === 25) return 0.25 - if (rate === 12) return 0.12 - if (rate === 6) return 0.06 - return 0 -} - -function rateToPctString(rate: number): string { - const pct = Math.round(rate * 10000) / 100 - return Number.isFinite(pct) ? String(pct) : '' -} - -function VatRateCell({ value, onChange }: { value: number; onChange: (v: number) => void }) { - const t = useTranslations('supplier_invoice_editor') - const inputRef = useRef(null) - // Local draft so the user can type "12," or "12." mid-keystroke without the - // controlled input snapping back to a parsed integer. - const [draft, setDraft] = useState(() => rateToPctString(value)) - - // Re-sync from form value only when the field isn't focused: keeps AI - // prefill / supplier defaults / dropdown picks flowing in without clobbering - // active typing. - useEffect(() => { - if (document.activeElement !== inputRef.current) { - setDraft(rateToPctString(value)) - } - }, [value]) - - return ( -
-
- e.currentTarget.select()} - onBlur={() => setDraft(rateToPctString(value))} - onChange={(e) => { - const raw = e.target.value - // Strict whitelist: digits with at most one decimal separator. - // Blocks "2-22", "100-2", "1.2.3", letters, signs: the keystroke - // is dropped before reaching the draft. - if (raw !== '' && !/^\d*[.,]?\d*$/.test(raw)) return - const normalized = raw.replace(',', '.') - if (normalized === '' || normalized === '.') { - setDraft(raw) - onChange(0) - return - } - const parsed = parseFloat(normalized) - if (!Number.isFinite(parsed)) { - setDraft(raw) - return - } - const clamped = Math.min(100, Math.max(0, parsed)) - // Snap the draft back when the parsed value falls outside [0, 100] - // so the input can never display a rate the form won't apply. - setDraft(clamped === parsed ? raw : String(clamped)) - onChange(clamped / 100) - }} - className="text-right tabular-nums pr-6" - aria-label={t('col_vat_rate')} - /> - - % - -
- - - - - - {LEGAL_VAT_RATES.map((preset) => ( - onChange(preset)} - className="justify-end tabular-nums" - > - {Math.round(preset * 100)} % - - ))} - - -
- ) -} - -// Self-assessment rate picker shown in place of the Momssats cell when an -// invoice is reverse charge. The supplier charges no VAT (the line vat_rate is -// 0); this is the Swedish statutory rate the buyer self-assesses at: 25% -// huvudregeln for EU services, 12%/6% for reduced-rated services (ML 6 kap 34 §). -function RcRateSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) { - const t = useTranslations('supplier_invoice_editor') - return ( - - ) +// Accepts sv-SE formatted numbers ("12 345,67" with regular, no-break or +// narrow no-break group separators) as well as plain "12345.67". +function parseFlexibleNumber(s: string): number | null { + const cleaned = s.replace(/[\s  ]/g, '').replace(',', '.') + if (!cleaned) return null + const n = parseFloat(cleaned) + return Number.isFinite(n) ? n : null } const EMPTY_NEW_SUPPLIER: NewSupplierForm = { @@ -269,6 +99,39 @@ const EMPTY_NEW_SUPPLIER: NewSupplierForm = { default_expense_account: '', } +// Dense in-table inputs: leaf tier inside the Kontering table surface. +const CELL_INPUT_CLASS = + 'h-8 rounded-sm border-transparent bg-transparent px-2 text-[13px] hover:bg-secondary/60 focus-visible:bg-card' +// Row controls: 24x24 hit area (pre-approved dense-row exception to the 40px +// icon-button floor), revealed on hover/focus-within, always on coarse pointers. +const ROW_ICON_BTN_CLASS = + 'inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground' + +const UNDERLAG_ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] +const UNDERLAG_ACCEPT_ATTR = '.pdf,.jpg,.jpeg,.png,.webp' +const UNDERLAG_MAX_SIZE = 10 * 1024 * 1024 + +// A deferred web upload returns an empty extraction skeleton (confidence 0) +// while processing; only apply a prefill that actually carries content. +function hasExtractionContent(e: InvoiceExtractionResult | null | undefined): boolean { + if (!e) return false + return Boolean( + e.supplier?.name || + e.invoice?.invoiceNumber || + e.invoice?.invoiceDate || + (e.lineItems && e.lineItems.length > 0) || + e.totals?.total != null, + ) +} + +function SectionLabel({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + export interface NewSupplierInvoiceFormProps { /** Invoice-inbox item to convert; prefills the form from its AI extraction. */ inboxItemId?: string | null @@ -321,63 +184,78 @@ export default function NewSupplierInvoiceForm({ else router.push(afterCreate(invoiceId)) } - const [suppliers, setSuppliers] = useState([]) - const [suppliersLoaded, setSuppliersLoaded] = useState(false) - const [accounts, setAccounts] = useState([]) - const [entityType, setEntityType] = useState('enskild_firma') - const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') - // Öresavrundning is display-only; defaults to the company-wide setting and is - // overridable per invoice via the toggle in the totals section. - const [oreRounding, setOreRounding] = useState(true) - // Dimension tagging (kostnadsställe/projekt). Affordances render only when - // company_settings.dimensions_enabled: the same UI-visibility gate as - // JournalEntryForm. defaultDims is the invoice-level default bag; per-item - // bags live on the form's items and merge over it server-side. - const [dimensionsEnabled, setDimensionsEnabled] = useState(false) - // Icke momsregistrerad verksamhet has no right to deduct input VAT: the - // moms controls disappear and every line books at 0 % (the gross amount IS - // the cost). Defaults true so registered companies keep the 25 % prefill - // while /api/settings is still in flight. - const [vatRegistered, setVatRegistered] = useState(true) + const { + suppliers, + setSuppliers, + suppliersLoaded, + accounts, + entityType, + accountingMethod, + oreRounding, + setOreRounding, + dimensionsEnabled, + vatRegistered, + periods, + periodsLoaded, + } = useSupplierInvoiceData() + const [defaultDims, setDefaultDims] = useState>({}) - const [periods, setPeriods] = useState([]) - const [periodsLoaded, setPeriodsLoaded] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) - const [showReview, setShowReview] = useState(false) - const [pendingData, setPendingData] = useState(null) const [showNewSupplier, setShowNewSupplier] = useState(false) const [isCreatingSupplier, setIsCreatingSupplier] = useState(false) const [pendingSupplierSelect, setPendingSupplierSelect] = useState(null) - const [advancedOpen, setAdvancedOpen] = useState(false) const [newSupplier, setNewSupplier] = useState(EMPTY_NEW_SUPPLIER) const [documentFiles, setDocumentFiles] = useState([]) const documentFilesRef = useRef([]) const createFinishedRef = useRef(false) - - // Inbox/AI state - const [extractedData, setExtractedData] = useState(null) - const [originalExtracted, setOriginalExtracted] = useState(null) - const [hasMatchedSupplier, setHasMatchedSupplier] = useState(false) - const [isLoadingInbox, setIsLoadingInbox] = useState(!!inboxItemId) - const [hasPrefilled, setHasPrefilled] = useState(false) - - // Match-on-create state - const [showBankPicker, setShowBankPicker] = useState(false) - const [pendingTransactionId, setPendingTransactionId] = useState(null) - // The button's onClick and the form's onSubmit run in the same React event - // batch, so a `useState`-backed submitMode would still hold the previous - // render's value when onSubmit reads it. A ref bridges the two synchronous - // handlers; the matching state mirror only drives the review-dialog UI. - const submitModeRef = useRef<'register' | 'register_and_match'>('register') - - // Conflict state for duplicate-supplier-invoice-number - const [conflict, setConflict] = useState<{ - message: string - existing: ExistingSupplierInvoice | null - } | null>(null) - const [isResolvingConflict, setIsResolvingConflict] = useState(false) const invoiceNumberInputRef = useRef(null) + // Dokument-först state: a standalone upload routed through the invoice-inbox + // pipeline gets an inbox item id (extraction + convert endpoint); a fallback + // upload through /api/documents stays a plain attached document. + const [uploadedInboxItemId, setUploadedInboxItemId] = useState(null) + const uploadedInboxItemIdRef = useRef(null) + const [extractionPhase, setExtractionPhase] = useState<'idle' | 'processing' | 'done'>('idle') + const extractionPollTokenRef = useRef(0) + const underlagInputRef = useRef(null) + const [isDraggingUnderlag, setIsDraggingUnderlag] = useState(false) + // Deferred tolkning that landed while the user was already typing: buffered + // instead of applied, surfaced as a quiet click-to-apply line (see + // applyExtractionIfPristine below). + const [pendingExtraction, setPendingExtraction] = useState(null) + + // Fields the tolkning just filled: they get the brief settle tint (the CSS + // animation is gated on prefers-reduced-motion in globals.css). + const [settledFields, setSettledFields] = useState>(() => new Set()) + + // Terms-based due date (change 4): auto-filled from the supplier's + // default_payment_terms until the user (or the AI) sets it explicitly. + const dueDateManualRef = useRef(false) + const lastAutoDueRef = useRef(null) + const [dueDateCaption, setDueDateCaption] = useState< + { type: 'terms'; days: number } | { type: 'on_invoice' } | null + >(null) + + // Pre-submit duplicate advisory (change 3): debounced lookup against + // GET /api/supplier-invoices/exists; the create route's 409 stays the backstop. + const [duplicateWarning, setDuplicateWarning] = useState<{ + number: string + existingId: string | null + } | null>(null) + const duplicateSeqRef = useRef(0) + + // Total cross-check (change 2): client-only compare, never in the payload. + const [fakturaTotalStr, setFakturaTotalStr] = useState('') + + // Shell state + const [forvalOpen, setForvalOpen] = useState(false) + const [supplierMenuOpen, setSupplierMenuOpen] = useState(false) + const supplierTriggerRef = useRef(null) + const entryInputRef = useRef(null) + const [entryResetKey, setEntryResetKey] = useState(0) + const amountInputRefs = useRef>({}) + const [pendingAmountFocus, setPendingAmountFocus] = useState(null) + const accountInputRefs = useRef>({}) + const { register, control, handleSubmit, watch, setValue, getValues, reset, formState: { isDirty } } = useForm({ defaultValues: { supplier_id: '', @@ -391,22 +269,47 @@ export default function NewSupplierInvoiceForm({ payment_reference: '', notes: '', paid_with_private_funds: false, - // account_number is deliberately empty: a silent prefilled expense - // account (the old '5010' Lokalhyra seed) produced legally wrong - // verifikat whenever the user didn't notice it. An explicit choice is - // required; the supplier's default_expense_account fills it when set. - items: [{ description: '', amount: 0, account_number: '', vat_rate: 0.25, reverse_charge_rate: 0.25 }], + // 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 }) @@ -428,9 +331,61 @@ export default function NewSupplierInvoiceForm({ 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 { @@ -481,156 +436,21 @@ export default function NewSupplierInvoiceForm({ suppliers.find((s) => s.id === watchedSupplierId)?.supplier_type, ) - useEffect(() => { - fetchSuppliers() - fetchAccounts() - fetchEntityType() - fetchPeriods() - }, []) - - // One-shot: load inbox item and prefill form. Runs after suppliers are - // loaded so we can resolve matched_supplier_id to a real picker value. - // Gate on `suppliersLoaded`, not `suppliers.length > 0`: otherwise the - // effect never fires for users who haven't booked a supplier yet and - // the "Laddar uppgifter från inkorgen…" spinner sticks forever. - useEffect(() => { - if (!inboxItemId || hasPrefilled || !suppliersLoaded) return - let cancelled = false - - ;(async () => { - try { - const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${inboxItemId}`) - const json = await res.json() - if (cancelled) return - if (!res.ok) { - toast({ - title: t('inbox_load_failed_title'), - description: json?.error || t('inbox_load_failed_description'), - variant: 'destructive', - }) - setIsLoadingInbox(false) - return - } - - const item = json.data as { - id: string - extracted_data: InvoiceExtractionResult | null - matched_supplier_id: string | null - document_id: string | null - } - const extracted = item.extracted_data - if (!extracted) { - setIsLoadingInbox(false) - setHasPrefilled(true) - return - } - - setExtractedData(extracted) - setOriginalExtracted(extracted) - - // Supplier - if (item.matched_supplier_id && suppliers.find((s) => s.id === item.matched_supplier_id)) { - setValue('supplier_id', item.matched_supplier_id) - setHasMatchedSupplier(true) - } - - // Scalar invoice fields - if (extracted.invoice?.invoiceNumber) { - setValue('supplier_invoice_number', extracted.invoice.invoiceNumber) - } - if (extracted.invoice?.invoiceDate) { - setValue('invoice_date', extracted.invoice.invoiceDate) - } - if (extracted.invoice?.dueDate) { - setValue('due_date', extracted.invoice.dueDate) - } - if (extracted.invoice?.paymentReference) { - setValue('payment_reference', extracted.invoice.paymentReference) - } - if (extracted.invoice?.currency) { - setValue('currency', extracted.invoice.currency) - } - - // Line items: keep the single empty default if AI returned nothing, - // otherwise replace it with the extracted lines. When the document - // states a service window of 2+ calendar months (insurance period, - // license term), pre-fill periodisering on every positive line: the - // user sees the panel and can remove it before booking. - if (extracted.lineItems && extracted.lineItems.length > 0) { - // AI-extracted values are untrusted input: only accept strict - // ISO-8601 dates before they reach form state (and later the API). - const isIsoDate = (v: unknown): v is string => - typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) - const spsRaw = extracted.invoice?.servicePeriodStart - const speRaw = extracted.invoice?.servicePeriodEnd - const sps = isIsoDate(spsRaw) ? spsRaw : null - const spe = isIsoDate(speRaw) ? speRaw : null - let prefillAccrual = false - if (sps && spe && spe >= sps) { - try { - prefillAccrual = countCalendarMonths(sps, spe) >= 2 - } catch { - prefillAccrual = false - } - } - replace( - extracted.lineItems.map((li) => { - const amount = typeof li.lineTotal === 'number' ? li.lineTotal : 0 - const withAccrual = prefillAccrual && amount > 0 - return { - description: li.description || '', - amount, - // Extraction never suggests accounts (forcibly nulled at parse - // time) and a silent default misbooks: leave empty so the user - // (or the supplier default) makes the call. - account_number: '', - // Deliberately unconditional: for icke momsregistrerade the - // zeroing effect below grosses the net amount up by this rate - // before forcing it to 0, so the rate must arrive intact. - vat_rate: vatRateFromAi(li.vatRate), - accrual_period_start: withAccrual ? (sps as string) : undefined, - accrual_period_end: withAccrual ? (spe as string) : undefined, - // No account yet → generic 1790; toggleAccrual re-suggests the - // same way once the user picks one. - accrual_balance_account: withAccrual - ? suggestBalanceAccount('expense', '') - : undefined, - } - }), - ) - } - - // Treat the AI prefill as the new baseline: otherwise the unsaved- - // changes prompt fires the moment the user navigates away, even if - // they didn't touch anything. - reset(getValues()) - setHasPrefilled(true) - } catch (err) { - if (cancelled) return - toast({ - title: t('inbox_load_failed_title'), - description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), - variant: 'destructive', - }) - } finally { - if (!cancelled) setIsLoadingInbox(false) - } - })() - - return () => { - cancelled = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inboxItemId, suppliersLoaded, suppliers]) - - // Auto-fill due date and defaults when supplier is selected: but never - // overwrite a value the AI already filled in for us. + // 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 the counterparty-history prefill, so a supplier SWITCH can - // un-plant them: without this, supplier A's history account survives into - // supplier B's invoice and silently blocks B's own default_expense_account - // (the fill branches only touch empty rows). - const plantedRef = useRef<{ account: string; rows: number[] } | 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 @@ -639,30 +459,48 @@ export default function NewSupplierInvoiceForm({ // 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; plant: boolean; counterparty?: string } | null>(null) + const pendingAccountFillRef = useRef<{ account: string; counterparty?: string } | null>(null) const [accountFillTick, setAccountFillTick] = useState(0) - function requestAccountFill(account: string, plant: boolean, counterparty?: string) { - pendingAccountFillRef.current = { account, plant, counterparty } + function requestAccountFill(account: string, counterparty?: string) { + pendingAccountFillRef.current = { account, counterparty } setAccountFillTick((t) => t + 1) } useEffect(() => { if (accounts.length === 0 || !pendingAccountFillRef.current) return - const { account, plant, counterparty } = pendingAccountFillRef.current + const { account, counterparty } = pendingAccountFillRef.current pendingAccountFillRef.current = null const items = getValues('items') - const appliedRows: number[] = [] - items.forEach((row, i) => { - if (!row.account_number) { - // Same path as a manual pick: konto default moms rides along. - handleAccountChange(i, account) - appliedRows.push(i) + 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 (appliedRows.length > 0 && plant && counterparty) { - plantedRef.current = { account, rows: appliedRows } - setTemplateAccountNote({ account, counterparty }) + if (counterparty) setTemplateAccountNote({ account, counterparty }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [accounts, accountFillTick]) @@ -676,30 +514,35 @@ export default function NewSupplierInvoiceForm({ if (plantedRef.current) { const { account, rows } = plantedRef.current const planted = getValues('items') - rows.forEach((i) => { - if (planted[i]?.account_number === account) { - // Clear only the account; the rate is left for the next fill or - // manual pick to settle (handleAccountChange reapplies konto - // defaults), so an AI-extracted rate is never clobbered here. - setValue(`items.${i}.account_number`, '') + // 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 } - const invoiceDate = watch('invoice_date') - const currentDue = watch('due_date') - if (invoiceDate && !currentDue) { - const due = new Date(invoiceDate) - due.setDate(due.getDate() + supplier.default_payment_terms) - setValue('due_date', due.toISOString().split('T')[0]) - } - if (supplier.default_expense_account && fields.length > 0) { - // Fill every row the user hasn't assigned yet: an empty account is the - // only signal needed (rows start empty by design, no seeded default). + 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, false) + requestAccountFill(supplier.default_expense_account) } if (supplier.default_currency && watch('currency') === 'SEK') { setValue('currency', supplier.default_currency) @@ -714,7 +557,7 @@ export default function NewSupplierInvoiceForm({ // 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 && fields.length > 0 && supplier.name?.trim()) { + if (!supplier.default_expense_account && supplier.name?.trim()) { let cancelled = false ;(async () => { try { @@ -729,7 +572,7 @@ export default function NewSupplierInvoiceForm({ 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, true, match.template.counterparty_name) + requestAccountFill(debit, match.template.counterparty_name) } catch { // Prefill is best-effort; the rows stay blank. } @@ -740,6 +583,94 @@ export default function NewSupplierInvoiceForm({ // 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 @@ -792,54 +723,6 @@ export default function NewSupplierInvoiceForm({ } }, [suppliers, pendingSupplierSelect, setValue]) - async function fetchSuppliers() { - try { - const res = await fetch('/api/suppliers') - const { data } = await res.json() - setSuppliers(data || []) - } finally { - setSuppliersLoaded(true) - } - } - - async function fetchAccounts() { - const res = await fetch('/api/bookkeeping/accounts') - const { data } = await res.json() - setAccounts(data || []) - } - - async function fetchEntityType() { - try { - const res = await fetch('/api/settings') - const { data } = await res.json() - if (data?.entity_type) setEntityType(data.entity_type) - // Cash method books at payment, not registration: drives whether the - // out-of-period warning is relevant (see willBookAtRegistration below). - if (data?.accounting_method === 'cash' || data?.accounting_method === 'accrual') { - setAccountingMethod(data.accounting_method) - } - if (typeof data?.ore_rounding === 'boolean') setOreRounding(data.ore_rounding) - setDimensionsEnabled(data?.dimensions_enabled === true) - // Only an explicit false gates: a missing column or failed fetch keeps - // the registered-company behavior. - if (data?.vat_registered === false) setVatRegistered(false) - } catch { - // Default to enskild_firma / accrual, dimension affordances hidden - } - } - - async function fetchPeriods() { - try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - const { data } = await res.json() - setPeriods(data || []) - } catch { - // Non-critical: the server still hard-blocks an out-of-period booking. - } finally { - setPeriodsLoaded(true) - } - } - function handleAccountChange(index: number, accountNumber: string) { setValue(`items.${index}.account_number`, accountNumber) const currentDesc = watch(`items.${index}.description`) @@ -866,6 +749,53 @@ export default function NewSupplierInvoiceForm({ } } + // 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 @@ -895,8 +825,10 @@ export default function NewSupplierInvoiceForm({ // 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 the inbox prefill lands so a late extraction can't - // reintroduce a rate. + // 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 @@ -916,7 +848,7 @@ export default function NewSupplierInvoiceForm({ } }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [vatRegistered, hasPrefilled]) + }, [vatRegistered, prefillApplyCount]) function isAccrualOpen(index: number): boolean { return watchedItems?.[index]?.accrual_balance_account != null @@ -984,8 +916,8 @@ export default function NewSupplierInvoiceForm({ role="status" className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3" > - -

+ +

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

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

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

+ ) : underlagCaption ? ( +

{underlagCaption}

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

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

+

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

+
+
+
- - - - - )} + )} - - {/* Section 1: Faktura */} - - - {t('section_invoice')} - - - {/* Eget utlägg-toggle. När den är på bokas verifikatet direkt mot - skuld till ägare (2893/2018) istället för leverantörsskuld (2440), - och fakturan får status "Betalad" direkt. */} -
- ( -
- -
-
- - ( - + + +
+ aria-haspopup="menu" + aria-expanded={supplierMenuOpen} + > + + + {selectedSupplier?.name ?? t('supplier_placeholder')} + + + + {selectedSupplier?.org_number && ( + + {t('org_number_prefix', { number: selectedSupplier.org_number })} + + )} + + + { + // 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.map((s) => ( + { + setValue('supplier_id', s.id, { shouldDirty: true }) + requestAnimationFrame(() => invoiceNumberInputRef.current?.focus()) + }} + > +
+
{s.name}
+ {s.org_number && ( +
+ {t('org_number_prefix', { number: s.org_number })} +
+ )} +
+
+ ))} + + + {t('add_new_supplier')} + +
+ +
+ + {/* ── Fakturauppgifter ─────────────────────────────────── */} +
+ + {t('section_details')} + + {detailsFilled && ( + + {'✓'} {t('mark_filled')} + + )} + +
- +
{(() => { const { ref: rhfRef, ...rest } = register('supplier_invoice_number') return ( { rhfRef(el) @@ -1836,79 +1739,93 @@ export default function NewSupplierInvoiceForm({ /> ) })()} + {duplicateWarning && ( +

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

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

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

+ )}
- - + +
- + + {supplierHasGiro && ( +

{t('ocr_payment_file_caption')}

+ )}
)}
- {!inboxItemId && ( -
-
- -
- -
-
- -
- )} - - {/* Invoice-level default dims (kostnadsställe/projekt): applied to - every generated journal line; per-row bags in Kontering merge on - top. Renders only when dimensions are enabled for the company. */} - {dimensionsEnabled && ( -
-
- -
-
- )} - {showNoPeriodWarning && (
@@ -1917,30 +1834,23 @@ export default function NewSupplierInvoiceForm({
)} - - +
+ + {/* ── Kontering ────────────────────────────────────────── */} +
+ + {t('section_accounting')} + + {fields.length > 0 && ( + + {t('rows_count', { count: fields.length })} + + )} + - {/* Section 2: Kontering */} - - - {t('section_accounting')} - - - {templateAccountNote && (watchedItems ?? []).some((r) => r.account_number === templateAccountNote.account) && ( -

+

{t('account_from_history', { account: templateAccountNote.account, @@ -1948,83 +1858,15 @@ export default function NewSupplierInvoiceForm({ })}

)} - {/* Valuta & moms: kept inline with the line items because they - drive how each row is interpreted. Hidden defaults (SEK + - normal moms) collapse to nothing so most users don't see this. */} -
-
- - ( - - )} - /> -
- {watchedCurrency !== 'SEK' && ( -
- - { userTouchedRateRef.current = true }, - })} - /> -
- )} -
- ( - - )} - /> - -
-
{/* Non-blocking account-range hint for reverse charge (#863 item 2) */} {rcAccountWarningRows.length > 0 && (
- -

+ +

{t('rc_account_warning', { count: rcAccountWarningRows.length, rows: rcAccountWarningRows.map((i) => i + 1).join(', '), @@ -2039,10 +1881,10 @@ export default function NewSupplierInvoiceForm({ {foreignZeroVatRows.length > 0 && (

- -

+ +

{t('foreign_zero_vat_warning', { count: foreignZeroVatRows.length, rows: foreignZeroVatRows.map((i) => i + 1).join(', '), @@ -2051,308 +1893,450 @@ export default function NewSupplierInvoiceForm({

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