diff --git a/app/(dashboard)/expenses/new/page.tsx b/app/(dashboard)/expenses/new/page.tsx index f06b7521..ceff4666 100644 --- a/app/(dashboard)/expenses/new/page.tsx +++ b/app/(dashboard)/expenses/new/page.tsx @@ -17,6 +17,8 @@ export default async function NewExpenseRedirectPage({ qs.set(key, value) } } - const suffix = qs.toString() - redirect(`/supplier-invoices/new${suffix ? `?${suffix}` : ''}`) + // Supplier invoice registration lives in a modal on the list page now + // (?new=1) — go there directly instead of bouncing via /supplier-invoices/new. + qs.set('new', '1') + redirect(`/supplier-invoices?${qs.toString()}`) } diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 1064651f..ba14ce3d 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -1,8 +1,9 @@ -import InvoiceEditor from '@/components/invoices/InvoiceEditor' +import { redirect } from 'next/navigation' -// The invoice creator lives in the shared component so the same -// form powers both creating a new invoice and editing an existing draft -// (app/(dashboard)/invoices/[id]/edit). +// Invoice creation now happens in a modal on the invoice list (issue: match +// the verifikat pattern). This route survives as a redirect so old links, +// bookmarks, and agent intents keep working. Editing drafts still has a full +// page at /invoices/[id]/edit. export default function NewInvoicePage() { - return + redirect('/invoices?new=1') } diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index d70eb4e1..ae3b1b0d 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' @@ -26,6 +27,7 @@ import { invoiceDisplayNumber } from '@/lib/invoices/display' import { getDisplayTotal } from '@/lib/invoices/rounding' import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react' import { EmptyInvoices } from '@/components/ui/empty-state' +import NewInvoiceDialog from '@/components/invoices/NewInvoiceDialog' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { Invoice, InvoiceStatus } from '@/types' @@ -69,6 +71,8 @@ function useRelativeTimeLabel() { export default function InvoicesPage() { const { company } = useCompany() const { canWrite } = useCanWrite() + const router = useRouter() + const searchParams = useSearchParams() const [invoices, setInvoices] = useState([]) const [oreRounding, setOreRounding] = useState(true) const [isLoading, setIsLoading] = useState(true) @@ -79,6 +83,15 @@ export default function InvoicesPage() { const t = useTranslations('invoices') const getRelativeTimeLabel = useRelativeTimeLabel() + // The "Ny faktura" modal is driven by the URL (?new=1) so every entry point + // — the header button, empty states, the command palette, and the legacy + // /invoices/new redirect — opens the same dialog, and the browser back + // button closes it. No canWrite gate here: like the old /invoices/new page, + // the editor itself disables submission for viewers. + const showNewInvoice = searchParams.has('new') + const closeNewInvoice = () => router.replace('/invoices', { scroll: false }) + const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false }) + async function fetchInvoices() { if (!company) return setIsLoading(true) @@ -160,12 +173,10 @@ export default function InvoicesPage() { {canWrite ? ( - - - + ) : ( - - - {VAT_RATE_PRESETS.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 ( - - ) -} - -const EMPTY_NEW_SUPPLIER: NewSupplierForm = { - name: '', - supplier_type: 'swedish_business', - org_number: '', - vat_number: '', - address_line1: '', - bankgiro: '', - plusgiro: '', - default_expense_account: '', -} - -export default function NewSupplierInvoicePage() { - const router = useRouter() - const searchParams = useSearchParams() - const inboxItemId = searchParams.get('inbox_item_id') - const { canWrite } = useCanWrite() - const { toast } = useToast() - const t = useTranslations('supplier_invoice_editor') - const ta = useTranslations('accruals') - - // When opened from an invoice-inbox item, every redirect should land the - // user back in the inbox so they can pick the next document. Outside the - // inbox flow, preserve the original behavior (detail page when we have an - // invoice id, otherwise the list). - const afterCreate = (invoiceId?: string) => - inboxItemId - ? '/e/general/invoice-inbox' - : invoiceId - ? `/supplier-invoices/${invoiceId}` - : '/supplier-invoices' - - 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) - 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) - - // 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) - - const { register, control, handleSubmit, watch, setValue, getValues, reset, formState: { isDirty } } = useForm({ - defaultValues: { - supplier_id: '', - supplier_invoice_number: '', - invoice_date: new Date().toISOString().split('T')[0], - due_date: '', - delivery_date: '', - currency: 'SEK', - exchange_rate: '', - reverse_charge: false, - payment_reference: '', - notes: '', - paid_with_private_funds: false, - // 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 }], - }, - }) - - useUnsavedChanges(isDirty) - - const { fields, append, remove, replace } = useFieldArray({ control, name: 'items' }) - const watchedItems = watch('items') - const watchedSupplierId = watch('supplier_id') - const watchedCurrency = watch('currency') - const watchedPaidPrivately = watch('paid_with_private_funds') - const watchedReverseCharge = watch('reverse_charge') - // Watched values used to decide whether the AI-filled indicator should - // still be visible. Once the user edits a field, its value no longer - // matches what the extractor wrote, and the dot fades out. - const watchedInvoiceNumber = watch('supplier_invoice_number') - const watchedInvoiceDate = watch('invoice_date') - const watchedDueDate = watch('due_date') - const watchedPaymentReference = watch('payment_reference') - // Returns true when the field currently matches whatever the AI wrote - // when the form first loaded. Edits diverge it, hiding the dot. - function stillFromAi(value: string | null | undefined, original: string | null | undefined): boolean { - if (!original) return false - return (value ?? '') === (original ?? '') - } - const aiFlags = { - invoiceNumber: stillFromAi(watchedInvoiceNumber, originalExtracted?.invoice?.invoiceNumber ?? null), - invoiceDate: stillFromAi(watchedInvoiceDate, originalExtracted?.invoice?.invoiceDate ?? null), - dueDate: stillFromAi(watchedDueDate, originalExtracted?.invoice?.dueDate ?? null), - paymentReference: stillFromAi( - watchedPaymentReference, - originalExtracted?.invoice?.paymentReference ?? null, - ), - } - - const isEF = entityType === 'enskild_firma' - - // Out-of-period guard (mirrors the manual voucher form). A registration JE is - // only posted at registration time under the accrual method or when the - // invoice is marked paid privately — cash method books at payment, so an - // out-of-period date is fine there and we stay quiet. periodsLoaded gates the - // warning so it never flashes before the fiscal periods have been fetched. - const willBookAtRegistration = accountingMethod === 'accrual' || watchedPaidPrivately - const invoiceDateOutsidePeriod = - periodsLoaded && - !!watchedInvoiceDate && - !periods.some((p) => watchedInvoiceDate >= p.period_start && watchedInvoiceDate <= p.period_end) - const showNoPeriodWarning = willBookAtRegistration && invoiceDateOutsidePeriod - - 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: '', - 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 ? err.message : 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. - useEffect(() => { - if (!watchedSupplierId) return - const supplier = suppliers.find((s) => s.id === watchedSupplierId) - if (!supplier) return - - 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). - const items = getValues('items') - items.forEach((row, i) => { - if (!row.account_number) { - setValue(`items.${i}.account_number`, supplier.default_expense_account!) - } - }) - } - if (supplier.default_currency && watch('currency') === 'SEK') { - setValue('currency', supplier.default_currency) - } - if (supplier.supplier_type === 'eu_business') { - setValue('reverse_charge', true) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [watchedSupplierId, suppliers]) - - // Auto-fetch Riksbanken exchange rate when currency switches to non-SEK and - // the user hasn't typed a custom rate yet. Re-fetches when the invoice - // date changes too. Never overwrites a user-entered rate. Reuses the - // watchedInvoiceDate declared above for the AI-filled-indicator flag. - // The "user has manually edited the rate" flag is scoped *per currency*. - // Switching from EUR (rate 11.8 edited by hand) to USD must re-fetch — the - // EUR rate is meaningless for a USD invoice. Tracking last-fetched currency - // lets us reset the touched flag on a currency switch while still honoring - // a manual edit when only the invoice date changes within the same currency. - const userTouchedRateRef = useRef(false) - const lastFxCurrencyRef = useRef(null) - useEffect(() => { - if (watchedCurrency === 'SEK') { - setValue('exchange_rate', '') - userTouchedRateRef.current = false - lastFxCurrencyRef.current = null - return - } - if (lastFxCurrencyRef.current !== watchedCurrency) { - // Currency switched — drop the previous currency's manual-edit flag. - userTouchedRateRef.current = false - lastFxCurrencyRef.current = watchedCurrency - } - if (userTouchedRateRef.current) return - let cancelled = false - ;(async () => { - try { - const url = `/api/currency/rate?currency=${watchedCurrency}${ - watchedInvoiceDate ? `&date=${watchedInvoiceDate}` : '' - }` - const res = await fetch(url) - if (!res.ok) return - const { data } = await res.json() - if (cancelled || !data?.rate) return - // Don't clobber a value the user typed while we were fetching. - if (userTouchedRateRef.current) return - setValue('exchange_rate', String(Math.round(data.rate * 10000) / 10000)) - } catch { - // Non-critical — user can type the rate manually. - } - })() - return () => { cancelled = true } - }, [watchedCurrency, watchedInvoiceDate, setValue]) - - // Auto-select newly created supplier once it shows up in the list - useEffect(() => { - if (pendingSupplierSelect && suppliers.find((s) => s.id === pendingSupplierSelect)) { - setValue('supplier_id', pendingSupplierSelect, { shouldDirty: true, shouldValidate: true }) - setPendingSupplierSelect(null) - } - }, [suppliers, pendingSupplierSelect, setValue]) - - 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) - } catch { - // Default to enskild_firma / accrual - } - } - - 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`) - if (!currentDesc && accountNumber.length === 4) { - const desc = getAccountDescription(accountNumber) - if (desc) setValue(`items.${index}.description`, desc.name) - } - } - - // Periodisering per rad: kräver faktureringsmetoden; eget utlägg bokar - // kostnaden direkt mot ägarkontot och kan inte periodiseras. Omvänd - // skattskyldighet kan inte heller periodiseras — kostnadsraden utgör - // momsunderlaget (ruta 20–32) och får inte flyttas till ett interimskonto. - const canUseAccrual = - accountingMethod === 'accrual' && !watchedPaidPrivately && !watchedReverseCharge - - // When reverse charge is switched on, clear any per-line periodisering so a - // stale AI prefill (or fields set before the toggle) can never reach the - // API, which rejects the combination with SI_CREATE_ACCRUAL_REVERSE_CHARGE. - useEffect(() => { - if (!watchedReverseCharge) return - const items = getValues('items') ?? [] - items.forEach((item, index) => { - if ( - item.accrual_period_start !== undefined || - item.accrual_period_end !== undefined || - item.accrual_balance_account !== undefined - ) { - setValue(`items.${index}.accrual_period_start`, undefined, { shouldDirty: true }) - setValue(`items.${index}.accrual_period_end`, undefined, { shouldDirty: true }) - setValue(`items.${index}.accrual_balance_account`, undefined, { shouldDirty: true }) - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [watchedReverseCharge]) - - function isAccrualOpen(index: number): boolean { - return watchedItems?.[index]?.accrual_balance_account != null - } - - function toggleAccrual(index: number) { - if (isAccrualOpen(index)) { - setValue(`items.${index}.accrual_period_start`, undefined, { shouldDirty: true }) - setValue(`items.${index}.accrual_period_end`, undefined, { shouldDirty: true }) - setValue(`items.${index}.accrual_balance_account`, undefined, { shouldDirty: true }) +// Supplier invoice registration now happens in a modal on the list page +// (matching the verifikat pattern) — the form itself lives in +// components/supplier-invoices/NewSupplierInvoiceForm.tsx. This route +// survives as a redirect so old links, bookmarks, the /expenses/new alias, +// and inbox deep links (?inbox_item_id=…) keep working. +export default async function NewSupplierInvoicePage({ + searchParams, +}: { + searchParams: Promise +}) { + const params = await searchParams + const qs = new URLSearchParams() + qs.set('new', '1') + for (const [key, value] of Object.entries(params)) { + if (value == null) continue + if (Array.isArray(value)) { + for (const v of value) qs.append(key, v) } else { - const account = watch(`items.${index}.account_number`) || '' - setValue(`items.${index}.accrual_period_start`, watch('invoice_date') || '', { shouldDirty: true }) - setValue(`items.${index}.accrual_period_end`, '', { shouldDirty: true }) - setValue( - `items.${index}.accrual_balance_account`, - suggestBalanceAccount('expense', account), - { shouldDirty: true }, - ) + qs.set(key, value) } } - - function renderAccrualPanel(index: number, idPrefix: string) { - const item = watchedItems?.[index] - if (!item || item.accrual_balance_account == null) return null - return ( - { - setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true }) - setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true }) - setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true }) - }} - onRemove={() => toggleAccrual(index)} - /> - ) - } - - const itemTotals = (watchedItems || []).map((item) => { - const lineTotal = Math.round((item.amount || 0) * 100) / 100 - // Reverse charge: VAT is self-assessed at reverse_charge_rate (25% default), - // not the line's vat_rate (which is 0 — the supplier charged nothing). - const effectiveRate = watchedReverseCharge ? (item.reverse_charge_rate ?? 0.25) : (item.vat_rate || 0) - const vatAmount = Math.round(lineTotal * effectiveRate * 100) / 100 - return { lineTotal, vatAmount } - }) - const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0) - const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0) - // Reverse charge: supplier never invoices VAT, so it doesn't roll into the - // payable total. The VAT is still accounted for via 2614 / 2645 in - // bookkeeping — the line stays in the breakdown for transparency. - const payableVat = watchedReverseCharge ? 0 : totalVat - const total = Math.round((subtotal + payableVat) * 100) / 100 - - // Show the AI-suggested supplier card when we have an inbox item, the AI - // surfaced a supplier name, and we couldn't match it to an existing record. - const showAISupplierHint = - !!extractedData?.supplier?.name && - !hasMatchedSupplier && - !watchedSupplierId - - function openSupplierDialogPrefilled() { - setNewSupplier({ - name: extractedData?.supplier?.name || '', - supplier_type: 'swedish_business', - org_number: extractedData?.supplier?.orgNumber || '', - vat_number: extractedData?.supplier?.vatNumber || '', - address_line1: extractedData?.supplier?.address || '', - bankgiro: extractedData?.supplier?.bankgiro || '', - plusgiro: extractedData?.supplier?.plusgiro || '', - default_expense_account: '', - }) - setShowNewSupplier(true) - } - - function openSupplierDialogBlank() { - setNewSupplier(EMPTY_NEW_SUPPLIER) - setShowNewSupplier(true) - } - - async function handleCreateSupplier() { - if (!newSupplier.name.trim()) { - toast({ title: t('name_missing_title'), description: t('name_missing_description'), variant: 'destructive' }) - return - } - setIsCreatingSupplier(true) - - const payload: Record = { - name: newSupplier.name, - supplier_type: newSupplier.supplier_type, - } - if (newSupplier.org_number) payload.org_number = newSupplier.org_number - if (newSupplier.vat_number) payload.vat_number = newSupplier.vat_number - if (newSupplier.address_line1) payload.address_line1 = newSupplier.address_line1 - if (newSupplier.bankgiro) payload.bankgiro = newSupplier.bankgiro - if (newSupplier.plusgiro) payload.plusgiro = newSupplier.plusgiro - if (newSupplier.default_expense_account) payload.default_expense_account = newSupplier.default_expense_account - - const res = await fetch('/api/suppliers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - const result = await res.json() - - if (!res.ok) { - toast({ title: t('create_supplier_failed_title'), description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' }) - } else { - const created = result.data as Supplier - setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))) - setPendingSupplierSelect(created.id) - setHasMatchedSupplier(true) - setShowNewSupplier(false) - setNewSupplier(EMPTY_NEW_SUPPLIER) - toast({ title: t('supplier_created_title'), description: created.name }) - } - - setIsCreatingSupplier(false) - } - - function buildPayload(data: FormData) { - const vatTreatment = inferVatTreatment(data.items, data.reverse_charge) - // When paid privately, due_date is irrelevant — but the API still requires - // a YYYY-MM-DD value. Default to invoice_date so the field passes validation. - const dueDate = data.paid_with_private_funds && !data.due_date - ? data.invoice_date - : data.due_date - return { - supplier_id: data.supplier_id, - supplier_invoice_number: data.supplier_invoice_number, - invoice_date: data.invoice_date, - due_date: dueDate, - delivery_date: data.delivery_date || undefined, - currency: data.currency, - exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined, - vat_treatment: vatTreatment, - reverse_charge: data.reverse_charge, - payment_reference: data.payment_reference || undefined, - notes: data.notes || undefined, - paid_with_private_funds: data.paid_with_private_funds, - ore_rounding: oreRounding, - items: data.items.map((item) => ({ - description: item.description, - amount: item.amount, - account_number: item.account_number, - // Reverse charge: the supplier charges no VAT, so the line rate is 0 and - // the self-assessed rate travels on reverse_charge_rate (25% default). - vat_rate: data.reverse_charge ? 0 : item.vat_rate, - reverse_charge_rate: data.reverse_charge ? (item.reverse_charge_rate ?? 0.25) : undefined, - // Periodisering: only sent when the row has a complete period AND the - // flow supports it (kontantmetod/eget utlägg would be rejected by the - // API — an AI prefill must never block those submits). - ...(canUseAccrual && item.accrual_period_start && item.accrual_period_end - ? { - accrual_period_start: item.accrual_period_start, - accrual_period_end: item.accrual_period_end, - accrual_balance_account: item.accrual_balance_account || undefined, - } - : {}), - })), - } - } - - // Persist user edits back into the inbox item's extracted_data so the - // inbox stays in sync with what was actually booked. Best-effort: a - // failed PATCH never blocks the registration. - async function patchInboxFieldsIfChanged(data: FormData) { - if (!inboxItemId || !originalExtracted) return - const supplierField: Record = {} - const invoiceField: Record = {} - - if (originalExtracted.invoice?.invoiceNumber !== data.supplier_invoice_number) { - invoiceField.invoiceNumber = data.supplier_invoice_number || null - } - if (originalExtracted.invoice?.invoiceDate !== data.invoice_date) { - invoiceField.invoiceDate = data.invoice_date || null - } - if (originalExtracted.invoice?.dueDate !== data.due_date) { - invoiceField.dueDate = data.due_date || null - } - if ((originalExtracted.invoice?.paymentReference || null) !== (data.payment_reference || null)) { - invoiceField.paymentReference = data.payment_reference || null - } - if (originalExtracted.invoice?.currency !== data.currency) { - invoiceField.currency = data.currency - } - - if (Object.keys(supplierField).length === 0 && Object.keys(invoiceField).length === 0) return - - try { - await fetch(`/api/extensions/ext/invoice-inbox/items/${inboxItemId}/fields`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...(Object.keys(supplierField).length ? { supplier: supplierField } : {}), - ...(Object.keys(invoiceField).length ? { invoice: invoiceField } : {}), - }), - }) - } catch { - // Best-effort sync; don't block registration on this. - } - } - - // Single submit endpoint chooser — convert when we came from inbox, plain - // POST otherwise. Both endpoints validate the same CreateSupplierInvoiceSchema - // and return the same canonical error envelope ({ error: { code, message, - // details } }) — including the recoverable duplicate-number 409. - async function postCreate(data: FormData): Promise<{ - ok: boolean - status: number - result: CreateResult - }> { - const url = inboxItemId - ? `/api/extensions/ext/invoice-inbox/items/${inboxItemId}/convert` - : '/api/supplier-invoices' - - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildPayload(data)), - }) - const result = await res.json() - return { ok: res.ok, status: res.status, result } - } - - function onSubmit(data: FormData) { - // Hard block: under faktureringsmetoden (and for privately-paid kvitton) a - // verifikation is posted at registration, and BFL 5 kap kräver att - // verifikationsnumret ligger i en obruten serie inom ett räkenskapsår. No - // räkenskapsår for the invoice date → no compliant voucher can exist, so we - // refuse rather than register an unbooked invoice. Kontantmetoden books at - // payment, so it is intentionally not blocked here (see showNoPeriodWarning). - if (showNoPeriodWarning) { - toast({ - title: t('warning_title'), - description: t('no_period_warning', { date: data.invoice_date }), - variant: 'destructive', - }) - return - } - if (!data.supplier_id) { - toast({ title: t('supplier_missing_title'), description: t('supplier_missing_description'), variant: 'destructive' }) - return - } - if (!data.supplier_invoice_number) { - toast({ title: t('invoice_number_missing_title'), description: t('invoice_number_missing_description'), variant: 'destructive' }) - return - } - const rowWithoutAccount = data.items.findIndex((item) => !item.account_number) - if (rowWithoutAccount !== -1) { - toast({ - title: t('account_missing_title'), - description: t('account_missing_description', { row: rowWithoutAccount + 1 }), - variant: 'destructive', - }) - return - } - // A row with an open periodisering panel must carry a complete period of - // at least two calendar months before the invoice can be booked. - const invalidAccrual = canUseAccrual && data.items.some((item) => { - if (item.accrual_balance_account == null) return false - if (!item.accrual_period_start || !item.accrual_period_end) return true - if (item.accrual_period_end < item.accrual_period_start) return true - return countCalendarMonths(item.accrual_period_start, item.accrual_period_end) < 2 - }) - if (invalidAccrual) { - toast({ - title: ta('incomplete_toast_title'), - description: ta('incomplete_toast_description'), - variant: 'destructive', - }) - return - } - - if (submitModeRef.current === 'register_and_match') { - // Open the bank-transaction picker; actual create happens on pick. - // For AB the review dialog is shown after a transaction is picked. - setPendingData(data) - setShowBankPicker(true) - return - } - - // Privately-paid skips the AB review dialog — the toggle itself is the - // explicit user intent, and the resulting verifikat is just expense + VAT - // against the owner account (2893/2018). Same path for EF. - if (isEF || data.paid_with_private_funds) { - setPendingData(data) - handleDirectSubmit(data) - } else { - setPendingData(data) - setShowReview(true) - } - } - - // EF: create + auto-approve, no review dialog. Privately-paid invoices land - // here too and skip auto-approve since they're already in status='paid'. - async function handleDirectSubmit(data: FormData) { - setIsSubmitting(true) - await patchInboxFieldsIfChanged(data) - const { ok, status, result } = await postCreate(data) - - if (!ok) { - // EF/direct path also hits the duplicate-number 409 (e.g. converting an - // inbox receipt whose number was already registered) — offer recovery - // instead of a dead-end toast. - if (!tryHandleDuplicateConflict(status, result)) { - handleCreateError(status, result) - } - setIsSubmitting(false) - return - } - if (!result.data) { - setIsSubmitting(false) - return - } - - // Clear dirty state so useUnsavedChanges doesn't fire the - // beforeunload prompt while we navigate away on a successful submit. - reset(data) - - if (data.paid_with_private_funds) { - toast({ - title: t('expense_registered_title'), - description: t('arrival_number_label', { number: result.data.arrival_number }), - }) - router.push(afterCreate()) - setIsSubmitting(false) - return - } - - // Auto-approve for EF - const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' }) - if (!approveRes.ok) { - toast({ - title: t('warning_title'), - description: t('auto_approve_failed_description'), - variant: 'destructive', - }) - router.push(afterCreate(result.data.id)) - } else { - toast({ title: t('invoice_registered_title'), description: t('arrival_number_label', { number: result.data.arrival_number }) }) - router.push(afterCreate()) - } - setIsSubmitting(false) - } - - // AB: create after review dialog. If a bank transaction was picked first - // (register-and-match flow), also match the new invoice to it. - async function handleConfirm() { - if (!pendingData) return - setIsSubmitting(true) - await patchInboxFieldsIfChanged(pendingData) - const { ok, status, result } = await postCreate(pendingData) - - if (ok && result.data) { - const invoiceId = result.data.id - const arrivalNumber = result.data.arrival_number - setShowReview(false) - // Clear dirty state — see comment in handleDirectSubmit. - reset(pendingData) - - if (pendingTransactionId) { - const matchRes = await fetch(`/api/transactions/${pendingTransactionId}/match-supplier-invoice`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ supplier_invoice_id: invoiceId }), - }) - const matchResult = await matchRes.json() - setPendingTransactionId(null) - submitModeRef.current = 'register' - - if (matchRes.ok) { - toast({ - title: t('invoice_registered_and_matched_title'), - description: t('invoice_registered_and_matched_description', { number: arrivalNumber }), - }) - } else { - toast({ - title: t('invoice_registered_match_failed_title'), - description: getErrorMessage(matchResult, { context: 'supplier_invoice', statusCode: matchRes.status }), - variant: 'destructive', - }) - } - } else { - toast({ title: t('invoice_registered_title'), description: t('arrival_number_label', { number: arrivalNumber }) }) - } - - router.push(afterCreate(invoiceId)) - } else { - // Treat duplicate-number as a recoverable conflict; everything else as a hard error. - if (!tryHandleDuplicateConflict(status, result)) { - handleCreateError(status, result) - } - } - setIsSubmitting(false) - } - - // Detect the recoverable duplicate-supplier-invoice-number conflict and open - // the resolution dialog. Both the inbox `convert` route and the plain create - // route return the same structured 409 envelope, so this works for every - // submit path. Returns true when handled (caller should skip the error toast). - function tryHandleDuplicateConflict(status: number, result: CreateResult): boolean { - const err = result.error - if ( - status !== 409 || - typeof err !== 'object' || - err === null || - err.code !== 'SI_CREATE_DUPLICATE_INVOICE_NUMBER' - ) { - return false - } - // Close the review dialog if it was the path that triggered the conflict; - // a no-op for the EF/direct paths where it was never opened. - setShowReview(false) - setConflict({ - message: err.message || t('duplicate_default_message'), - existing: err.details?.existing ?? null, - }) - return true - } - - // Shared error toast for non-conflict failures. - function handleCreateError(status: number, result: CreateResult) { - toast({ - title: t('register_invoice_failed_title'), - description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }), - variant: 'destructive', - }) - } - - async function handleUncreditAndRetry() { - if (!conflict?.existing) return - const existingId = conflict.existing.id - const existingNumber = conflict.existing.supplier_invoice_number - setIsResolvingConflict(true) - - const uncreditRes = await fetch( - `/api/supplier-invoices/${existingId}/uncredit`, - { method: 'POST' }, - ) - const uncreditResult = await uncreditRes.json() - if (!uncreditRes.ok) { - toast({ - title: t('uncredit_failed_title'), - description: getErrorMessage(uncreditResult, { context: 'supplier_invoice', statusCode: uncreditRes.status }), - variant: 'destructive', - }) - setIsResolvingConflict(false) - return - } - - setConflict(null) - - if (!pendingData) { - setIsResolvingConflict(false) - return - } - - const { ok, status, result } = await postCreate(pendingData) - setIsResolvingConflict(false) - - if (ok && result.data) { - toast({ - title: t('uncredit_and_register_success_title'), - description: t('arrival_number_label', { number: result.data.arrival_number }), - }) - reset(pendingData) - router.push(afterCreate(result.data.id)) - return - } - - toast({ - title: t('uncredit_but_register_failed_title'), - description: t('uncredit_but_register_failed_description', { - number: existingNumber, - reason: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }), - }), - variant: 'destructive', - }) - } - - function handlePickNewNumber() { - setConflict(null) - setTimeout(() => invoiceNumberInputRef.current?.focus(), 0) - } - - // Match-on-create: register the invoice, then match the picked transaction. - // EF goes straight through (auto-approve included). AB stores the picked - // transaction and routes through the same review dialog as the plain - // register flow — handleConfirm picks up the match step on confirmation. - async function handlePickTransaction(transactionId: string) { - if (!pendingData) return - setShowBankPicker(false) - - if (!isEF) { - setPendingTransactionId(transactionId) - setShowReview(true) - return - } - - setIsSubmitting(true) - await patchInboxFieldsIfChanged(pendingData) - const { ok, status, result } = await postCreate(pendingData) - - if (!ok || !result.data) { - if (!tryHandleDuplicateConflict(status, result)) { - handleCreateError(status, result) - } - setIsSubmitting(false) - return - } - - const invoiceId = result.data.id - const arrivalNumber = result.data.arrival_number - - // Auto-approve before matching, so the invoice is in the 'approved' state - // that match-supplier-invoice expects (it accepts registered too, but - // EF's expectation is fully-booked). - await fetch(`/api/supplier-invoices/${invoiceId}/approve`, { method: 'POST' }) - - const matchRes = await fetch(`/api/transactions/${transactionId}/match-supplier-invoice`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ supplier_invoice_id: invoiceId }), - }) - const matchResult = await matchRes.json() - setIsSubmitting(false) - submitModeRef.current = 'register' - - if (matchRes.ok) { - toast({ - title: t('invoice_registered_and_matched_title'), - description: t('invoice_registered_and_matched_description', { number: arrivalNumber }), - }) - } else { - toast({ - title: t('invoice_registered_match_failed_title'), - description: getErrorMessage(matchResult, { context: 'supplier_invoice', statusCode: matchRes.status }), - variant: 'destructive', - }) - } - reset(pendingData) - router.push(afterCreate(invoiceId)) - } - - return ( -
-
- -
-

{t('page_title')}

-
-
- - {isLoadingInbox && ( - - - - {t('loading_inbox')} - - - )} - - {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. */} -
- ( -
- -
-
- - ( - - )} - /> -
-
-
- - -
- {(() => { - const { ref: rhfRef, ...rest } = register('supplier_invoice_number') - return ( - { - rhfRef(el) - invoiceNumberInputRef.current = el - }} - /> - ) - })()} -
-
-
-
-
- - -
- -
- {!watchedPaidPrivately && ( - <> -
-
- - -
- -
-
-
- - -
- -
- - )} -
- - {showNoPeriodWarning && ( -
- -
-

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

-

{t('no_period_help')}

-
-
- )} -
-
- - {/* Section 2: Kontering */} - - - {t('section_accounting')} - - - - {/* 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 }, - })} - /> -
- )} -
- ( - - )} - /> - -
-
- - {/* Desktop table */} -
- - - - - - - - - - - - - {fields.map((field, index) => ( - - - - - - - - - - {canUseAccrual && isAccrualOpen(index) && ( - - - - )} - - ))} - -
{t('col_account')}{t('col_description')}{t('col_amount_excl')}{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 ? ( - ( - - )} - /> - ) : ( - ( - - )} - /> - )} - - {formatAmount(itemTotals[index]?.vatAmount ?? 0)} - -
- {canUseAccrual && ( - - )} - {fields.length > 1 && ( - - )} -
-
- {renderAccrualPanel(index, `accrual-desktop-${index}`)} -
-
- - {/* Mobile cards */} -
- {fields.map((field, index) => ( -
-
- {t('row_label', { index: index + 1 })} -
- {canUseAccrual && ( - - )} - {fields.length > 1 && ( - - )} -
-
-
- - ( - handleAccountChange(index, val)} /> - )} - /> -
-
- - ( - - )} - /> -
-
-
- - ( - field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)} - /> - )} - /> -
-
- - {watchedReverseCharge ? ( - ( - - )} - /> - ) : ( - ( - - )} - /> - )} -
-
-
- {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')} - - {formatAmount(itemTotals[index]?.vatAmount ?? 0)} - -
- {canUseAccrual && isAccrualOpen(index) && - renderAccrualPanel(index, `accrual-mobile-${index}`)} -
- ))} -
- - {/* AI totals comparison — only when extracted */} - {extractedData?.totals && (extractedData.totals.subtotal != null || extractedData.totals.total != null) && ( -
- {t('ai_totals_label')} - {extractedData.totals.subtotal != null && ( - - {t('ai_net', { amount: formatAmount(extractedData.totals.subtotal) })} - - )} - {extractedData.totals.vatAmount != null && ( - - {t('ai_vat', { amount: formatAmount(extractedData.totals.vatAmount) })} - - )} - {extractedData.totals.total != null && ( - - {t('ai_total', { amount: formatAmount(extractedData.totals.total) })} - - )} -
- )} - - {/* Computed totals */} -
-
- {t('net_excl_vat')} - {formatCurrency(subtotal, watchedCurrency)} -
-
- - {watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')} - - {formatCurrency(totalVat, watchedCurrency)} -
-
- {t('total_label')} - {formatCurrency(total, watchedCurrency)} -
- {/* Öresavrundning — display-only rounding of the displayed total to - whole kronor (SEK only). The registered amount and the booked - verifikat keep the exact öre; this only changes what's shown. */} - {(watchedCurrency || 'SEK') === 'SEK' && ( -
-
- -

{t('ore_rounding_help')}

-
- -
- )} -
-
-
- - {/* Section 3: Övrigt (collapsible) */} - - setAdvancedOpen(!advancedOpen)} - > -
- {t('section_other')} - -
-
- {advancedOpen && ( - -
- - -
-
- -