UX polish bundle: Enable Banking lookback + sync progress, invoice inbox, matching previews (#548)
* fix(import): dedup opening-balance rows when account numbers differ only in whitespace The parser's merge map keyed on the post-strip account_number, but rows like "1930", " 1930 " and "1.930" could leak as separate entries when the upstream string contained non-breaking spaces or zero-width chars that the old .replace(/[^0-9]/g, '') ran on already-stripped output. Strip those explicitly in the raw string and use /\D/g for the digit extraction. Also adds defense-in-depth dedup inside OpeningBalanceEditStep so any duplicates that survive the parser collapse before the user sees them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(enable-banking): anchor lookback picker to fiscal year, not days Replaces the 90/180/365 days dropdown on the account-selection screen with three explicit modes: - "Senaste 90 dagar (snabbt)" — fastest path, matches PSD2 ceiling - "Sedan räkenskapsårets början" (default) — resolves via fiscal_year_start_month, surfaces the literal date inline - "Anpassat datum" — free date picker OR "Föregående räkenskapsårets start" When the resulting range exceeds 90 days, the picker now surfaces a quiet helper that points users at the SIE/bankfil import for older history, so they don't waste an account-selection round-trip discovering that banks usually cap at ~90 days. The PATCH /accounts handler accepts initial_lookback_from_date alongside initial_lookback_days; the new helper getCurrentFiscalYearStart() in lib/company/fiscal-year.ts is reused. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(enable-banking): dedicated sync progress modal replaces silent spinner After the user confirms account selection, transactions fetch in the background for 30–60 seconds. Previously this showed only the Spara-button spinner with no indication of duration or what was happening — users described being stuck on the page. The new BankSyncProgressDialog opens immediately on Save, lists the enabled accounts being synced, and disables manual close until the PATCH resolves. On completion it shows the imported count and the actual date range the bank returned, plus an amber escape hatch to SIE/bankfil import when the returned range was truncated by >7 days from what was requested. Failure path surfaces in the same modal rather than as a destructive toast that disappears. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(invoice-inbox): drop duplicate Skapa leverantör button The inbox detail panel had its own supplier-creation button that fired /api/suppliers + match-supplier. The same action is reachable from the supplier-invoice form's "Skapa & välj" card (showAISupplierHint), which also prefills more fields. The duplicate button is gone; a quiet inline hint replaces it so the user still knows why no supplier matched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui(invoice-inbox): surface currency and totals above the long metadata tail Move Valuta / Totalt / Moms in FIELD_DEFS so they sit immediately under Leverantör / Org.nr / VAT-nr. These are the fields the user reads first when triaging an inbox item; burying them after nine metadata fields forces unnecessary scrolling on every single invoice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(invoice-inbox): accept .eml forwards and log rejected attachments Gmail's "Forward as attachment" packages the original email as message/rfc822, which our MIME allowlist silently dropped. Adds mailparser so we can unwrap the inner attachments and ingest them under the inner email's subject/from. Also persists every rejected attachment as an invoice_inbox_items row with status='error', so users can see what was dropped instead of guessing why nothing showed up in their inbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): redirect back to inbox after creating from invoice-inbox When the leverantörsfaktura form is opened from an invoice-inbox item, every successful create previously kicked the user out to /supplier-invoices or the just-created invoice's detail page — derailing the "process the next document" workflow. The Tillbaka button likewise routed to the supplier-invoice list rather than the inbox they came from. Adds an afterCreate helper that lands inbox-originated submissions at /e/general/invoice-inbox and preserves the original target everywhere else. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui(pending): show transaction/document context for match-and-attach reviews The granskning page previously rendered attach_document_to_transaction and match_transaction_invoice operations through the generic key/value preview, so reviewers saw "document file name: Faktura.pdf / transaction amount: -216 USD" without any visual indication of which two things were being paired. The MCP tool already returns enriched preview data; we just needed dedicated layouts. Adds: - AttachDocumentPreview — two-card layout (Transaktion | Dokument) with a "Visa dokument" button that fetches a signed download URL on demand - MatchTransactionInvoicePreview — same layout (Transaktion | Faktura) - DocumentViewButton — reusable signed-URL opener Also tightens the matching tools' descriptions so AI clients are nudged to verify human-readable context before staging. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): address PR #548 review feedback - invoice-inbox: hoist mailparser to a static import. The extension system generates a static import tree via setup:extensions and disallows dynamic imports — await import('mailparser') worked in dev but could fail in production standalone builds. - enable-banking AccountPickerDialog: guard the Save path when "Anpassat datum" + "Specifikt datum" is selected with an empty date. Without this, lookback.body resolves to null and the PATCH silently falls back to the backend's 120-day default, ignoring the user's intent. - enable-banking BankSyncProgressDialog: drop the empty-body useEffect. Close-prevention is already handled inline via the onOpenChange guard + onPointerDownOutside + onEscapeKeyDown handlers. - lib/company/fiscal-year: pin both operands of daysBetween() to UTC when parsing ISO date strings. Mixing a UTC-parsed date with new Date() (local time) drifts by one day in any timezone east of UTC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): address compliance swarm + Swedish review feedback Three actionable items from the post-fix compliance scan; the rest were false positives or out of scope. - enable-banking PATCH /accounts: reject future initial_lookback_from_date with 400 instead of silently falling through to the 120-day default. Compliance V2.2. - AttachDocumentPreview: promote the overwrite warning to a destructive banner with BFL 7 kap context when the existing document is marked as räkenskapsinformation. A muted footnote was too easy to skip past for a verifikationsunderlag replacement. - MatchTransactionInvoicePreview: surface transaction_date + invoice_date in the staged preview so reviewers can spot date drift before approving (BFL 5 kap 6§ — verifikation date must align with affärshändelse). Also shows a quiet hint when the two dates differ by > 31 days. Tool's SELECT + stage payload extended accordingly. Skipped (with rationale): - V5.3 inner.filename path traversal — lib/core/documents/document-service.ts already sanitizes filenames before constructing storage paths. - V5.2 magic-number MIME — pre-existing pattern for all email attachments; scope is codebase-wide. - V1.2 att.id composite ID — only used as a DB column value, never a path. - V13.1 / CM-8 SBOM/SCA — repository-wide policy, not this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): address second-round compliance + Swedish review feedback Compliance Swarm (defense-in-depth + valid finds): - invoice-inbox: sanitise .eml inner attachment filenames and content-types before they flow into uploadAndExtract or the raw_email_payload JSONB. document-service already strips bad chars before constructing storage paths, but the swarm flagged the upstream input as unsanitised — easier to add a thin sanitiseFilename/sanitiseMime layer than to argue about defense-in-depth. Caps lengths too. - DocumentViewButton: validate documentId as a UUID before interpolating into /api/documents/:id — staged preview_data is Record<string, unknown> on the wire, so refusing junk early gives a clearer error and keeps the internal API from seeing oddly-shaped path segments. (Compliance V1.2.) Swedish review: - MatchTransactionInvoicePreview: drop the BFL 5 kap 6§ citation from the date-drift hint — that section governs verifikationsinnehåll, not a 31-day tolerance. The hint stays (the practical concern is real) but no longer pretends to quote a legislated threshold. - fiscal-year: document the implicit assumption that entity_type reflects the company's current tax-year status, not a mid-conversion state. Skipped (with rationale): - V5.2 magic-number MIME — pre-existing pattern across all email attachments. - A.8.12 signed URL via window.open — pre-existing pattern shared with JournalEntryAttachments.tsx; refactor to server-side redirect is broader scope. - A.8.15 logRejection failure path — pre-existing console.error pattern. - CC9.2 mailparser vendor review / SBOM — out of PR scope. - CC6.1 IDOR — /api/documents/:id already enforces company_id; false positive. - Swedish #1 räkenskapsinformation flag origin — server-side already derives the flag from document_attachments.journal_entry_id in the staging tool; not caller-trusted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): fail-safe BFL warning + preserve merge validation errors Two findings from the third compliance pass; both worth addressing. - AttachDocumentPreview: treat an absent existing_document_is_rakenskapsinformation flag as räkenskapsinformation rather than as "safe to overwrite". The MCP staging tool sets the flag deterministically from document_attachments.journal_entry_id today, but a future code path that forgets it would silently downgrade the BFL 7 kap warning. Only an explicit `=== false` from the server keeps the muted note path. - Opening-balance merge: union validation_errors when collapsing duplicate account_number rows, both in the parser and the EditStep useState initializer. Previously a warning that fired on row 5 (e.g. BAS-class mismatch) was silently dropped if row 2 of the same account had no error, risking misclassified IB data downstream. Added a parser test covering the union behaviour for two rows of a class-3 (resultatkonto) account. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e71b4a9138
commit
39204cc0de
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowDown, AlertTriangle } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { DocumentViewButton } from './DocumentViewButton'
|
||||
|
||||
interface AttachDocumentPreviewProps {
|
||||
data: Record<string, unknown>
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-card preview for `attach_document_to_transaction` operations. Renders
|
||||
* the transaction and the document side by side so the reviewer can confirm
|
||||
* the pairing without cross-referencing IDs.
|
||||
*/
|
||||
export function AttachDocumentPreview({ data, params }: AttachDocumentPreviewProps) {
|
||||
const txDescription = (data.transaction_description as string) || '—'
|
||||
const txAmount = data.transaction_amount as number | undefined
|
||||
const txCurrency = (data.transaction_currency as string) || 'SEK'
|
||||
const txDate = data.transaction_date as string | undefined
|
||||
|
||||
const docFileName = (data.document_file_name as string) || '—'
|
||||
const docVendor = data.document_vendor_name as string | undefined
|
||||
const docAmount = data.document_amount as number | undefined
|
||||
const docCurrency = (data.document_currency as string) || txCurrency
|
||||
const docInvoiceDate = data.document_invoice_date as string | undefined
|
||||
|
||||
const willOverwrite = data.will_overwrite_existing === true
|
||||
const existingDocName = data.existing_document_file_name as string | undefined
|
||||
// Fail safe: if the staging tool didn't explicitly assert the existing doc
|
||||
// is NOT räkenskapsinformation (i.e. `=== false`), treat overwrite as a
|
||||
// BFL 7 kap event. A missing/undefined flag must not silently downgrade
|
||||
// the destructive warning.
|
||||
const existingIsAccounting =
|
||||
willOverwrite && data.existing_document_is_rakenskapsinformation !== false
|
||||
|
||||
const documentId = params.document_id as string | undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PreviewCard label="Transaktion">
|
||||
<Row label="Datum" value={txDate ? formatDate(txDate) : '—'} tabular />
|
||||
<Row label="Beskrivning" value={txDescription} />
|
||||
<Row
|
||||
label="Belopp"
|
||||
value={typeof txAmount === 'number' ? formatCurrency(txAmount, txCurrency) : '—'}
|
||||
tabular
|
||||
/>
|
||||
</PreviewCard>
|
||||
|
||||
<PreviewCard label="Dokument">
|
||||
<Row label="Fil" value={docFileName} />
|
||||
{docVendor && <Row label="Leverantör" value={docVendor} />}
|
||||
{docInvoiceDate && <Row label="Fakturadatum" value={formatDate(docInvoiceDate)} tabular />}
|
||||
{typeof docAmount === 'number' && (
|
||||
<Row label="Belopp" value={formatCurrency(docAmount, docCurrency)} tabular />
|
||||
)}
|
||||
{documentId && (
|
||||
<div className="pt-1">
|
||||
<DocumentViewButton documentId={documentId} />
|
||||
</div>
|
||||
)}
|
||||
</PreviewCard>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center text-xs text-muted-foreground">
|
||||
<ArrowDown className="h-3.5 w-3.5 mr-1" />
|
||||
kopplas till transaktionen
|
||||
</div>
|
||||
|
||||
{willOverwrite && existingIsAccounting && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Ersätter räkenskapsinformation</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Befintligt dokument{existingDocName ? ` (${existingDocName})` : ''} är markerat som
|
||||
räkenskapsinformation enligt BFL 7 kap. Att ersätta det här gör det tidigare
|
||||
verifikationsunderlaget otillgängligt — bekräfta att du har originalet sparat innan
|
||||
du godkänner.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{willOverwrite && !existingIsAccounting && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-2 text-xs text-muted-foreground">
|
||||
Ersätter befintligt dokument{existingDocName ? `: ${existingDocName}` : ''}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewCard({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3 space-y-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value, tabular }: { label: string; value: string; tabular?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={`text-sm ${tabular ? 'tabular-nums' : ''} text-right truncate`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface DocumentViewButtonProps {
|
||||
documentId: string
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Opens a document's signed download URL in a new tab. The signed URL is
|
||||
* minted on demand via /api/documents/:id (60 min TTL), so we don't bake
|
||||
* stale URLs into the preview payload.
|
||||
*/
|
||||
export function DocumentViewButton({ documentId, label = 'Visa dokument', className }: DocumentViewButtonProps) {
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
// documentId originates from staged preview_data (Record<string, unknown>);
|
||||
// validate the shape before interpolating into the request URL so a malformed
|
||||
// payload can't redirect the fetch at another internal endpoint.
|
||||
if (!UUID_RE.test(documentId)) {
|
||||
toast({
|
||||
title: 'Ogiltigt dokument-ID',
|
||||
description: 'Försök ladda om sidan eller kontakta support.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}`)
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok || !json?.data?.download_url) {
|
||||
toast({
|
||||
title: 'Kunde inte öppna dokumentet',
|
||||
description: json?.error || 'Försök igen om en stund.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
window.open(json.data.download_url as string, '_blank', 'noopener,noreferrer')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={className}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
|
||||
interface MatchTransactionInvoicePreviewProps {
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-card preview for `match_transaction_invoice` operations. Mirrors
|
||||
* AttachDocumentPreview's layout so reviewers learn one matching idiom.
|
||||
*/
|
||||
export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoicePreviewProps) {
|
||||
const txDescription = (data.transaction_description as string) || '—'
|
||||
const txAmount = data.transaction_amount as number | undefined
|
||||
const txCurrency = (data.transaction_currency as string) || 'SEK'
|
||||
const txDate = data.transaction_date as string | undefined
|
||||
|
||||
const invoiceNumber = (data.invoice_number as string) || '—'
|
||||
const invoiceTotal = data.invoice_total as number | undefined
|
||||
const invoiceCurrency = (data.invoice_currency as string) || txCurrency
|
||||
const invoiceDate = data.invoice_date as string | undefined
|
||||
const customerName = data.customer_name as string | undefined
|
||||
|
||||
// BFL 5 kap 4§ requires bookings to be made "so soon as possible" relative
|
||||
// to the affärshändelse, so a transaction and invoice that diverge by more
|
||||
// than a calendar month deserve a second look before the reviewer approves
|
||||
// the match. The threshold is editorial, not legislated — it just nudges
|
||||
// the reviewer; it doesn't block.
|
||||
const showDateDriftHint =
|
||||
txDate &&
|
||||
invoiceDate &&
|
||||
Math.abs(new Date(txDate).getTime() - new Date(invoiceDate).getTime()) > 31 * 24 * 60 * 60 * 1000
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<PreviewCard label="Transaktion">
|
||||
{txDate && <Row label="Datum" value={formatDate(txDate)} tabular />}
|
||||
<Row label="Beskrivning" value={txDescription} />
|
||||
<Row
|
||||
label="Belopp"
|
||||
value={typeof txAmount === 'number' ? formatCurrency(txAmount, txCurrency) : '—'}
|
||||
tabular
|
||||
/>
|
||||
</PreviewCard>
|
||||
|
||||
<PreviewCard label="Faktura">
|
||||
<Row label="Nummer" value={invoiceNumber} tabular />
|
||||
{invoiceDate && <Row label="Fakturadatum" value={formatDate(invoiceDate)} tabular />}
|
||||
{customerName && <Row label="Kund" value={customerName} />}
|
||||
{typeof invoiceTotal === 'number' && (
|
||||
<Row label="Totalt" value={formatCurrency(invoiceTotal, invoiceCurrency)} tabular />
|
||||
)}
|
||||
</PreviewCard>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center text-xs text-muted-foreground">
|
||||
<ArrowDown className="h-3.5 w-3.5 mr-1" />
|
||||
matchas mot fakturan
|
||||
</div>
|
||||
|
||||
{showDateDriftHint && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Transaktionsdatum och fakturadatum skiljer sig med mer än en månad — kontrollera att
|
||||
matchningen avser rätt affärshändelse.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewCard({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3 space-y-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value, tabular }: { label: string; value: string; tabular?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={`text-sm ${tabular ? 'tabular-nums' : ''} text-right truncate`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1260,81 +1260,16 @@ function FieldsRail({
|
||||
!isProcessed && !isBookedDirectly && !!item.matched_transaction_id
|
||||
const isResolved = isProcessed || isBookedDirectly || isLinkedToTransaction
|
||||
const [isRetrying, setIsRetrying] = useState(false)
|
||||
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
|
||||
|
||||
// "Skapa leverantör" — surface when extraction caught a supplier name
|
||||
// but no existing supplier matched. Frees the user from navigating to
|
||||
// /suppliers/new manually for the very common "first invoice from this
|
||||
// vendor" case.
|
||||
// Surface a quiet hint when extraction caught a supplier name but no existing
|
||||
// supplier matched. The actual creation flow lives on the leverantörsfaktura
|
||||
// form (Skapa & välj), so we don't render a separate button here.
|
||||
const extractedSupplierName = data?.supplier?.name?.trim() || null
|
||||
const showCreateSupplierCta =
|
||||
const showNoMatchHint =
|
||||
!isResolved &&
|
||||
!item.matched_supplier_id &&
|
||||
!!extractedSupplierName
|
||||
|
||||
const handleCreateSupplier = async () => {
|
||||
if (!extractedSupplierName) return
|
||||
setIsCreatingSupplier(true)
|
||||
try {
|
||||
// Heuristic supplier_type: any extracted VAT number starting with "SE"
|
||||
// (or a 10-digit org_number) → Swedish; otherwise default to
|
||||
// non_eu_business. The user can correct on the supplier detail page.
|
||||
const vat = data?.supplier?.vatNumber?.trim() || ''
|
||||
const org = data?.supplier?.orgNumber?.trim() || ''
|
||||
const supplierType =
|
||||
vat.toUpperCase().startsWith('SE') || /^\d{6}-?\d{4}$/.test(org)
|
||||
? 'swedish_business'
|
||||
: 'non_eu_business'
|
||||
|
||||
const createRes = await fetch('/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: extractedSupplierName,
|
||||
supplier_type: supplierType,
|
||||
org_number: org || undefined,
|
||||
vat_number: vat || undefined,
|
||||
address_line1: data?.supplier?.address || undefined,
|
||||
bankgiro: data?.supplier?.bankgiro || undefined,
|
||||
plusgiro: data?.supplier?.plusgiro || undefined,
|
||||
}),
|
||||
})
|
||||
const createJson = await createRes.json().catch(() => ({}))
|
||||
if (!createRes.ok || !createJson?.data?.id) {
|
||||
toast({
|
||||
title: 'Kunde inte skapa leverantör',
|
||||
description: createJson?.error || 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Link the new supplier back to the inbox item so the next action
|
||||
// (Skapa leverantörsfaktura) prefills correctly.
|
||||
const matchRes = await fetch(
|
||||
`/api/extensions/ext/invoice-inbox/items/${item.id}/match-supplier`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_id: createJson.data.id }),
|
||||
},
|
||||
)
|
||||
if (!matchRes.ok) {
|
||||
const matchErr = await matchRes.json().catch(() => ({}))
|
||||
toast({
|
||||
title: 'Leverantör skapad, men inte kopplad',
|
||||
description: matchErr?.error || 'Välj leverantören manuellt på leverantörsfakturan.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({ title: 'Leverantör skapad', description: extractedSupplierName })
|
||||
}
|
||||
await onRetryRequested()
|
||||
} finally {
|
||||
setIsCreatingSupplier(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = async () => {
|
||||
setIsRetrying(true)
|
||||
try {
|
||||
@@ -1412,26 +1347,12 @@ function FieldsRail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skapa leverantör hint — only when no existing supplier matched */}
|
||||
{showCreateSupplierCta && (
|
||||
<div className="border-b bg-primary/5 px-4 py-2.5 text-xs flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
Ingen leverantör matchade <span className="text-foreground font-medium">{extractedSupplierName}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs shrink-0"
|
||||
onClick={handleCreateSupplier}
|
||||
disabled={isCreatingSupplier}
|
||||
>
|
||||
{isCreatingSupplier ? (
|
||||
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3 mr-1.5" />
|
||||
)}
|
||||
Skapa leverantör
|
||||
</Button>
|
||||
{/* Hint only — creation happens on the leverantörsfaktura form via "Skapa & välj" */}
|
||||
{showNoMatchHint && (
|
||||
<div className="border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
|
||||
Ingen leverantör matchade{' '}
|
||||
<span className="text-foreground font-medium">{extractedSupplierName}</span>
|
||||
{' — leverantören skapas när du klickar Skapa leverantörsfaktura.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1604,15 +1525,15 @@ const FIELD_DEFS: FieldDef[] = [
|
||||
{ key: 'supplier.name', label: 'Leverantör', type: 'text' },
|
||||
{ key: 'supplier.orgNumber', label: 'Org.nr', type: 'text' },
|
||||
{ key: 'supplier.vatNumber', label: 'VAT-nr', type: 'text' },
|
||||
{ key: 'invoice.currency', label: 'Valuta', type: 'text' },
|
||||
{ key: 'totals.total', label: 'Totalt', type: 'number', inputMode: 'decimal' },
|
||||
{ key: 'totals.vatAmount', label: 'Moms', type: 'number', inputMode: 'decimal' },
|
||||
{ key: 'supplier.bankgiro', label: 'Bankgiro', type: 'text' },
|
||||
{ key: 'supplier.plusgiro', label: 'Plusgiro', type: 'text' },
|
||||
{ key: 'invoice.invoiceNumber', label: 'Fakturanr', type: 'text' },
|
||||
{ key: 'invoice.paymentReference', label: 'OCR/Referens', type: 'text' },
|
||||
{ key: 'invoice.invoiceDate', label: 'Fakturadatum', type: 'date' },
|
||||
{ key: 'invoice.dueDate', label: 'Förfallodatum', type: 'date' },
|
||||
{ key: 'invoice.currency', label: 'Valuta', type: 'text' },
|
||||
{ key: 'totals.total', label: 'Totalt', type: 'number', inputMode: 'decimal' },
|
||||
{ key: 'totals.vatAmount', label: 'Moms', type: 'number', inputMode: 'decimal' },
|
||||
]
|
||||
|
||||
function readField(data: InvoiceExtractionResult, key: FieldKey): string {
|
||||
|
||||
@@ -67,17 +67,39 @@ export default function OpeningBalanceEditStep({
|
||||
onContinue,
|
||||
onBack,
|
||||
}: OpeningBalanceEditStepProps) {
|
||||
const [rows, setRows] = useState<EditableRow[]>(() =>
|
||||
initialRows.map((r) => ({
|
||||
id: generateId(),
|
||||
account_number: r.account_number,
|
||||
account_name: r.account_name,
|
||||
debit_amount: r.debit_amount,
|
||||
credit_amount: r.credit_amount,
|
||||
validation_errors: r.validation_errors,
|
||||
bas_match: r.bas_match,
|
||||
})),
|
||||
)
|
||||
const [rows, setRows] = useState<EditableRow[]>(() => {
|
||||
// Defense-in-depth dedup: if the parser ever leaks duplicates by
|
||||
// account_number, collapse them here before the user sees them. Union
|
||||
// validation_errors so a warning surfaced only on the later row isn't
|
||||
// silently dropped during the merge.
|
||||
const byAccount = new Map<string, EditableRow>()
|
||||
for (const r of initialRows) {
|
||||
const key = r.account_number.replace(/\D/g, '')
|
||||
const existing = byAccount.get(key)
|
||||
if (existing) {
|
||||
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
|
||||
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
|
||||
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
|
||||
if (r.validation_errors?.length) {
|
||||
const seen = new Set(existing.validation_errors)
|
||||
for (const err of r.validation_errors) {
|
||||
if (!seen.has(err)) existing.validation_errors.push(err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
byAccount.set(key, {
|
||||
id: generateId(),
|
||||
account_number: r.account_number,
|
||||
account_name: r.account_name,
|
||||
debit_amount: r.debit_amount,
|
||||
credit_amount: r.credit_amount,
|
||||
validation_errors: [...r.validation_errors],
|
||||
bas_match: r.bas_match,
|
||||
})
|
||||
}
|
||||
return Array.from(byAccount.values())
|
||||
})
|
||||
|
||||
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
|
||||
const [autocompleteQuery, setAutocompleteQuery] = useState('')
|
||||
|
||||
Reference in New Issue
Block a user