From 93e99012d7286dcbab9276c5ed05dddbe3420c20 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 18 Aug 2026 09:50:48 +0200 Subject: [PATCH] feat(supplier-invoices): dokument-forst editor rebuild (prototype shell + 4 flow optimizations) (#1653) * refactor(supplier-invoices): extract payload builder and form hooks, pin wire contract with parity tests Zero visual/behavioral change. Pulls the pure payload builder (buildSupplierInvoicePayload + inferVatTreatment + vatRateFromAi) out of NewSupplierInvoiceForm into lib/supplier-invoices/form-payload.ts and pins it with a mode/feature-matrix parity test suite (document_id vs inbox, privately paid due-date default, reverse charge rate forcing, accrual attach/drop, dimensions bags, apply_slp validity, FX parsing, empty-string stripping, ore_rounding passthrough). Also extracts, verbatim: the VatRateCell/RcRateSelect cells, the reference data loading hook (suppliers/accounts/settings/periods), the inbox AI prefill hook (exposing applyInboxItem for reuse), and the submit orchestration hook (endpoint chooser, three submit paths, duplicate-number conflict recovery, inbox field sync-back). Deliberately NOT moved: the effect-ordering couplings (pendingAccountFillRef/accountFillTick supplier-defaults dance, the icke-momsregistrerad gross-up re-run keyed on hasPrefilled, the RC accrual-clearing effect, per-currency FX touched flags) stay in the component untouched; their ordering semantics are load-bearing. Co-Authored-By: Claude Fable 5 * feat(supplier-invoices): dokument-forst editor rebuild with prototype shell and four flow optimizations Rebuilds NewSupplierInvoiceForm to the approved Leverantorsflodet prototype: single 640px column, flat sections (Underlag first, then Leverantor, Fakturauppgifter, Kontering, Forval, Summering), honest state marks (RequiredMark, sage checks for binary facts, muted row counts), a single ochre next-step line (aria-live polite) whose link focuses the missing field, and a sticky bottom action bar with the live total that binds to the dialog scroll container in bare mode and the page panel scroll standalone. Dokument-forst (1): the standalone upload now tries the invoice-inbox pipeline over HTTP first (POST upload, poll items/:id past 'processing'), then runs the same applyInboxItem prefill path as an inbox arrival (settle tint on filled fields, reset(getValues()) dirty baseline, submit through the convert endpoint so the document links and the item is stamped). Extension off or extraction failed degrades to the plain /api/documents attachment; manual entry is never blocked. Total cross-check (2): optional "Totalt enligt fakturan" field in Summering, client-only compare against the displayed payable (sage match line, terracotta diff line), prefilled from extraction totals. Duplicate advisory (3): new index-only GET /api/supplier-invoices/exists (withRouteContext + validateQuery, mirrors the partial unique index's credited/reversed exclusion, full route tests), debounce-called on fakturanummer change; terracotta field-adjacent line with a link to the existing invoice. The structured 409 conflict dialog stays the backstop. Terms-based due date (4): muted caption "Fran leverantorens villkor (N dagar)" when auto-set, re-derives on invoice-date and supplier change, stops the moment the user or the AI supplies a date; terms 0 leaves the field empty with "Star pa fakturan". OCR hint (5): "Anvands i betalningsfilen." under the payment reference when the chosen supplier has bankgiro or plusgiro. Table model: rows start empty; the ghost tfoot entry row (never part of form state) commits an account via the existing AccountCombobox (opens on focus, Enter commits) and moves focus to the new row's amount cell; the supplier default/history fill plants the first row when the table is empty. Row controls are hover-revealed via HOVER_REVEAL_CLASS at a 24px hit area with per-row aria-labels carrying the description. The primary button is never disabled pre-click for writable users (in-flight only); every submit-time hard block stays in onSubmit; viewers keep the lock treatment. Co-Authored-By: Claude Fable 5 * fix(supplier-invoices): re-run gross-up per apply, guard deferred prefill, honest un-plant - Gross-up/zero-rate pass for icke momsregistrerade re-runs per applied extraction (applyCount bumps in applyInboxItem) instead of keying on the one-shot hasPrefilled flag: a remove + re-upload could previously push AI 25 % rates to the convert endpoint with the moms columns hidden. - Deferred extraction on the standalone upload path no longer overwrites what the user typed mid-poll: the result auto-applies only while the form is pristine (live isDirty ref), otherwise it is buffered behind a quiet "Tolkning klar" click-to-apply line. Inbox arrivals are unchanged. - Supplier-switch un-plant keeps rows the user edited in ANY field, not just amount (plant-time snapshot compare in lib/supplier-invoices/planted-rows.ts, since dirtyFields is unreliable for appended array rows), clearing only the stale account; untouched plant-created rows are still removed and rows that existed before the fill are never removed. - default_expense_account plants now register in plantedRef too, so a supplier switch un-plants them under the same rules as history plants. - applyInboxItem reads suppliers through a ref: the 90 s poll no longer resolves matched suppliers against a stale empty list. - The duplicate advisory bumps its seq in the clear branch, so an in-flight exists response cannot resurrect a warning under a cleared field. - Drop 7 orphaned supplier_invoice_editor keys from both message files. Co-Authored-By: Claude Fable 5 * fix(supplier-invoices): retry the entry-row focus hand-off on the next frame A single requestAnimationFrame after appending the row can fire before the new amount input's ref is mounted, silently dropping the focus hand-off (observed in headless verification). One retry frame makes the signature interaction reliable. Co-Authored-By: Claude Fable 5 * fix(supplier-invoices): deterministic entry-row focus hand-off via effect The rAF retry still lost to the dialog focus scope re-parking focus when the entry input remounts mid-commit. An effect keyed on the pending row index runs after the new row's input has mounted and wins deterministically. Co-Authored-By: Claude Fable 5 * fix(supplier-invoices): comma-tolerant amount cell and surviving focus routing The focus trace exposed two real issues behind a probe mystery: the amount cell was type=number (ArrowDown decrements money by 0.01, Enter fires the form's implicit submit mid-edit, and Swedish comma decimals are rejected outright), and the supplier menu's close-autofocus yanked focus back to the trigger, undoing the routed hand-off to the invoice-number field. AmountCell mirrors VatRateCell's draft pattern: text input with decimal inputMode, digits-and-one-separator whitelist, Enter commits via blur. The supplier DropdownMenuContent prevents default close autofocus. Co-Authored-By: Claude Fable 5 * fix(supplier-invoices): show comma decimals in the amount cell display Co-Authored-By: Claude Fable 5 * fix(supplier-invoices): stop dialog grid item overflowing small viewports min-w-0 on the form root (DialogContent is display:grid, so the kontering table's min-w otherwise forces the column past narrow screens) and wrap the sticky-bar action cluster. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../exists/__tests__/route.test.ts | 122 + app/api/supplier-invoices/exists/route.ts | 49 + app/globals.css | 13 + .../NewSupplierInvoiceDialog.tsx | 2 +- .../NewSupplierInvoiceForm.tsx | 3112 ++++++++--------- .../supplier-invoice-cells.tsx | 178 + .../supplier-invoices/use-inbox-prefill.ts | 240 ++ .../use-supplier-invoice-data.ts | 102 + .../use-supplier-invoice-submit.ts | 573 +++ lib/api/schemas.ts | 10 + .../__tests__/form-payload.test.ts | 339 ++ .../__tests__/planted-rows.test.ts | 86 + lib/supplier-invoices/form-payload.ts | 160 + lib/supplier-invoices/planted-rows.ts | 55 + messages/en.json | 62 +- messages/sv.json | 62 +- 16 files changed, 3569 insertions(+), 1596 deletions(-) create mode 100644 app/api/supplier-invoices/exists/__tests__/route.test.ts create mode 100644 app/api/supplier-invoices/exists/route.ts create mode 100644 components/supplier-invoices/supplier-invoice-cells.tsx create mode 100644 components/supplier-invoices/use-inbox-prefill.ts create mode 100644 components/supplier-invoices/use-supplier-invoice-data.ts create mode 100644 components/supplier-invoices/use-supplier-invoice-submit.ts create mode 100644 lib/supplier-invoices/__tests__/form-payload.test.ts create mode 100644 lib/supplier-invoices/__tests__/planted-rows.test.ts create mode 100644 lib/supplier-invoices/form-payload.ts create mode 100644 lib/supplier-invoices/planted-rows.ts 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)} + )} +
+ +