* feat(kpi): expense mix and top suppliers charts Replace the single monthly-trend chart with two additional compact visuals on /kpi: expense composition donut (BAS class 4-7) and top suppliers bar (supplier_invoices sum_sek over the fiscal period). KPIReport gains expenseComposition and topSuppliers fields, computed from the trial balance and supplier_invoices rows already fetched in the API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav): swap Deadlines sidebar slot for Dokumentinkorg Sidebar main-menu slot now points to the invoice-inbox extension. The /deadlines page stays accessible via dashboard widgets and direct links — only the prominent nav entry changes. Most users open gnubok to act on incoming documents, not to read tax deadlines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): cross-currency totals, FX residual, review SEK display Five fixes around foreign-currency supplier invoices: - Form layout: move Valuta / Växelkurs / Reverse charge from collapsed "Övrigt" into a visible row above the line-item table. Auto-fetch the Riksbanken rate when switching to a non-SEK currency; never clobber a user-typed rate; clear it when switching back to SEK. - Form submit: reset() the form on successful submit so the useUnsavedChanges hook detaches its beforeunload listener before the router.push, killing the "Are you sure you want to leave?" prompt that fired during Turbopack-mediated navigations. - BankTransactionPicker: drop the strict currency filter that hid every SEK transaction when the invoice was in EUR/USD. Cross-currency rows fall to the bottom with an "Annan valuta" hint instead of producing a meaningless numeric diff. - match-supplier-invoice route: when the bank transaction currency differs from the invoice currency, compute the FX diff against the AP-booked SEK and pass it to createSupplierInvoicePaymentEntry so 7960/3960 catches the residual instead of leaving a permanent stub on 2440. Fix also covers the "EUR transaction paying a SEK invoice" case that the first iteration missed. - Review dialog: buildJournalPreview now multiplies amounts by the exchange rate so the "Verifikation som bokförs" table shows the actual SEK numbers that hit the DB, not the EUR magnitudes labelled with no unit. Header gains an "(i SEK)" hint when foreign currency. Test coverage for the FX residual path covers SEK-SEK (no diff), SEK-into-EUR-invoice (loss), SEK-into-EUR-invoice (gain), foreign-tx- into-SEK-invoice, and the no-rate fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(inbox): rate limits, multi-file UX, onboarding, retry, supplier autolink Big workspace pass on /e/general/invoice-inbox. Highlights: Backend - New table inbox_rate_counters + RPC check_and_increment_inbox_quota. Postgres-backed (no Upstash dep) per-company limit: 30/min, 500/day. Applied at /upload, /inbound, and /items/:id/retry-extraction. - POST /items/:id/retry-extraction — re-runs the deterministic extractor on a stored document when the previous attempt errored. - POST /items/:id/match-supplier — links a freshly-created supplier back to the inbox item so the next action prefills correctly. - POST /api/transactions/create-from-document — creates an uncategorized manual transaction from an inbox item for the "I have a receipt, no bank transaction" case. The user categorizes through the normal flow. - /inbound caps email at 20 attachments/email; truncated count goes to processing_history as AttachmentsTruncated. Rate-limit drops emit RateLimitedDropped and return 200 so Resend doesn't retry. - attach-document side effect: when the document came from an inbox item, the inbox row's matched_transaction_id is updated so the UI can flip it to "Kopplad till transaktion" without a round-trip. New migration: re-introduces matched_transaction_id on invoice_inbox_items as a plain FK (the AI metadata that the previous migration stripped doesn't come back). Workspace UI - Onboarding card replaces the thin empty-state with a 3-step checkmark guide (Aktivera adress → Ladda upp → Matcha eller bokför). Auto-hides when all three steps are done; localStorage-backed dismiss. Beta badge + link to gnubok.se/priser. - Responsive layout: 3-pane at lg, 2-pane at md, master-detail toggle on phone (list xor detail with a back button). - Filter pills (Alla / Behöver åtgärd / Bearbetade / Fel) + search input above the list — client-side over the existing items list. - Multi-file upload queue with "Laddar X av N…" progress counter on the button. Sequential to avoid hammering pdfjs. Selection stays put during a batch (only single-file drops auto-jump the detail pane). - Bulk select + delete with sticky action bar. Items linked to a supplier invoice are skipped with a count toast. - Retry button in the FieldsRail error branch. - "Skapa transaktion från underlag" CTA in the match dialog when no unmatched bank transactions exist. Prefills date/amount/description from the extracted data; user picks the sign. - "Skapa leverantör" inline CTA when the extractor caught a supplier name with no match against existing suppliers. POSTs /api/suppliers with the extracted fields, then auto-links via /items/:id/match-supplier. - Matched-state CTA renamed to "Bokför transaktionen" with link to /transactions?highlight=<id> so the categorize panel auto-opens. Tests - lib/rate-limits/__tests__/inbox.test.ts — RPC wrapper happy/error/scope - app/api/transactions/create-from-document/__tests__/route.test.ts — auth, validation, 404/409/200/500, inbox-link failure tolerated - extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts — auth, rate limit, 404, 409, 400 no-doc, success, extraction failure - attach-document tests extend coverage to the new inbox-link side effect (both success and best-effort failure paths) - inbound-webhook test mocks the rate-limit module so the queued-mock sequence in each existing test doesn't have to know about it CLAUDE.md gains a row for lib/rate-limits/ so the new helper is discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transactions): paperclip indicator and highlight-row param Close the feedback loop after a user attaches a receipt to a transaction from the inbox: the row in /transactions now shows a paperclip icon when transaction.document_id is set, with a click handler that fetches a signed download URL and opens the document in a new tab. Works for both uncategorized and history views. When the inbox sends a user to /transactions?highlight=<id>, the page now scrolls that row into view and auto-opens the categorize panel if the transaction is still uncategorized. Behind a double-rAF so the row DOM exists when scrollIntoView fires. QuickReviewDialog no longer prompts to upload underlag when the transaction already has a doc attached (which it does after the inbox match flow). Shows "Underlag bifogat — Visa" instead, opening the existing doc in a new tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-444): address review feedback (Greptile + compliance bots) Migration rules - New migration 20260512092423: adds updated_at trigger on inbox_rate_counters (CLAUDE.md rule 2) and explicit USING (false) RLS policies for the four DML verbs to make the SECURITY DEFINER-only intent explicit (rule 1). - New pg-real test inbox-rate-limit.pg.test.ts covering happy path, minute-cap rejection, day-cap rejection, per-company isolation, and the updated_at trigger firing. CLAUDE.md mandates *.pg.test.ts for every new RPC because mocks pass on broken PL/pgSQL. Bugs - Stale exchange rate on currency switch (Greptile P1) — userTouchedRateRef was scoped per session, not per currency. Switching EUR (with a hand-edited rate) → USD kept the EUR rate. Now tracks the last fetched currency in a ref and resets the touched flag on currency change while still honoring manual edits within a single currency. - topSuppliersResult.error silently swallowed (Greptile P2) — failed queries used to render an empty chart matching the no-data state. Logged now. - Currency from extracted_data not validated (GDPR Art.25(2), OWASP V4.5, Swedish compliance bot) — extracted PDF currency was inserted into transactions.currency without sanitisation. Allowlisted against the six supported ISO 4217 codes; coerce to SEK otherwise. - Idempotency gap on create-from-document (OWASP V2.3) — two concurrent POSTs with the same inbox_item_id could each pass the matched_transaction_id IS NULL read and insert duplicate transactions. UPDATE now includes .is('matched_transaction_id', null) as an optimistic-lock release and returns 409 with an orphan-transaction rollback when the predicate doesn't match. - FX residual on cash-method match path (Swedish compliance bot) — createSupplierInvoiceCashEntry has no exchange_rate_difference path, so a cross-currency match would silently leave a 1930 reconciliation gap. Added a guard that returns MATCH_SI_CASH_FX_UNSUPPORTED (400) before the JE is created. Users on cash method can switch to accrual or book the FX diff manually. Design system - gap-y-1.5 / gap-1.5 in KPIExpenseMixChart — replaced with gap-y-2 / gap-2 (CLAUDE.md design tokens; 2.5/1.5/5/hardcoded pixels are forbidden spacing values). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): rename to match applied versions The mcp__plugin_supabase_supabase__apply_migration tool stamps its own timestamp when it applies a migration to the live project, so the version recorded in supabase_migrations.schema_migrations differs from my local generation-time filenames. Renaming the local files so a production CD run sees the migrations as already-applied (matching versions) instead of trying to re-apply them — which would fail for the trigger/RLS migration (CREATE TRIGGER and CREATE POLICY don't support IF NOT EXISTS). Follows the pattern from d854efcd ("chore(migration): rename to match applied version"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(create-from-document): scope orphan rollback DELETE by company_id Defence in depth on the inbox-link race rollback. newTx.id is a fresh UUID from a company-scoped insert two statements above, so the existing single-key DELETE is already safe, but adding .eq('company_id', companyId) makes the cross-company invariant explicit on every write — addresses the OWASP ASVS V2.3 finding from the compliance swarm on PR #444. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nav): mark Dokumentinkorg with Beta badge Same signal we use for Löner and Anställda — the inbox flow (AI extraction, supplier autolink, manual transaction creation) is in end-to-end customer testing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2109 lines
76 KiB
TypeScript
2109 lines
76 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Checkbox } from '@/components/ui/checkbox'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
import { useToast } from '@/components/ui/use-toast'
|
||
import { ToastAction } from '@/components/ui/toast'
|
||
import {
|
||
Inbox,
|
||
Upload,
|
||
Mail,
|
||
FileText,
|
||
Copy,
|
||
RotateCcw,
|
||
Trash2,
|
||
Check,
|
||
Loader2,
|
||
AlertTriangle,
|
||
ArrowRight,
|
||
Plus,
|
||
Link2,
|
||
Search,
|
||
Circle,
|
||
X,
|
||
} from 'lucide-react'
|
||
import Link from 'next/link'
|
||
import { useRouter } from 'next/navigation'
|
||
import { cn, formatCurrency } from '@/lib/utils'
|
||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||
import type { InvoiceExtractionResult } from '@/types'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
} from '@/components/ui/dialog'
|
||
|
||
// ── Types ────────────────────────────────────────────────────
|
||
|
||
interface InboxItem {
|
||
id: string
|
||
status: 'received' | 'error'
|
||
source: 'email' | 'upload'
|
||
created_at: string
|
||
email_from: string | null
|
||
email_subject: string | null
|
||
email_received_at: string | null
|
||
document_id: string | null
|
||
extracted_data: InvoiceExtractionResult | null
|
||
matched_supplier_id: string | null
|
||
matched_transaction_id: string | null
|
||
created_supplier_invoice_id: string | null
|
||
error_message: string | null
|
||
// Set client-side only while a manual upload is in flight. Replaced by a
|
||
// real server-side row once the AI extraction completes.
|
||
isPlaceholder?: boolean
|
||
fileName?: string
|
||
}
|
||
|
||
interface InboxAddress {
|
||
address: string
|
||
local_part: string
|
||
status: string
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────
|
||
|
||
function timeAgo(iso: string): string {
|
||
const ms = Date.now() - new Date(iso).getTime()
|
||
const min = Math.floor(ms / 60000)
|
||
if (min < 1) return 'nyss'
|
||
if (min < 60) return `${min} min sedan`
|
||
const h = Math.floor(min / 60)
|
||
if (h < 24) return `${h} h sedan`
|
||
const d = Math.floor(h / 24)
|
||
if (d < 30) return `${d} d sedan`
|
||
return new Date(iso).toLocaleDateString('sv-SE')
|
||
}
|
||
|
||
function pickAmount(item: InboxItem): number | null {
|
||
return item.extracted_data?.totals?.total ?? null
|
||
}
|
||
|
||
function pickCurrency(item: InboxItem): string {
|
||
return item.extracted_data?.invoice?.currency ?? 'SEK'
|
||
}
|
||
|
||
function pickSupplierName(item: InboxItem): string | null {
|
||
return item.extracted_data?.supplier?.name ?? null
|
||
}
|
||
|
||
// ── Skeleton ─────────────────────────────────────────────────
|
||
|
||
function WorkspaceSkeleton() {
|
||
return (
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-10 w-full" />
|
||
<div className="grid grid-cols-[280px_minmax(0,1fr)_320px] gap-4 h-[calc(100vh-12rem)]">
|
||
<Skeleton className="h-full" />
|
||
<Skeleton className="h-full" />
|
||
<Skeleton className="h-full" />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main component ───────────────────────────────────────────
|
||
|
||
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||
const { toast } = useToast()
|
||
const router = useRouter()
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||
|
||
const [items, setItems] = useState<InboxItem[]>([])
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
// Phone-only master-detail toggle. On screens <md the three panes don't
|
||
// fit side-by-side and stacking them produces a long vertical scroll, so
|
||
// we show the list xor the detail view, with a back button to return.
|
||
const [mobileView, setMobileView] = useState<'list' | 'detail'>('list')
|
||
// List filter + search (client-side over the already-fetched items list).
|
||
const [filter, setFilter] = useState<'all' | 'needs_action' | 'done' | 'error'>('all')
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
// Bulk selection. Items linked to a supplier invoice are skipped at delete
|
||
// time (server returns 409); we still allow them to be selected so the
|
||
// user can see the "X skipped" toast and learn the rule.
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||
const [isBulkDeleting, setIsBulkDeleting] = useState(false)
|
||
// Onboarding card visibility. Hides when all three steps are complete or
|
||
// the user dismissed it. Persisted to localStorage so refresh doesn't
|
||
// revive a dismissed card.
|
||
const [onboardingDismissed, setOnboardingDismissed] = useState(false)
|
||
// Multi-file upload progress. Null when no queue is running. Reflects the
|
||
// sequential progress through a batch ({ total, done }) so the button can
|
||
// show "Laddar X av N…".
|
||
const [uploadQueue, setUploadQueue] = useState<{ total: number; done: number } | null>(null)
|
||
const [selected, setSelected] = useState<InboxItem | null>(null)
|
||
const [docUrl, setDocUrl] = useState<string | null>(null)
|
||
const [docMime, setDocMime] = useState<string | null>(null)
|
||
const [inboxAddress, setInboxAddress] = useState<InboxAddress | null>(null)
|
||
const [isUploading, setIsUploading] = useState(false)
|
||
const [isDeleting, setIsDeleting] = useState(false)
|
||
const [isRotating, setIsRotating] = useState(false)
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const [attachOpen, setAttachOpen] = useState(false)
|
||
|
||
// ── Data loading ───────────────────────────────────────────
|
||
|
||
const fetchItems = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=50')
|
||
const json = await res.json()
|
||
if (res.ok) setItems(json.data?.items ?? [])
|
||
} catch (err) {
|
||
console.error('[invoice-inbox] fetchItems failed:', err)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
const fetchInboxAddress = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/address')
|
||
if (res.ok) {
|
||
const { data } = await res.json()
|
||
setInboxAddress(data)
|
||
}
|
||
} catch {
|
||
// 404 / 503 are expected when no address provisioned yet
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
fetchItems()
|
||
fetchInboxAddress()
|
||
}, [fetchItems, fetchInboxAddress])
|
||
|
||
// Read the onboarding-dismissed flag from localStorage after mount
|
||
// (SSR-safe — no window access during initial render).
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return
|
||
try {
|
||
setOnboardingDismissed(
|
||
window.localStorage.getItem('gnubok.inbox.onboarding.dismissed') === '1'
|
||
)
|
||
} catch {
|
||
// private browsing — keep default (show card)
|
||
}
|
||
}, [])
|
||
|
||
const handleDismissOnboarding = useCallback(() => {
|
||
try {
|
||
window.localStorage.setItem('gnubok.inbox.onboarding.dismissed', '1')
|
||
} catch {
|
||
// ignore; in-memory state is enough for this session
|
||
}
|
||
setOnboardingDismissed(true)
|
||
}, [])
|
||
|
||
// Onboarding card visibility — derived from real progress so a user who
|
||
// already has a working inbox flow never sees the guide. Once they finish
|
||
// all three steps, the card auto-hides on next render.
|
||
const hasInboxAddress = !!inboxAddress
|
||
const hasAnyItem = items.length > 0
|
||
const hasResolvedItem = items.some(
|
||
(it) => !!it.created_supplier_invoice_id || !!it.matched_transaction_id
|
||
)
|
||
const showOnboarding =
|
||
!onboardingDismissed && !(hasInboxAddress && hasAnyItem && hasResolvedItem)
|
||
|
||
// ── List filter + search (client-side over the fetched list) ─
|
||
|
||
const filteredItems = useMemo(() => {
|
||
const term = searchTerm.trim().toLowerCase()
|
||
return items.filter((item) => {
|
||
// Status filter
|
||
const isErr = item.status === 'error'
|
||
const isDone = !!item.created_supplier_invoice_id || !!item.matched_transaction_id
|
||
const needsAction = !isErr && !isDone
|
||
if (filter === 'error' && !isErr) return false
|
||
if (filter === 'done' && !isDone) return false
|
||
if (filter === 'needs_action' && !needsAction) return false
|
||
|
||
// Search filter — supplier name, email subject/from, placeholder filename
|
||
if (term === '') return true
|
||
const haystack = [
|
||
item.extracted_data?.supplier?.name,
|
||
item.email_subject,
|
||
item.email_from,
|
||
item.fileName,
|
||
]
|
||
.filter((v): v is string => !!v)
|
||
.join(' ')
|
||
.toLowerCase()
|
||
return haystack.includes(term)
|
||
})
|
||
}, [items, filter, searchTerm])
|
||
|
||
// ── Selection ──────────────────────────────────────────────
|
||
|
||
const handleSelect = useCallback(async (id: string) => {
|
||
setSelectedId(id)
|
||
setSelected(null)
|
||
setDocUrl(null)
|
||
setDocMime(null)
|
||
setMobileView('detail')
|
||
|
||
try {
|
||
const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`)
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json.error ?? 'Kunde inte hämta posten')
|
||
const item = json.data as InboxItem
|
||
setSelected(item)
|
||
|
||
if (item.document_id) {
|
||
try {
|
||
const docRes = await fetch(`/api/documents/${item.document_id}`)
|
||
if (docRes.ok) {
|
||
const { data } = await docRes.json()
|
||
setDocUrl(data.download_url ?? null)
|
||
setDocMime(data.mime_type ?? null)
|
||
}
|
||
} catch {
|
||
// Preview is optional
|
||
}
|
||
}
|
||
} catch (err) {
|
||
toast({
|
||
title: 'Kunde inte ladda dokumentet',
|
||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
}, [toast])
|
||
|
||
// ── Upload ─────────────────────────────────────────────────
|
||
|
||
// `autoSelect`: jump the detail pane to the new placeholder/row. Useful
|
||
// for a one-off drop (user expects to see what just landed). Harmful in
|
||
// a multi-file queue (selection yanks around as each file processes).
|
||
const uploadFile = useCallback(async (
|
||
file: File,
|
||
options: { autoSelect: boolean } = { autoSelect: true },
|
||
) => {
|
||
// Optimistic placeholder — gives the user an immediate visual response
|
||
// for the 3–8s while extraction runs. Removed once the real row arrives.
|
||
const tempId = `temp-${crypto.randomUUID()}`
|
||
const placeholder: InboxItem = {
|
||
id: tempId,
|
||
status: 'received',
|
||
source: 'upload',
|
||
created_at: new Date().toISOString(),
|
||
email_from: null,
|
||
email_subject: null,
|
||
email_received_at: null,
|
||
document_id: null,
|
||
extracted_data: null,
|
||
matched_supplier_id: null,
|
||
matched_transaction_id: null,
|
||
created_supplier_invoice_id: null,
|
||
error_message: null,
|
||
isPlaceholder: true,
|
||
fileName: file.name,
|
||
}
|
||
setItems((prev) => [placeholder, ...prev])
|
||
if (options.autoSelect) {
|
||
setSelectedId(tempId)
|
||
setSelected(placeholder)
|
||
}
|
||
setIsUploading(true)
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('file', file)
|
||
const res = await fetch('/api/extensions/ext/invoice-inbox/upload', {
|
||
method: 'POST',
|
||
body: fd,
|
||
})
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json.error ?? 'Uppladdning misslyckades')
|
||
toast({ title: 'Dokument uppladdat', description: file.name })
|
||
setItems((prev) => prev.filter((it) => it.id !== tempId))
|
||
await fetchItems()
|
||
if (options.autoSelect && json.data?.inbox_item_id) {
|
||
await handleSelect(json.data.inbox_item_id)
|
||
}
|
||
} catch (err) {
|
||
setItems((prev) => prev.filter((it) => it.id !== tempId))
|
||
if (options.autoSelect) {
|
||
setSelectedId((prev) => (prev === tempId ? null : prev))
|
||
setSelected((prev) => (prev?.id === tempId ? null : prev))
|
||
}
|
||
toast({
|
||
title: 'Uppladdning misslyckades',
|
||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setIsUploading(false)
|
||
}
|
||
}, [fetchItems, handleSelect, toast])
|
||
|
||
// Sequential queue — running multiple extractions concurrently would
|
||
// hammer pdfjs on slow boxes. Per-file placeholder rows + the queue
|
||
// counter on the upload button surface progress.
|
||
const uploadFiles = useCallback(async (files: File[]) => {
|
||
if (files.length === 0) return
|
||
if (files.length === 1) {
|
||
// Single-file drop: keep the historic behavior of jumping the detail
|
||
// pane to the new item. Skip the queue counter — it would just flash.
|
||
await uploadFile(files[0], { autoSelect: true })
|
||
return
|
||
}
|
||
setUploadQueue({ total: files.length, done: 0 })
|
||
try {
|
||
for (const file of files) {
|
||
await uploadFile(file, { autoSelect: false })
|
||
setUploadQueue((q) => (q ? { ...q, done: q.done + 1 } : null))
|
||
}
|
||
} finally {
|
||
setUploadQueue(null)
|
||
}
|
||
}, [uploadFile])
|
||
|
||
const handleFileInputChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = Array.from(e.target.files ?? [])
|
||
if (files.length > 0) await uploadFiles(files)
|
||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||
}, [uploadFiles])
|
||
|
||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
setIsDragging(false)
|
||
const files = Array.from(e.dataTransfer.files ?? [])
|
||
if (files.length > 0) await uploadFiles(files)
|
||
}, [uploadFiles])
|
||
|
||
// ── Delete ─────────────────────────────────────────────────
|
||
|
||
const handleDelete = useCallback(async (id: string) => {
|
||
if (!confirm('Ta bort dokumentet ur inkorgen?')) return
|
||
setIsDeleting(true)
|
||
try {
|
||
const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`, {
|
||
method: 'DELETE',
|
||
})
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json.error ?? 'Kunde inte ta bort')
|
||
toast({ title: 'Borttagen' })
|
||
if (selectedId === id) {
|
||
setSelectedId(null)
|
||
setSelected(null)
|
||
}
|
||
await fetchItems()
|
||
} catch (err) {
|
||
toast({
|
||
title: 'Kunde inte ta bort',
|
||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setIsDeleting(false)
|
||
}
|
||
}, [fetchItems, selectedId, toast])
|
||
|
||
const toggleSelected = useCallback((id: string) => {
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
||
|
||
const handleBulkDelete = useCallback(async () => {
|
||
if (selectedIds.size === 0) return
|
||
if (!confirm(`Ta bort ${selectedIds.size} poster ur inkorgen?`)) return
|
||
|
||
// Skip items that the server would 409 on, surface the count to the user.
|
||
const targets = items.filter((it) => selectedIds.has(it.id))
|
||
const deletable = targets.filter((it) => !it.created_supplier_invoice_id)
|
||
const skipped = targets.length - deletable.length
|
||
|
||
setIsBulkDeleting(true)
|
||
try {
|
||
const results = await Promise.allSettled(
|
||
deletable.map((it) =>
|
||
fetch(`/api/extensions/ext/invoice-inbox/items/${it.id}`, { method: 'DELETE' })
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'fail')
|
||
})
|
||
)
|
||
)
|
||
const failed = results.filter((r) => r.status === 'rejected').length
|
||
const succeeded = deletable.length - failed
|
||
const parts: string[] = []
|
||
if (succeeded > 0) parts.push(`${succeeded} borttagna`)
|
||
if (skipped > 0) parts.push(`${skipped} kopplade till leverantörsfaktura — hoppade över`)
|
||
if (failed > 0) parts.push(`${failed} misslyckades`)
|
||
toast({
|
||
title: 'Bulkborttagning klar',
|
||
description: parts.join(' · '),
|
||
variant: failed > 0 ? 'destructive' : 'default',
|
||
})
|
||
clearSelection()
|
||
// If the currently-selected item was deleted, clear the rail.
|
||
if (selectedId && deletable.some((it) => it.id === selectedId)) {
|
||
setSelectedId(null)
|
||
setSelected(null)
|
||
}
|
||
await fetchItems()
|
||
} finally {
|
||
setIsBulkDeleting(false)
|
||
}
|
||
}, [selectedIds, items, selectedId, fetchItems, toast, clearSelection])
|
||
|
||
// ── Inbox address ──────────────────────────────────────────
|
||
|
||
const handleCopyAddress = useCallback(() => {
|
||
if (!inboxAddress) return
|
||
navigator.clipboard.writeText(inboxAddress.address).catch(() => {})
|
||
toast({ title: 'Adress kopierad' })
|
||
}, [inboxAddress, toast])
|
||
|
||
const handleRotateAddress = useCallback(async () => {
|
||
if (inboxAddress && !confirm('Skapa en ny inkorgsadress? Den gamla slutar att fungera.')) return
|
||
setIsRotating(true)
|
||
try {
|
||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/rotate', {
|
||
method: 'POST',
|
||
})
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json.error ?? 'Rotation misslyckades')
|
||
setInboxAddress(json.data)
|
||
toast({ title: 'Ny adress skapad', description: json.data.address })
|
||
} catch (err) {
|
||
toast({
|
||
title: 'Rotation misslyckades',
|
||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setIsRotating(false)
|
||
}
|
||
}, [toast, inboxAddress])
|
||
|
||
// ── Render ─────────────────────────────────────────────────
|
||
|
||
if (isLoading) return <WorkspaceSkeleton />
|
||
|
||
return (
|
||
<div
|
||
className="h-[calc(100vh-1px)] p-4 md:p-6"
|
||
onDragOver={(e) => { e.preventDefault(); if (!isDragging) setIsDragging(true) }}
|
||
onDragLeave={(e) => {
|
||
// only clear when leaving the workspace itself, not children
|
||
if (e.currentTarget === e.target) setIsDragging(false)
|
||
}}
|
||
onDrop={handleDrop}
|
||
>
|
||
<div className="h-full flex flex-col rounded-lg border bg-card overflow-hidden shadow-sm">
|
||
{/* Top bar */}
|
||
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5 flex-wrap">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Inbox className="h-4 w-4 text-muted-foreground shrink-0" />
|
||
<h1 className="font-medium text-sm shrink-0">Dokumentinkorg</h1>
|
||
{inboxAddress ? (
|
||
<>
|
||
<span className="text-muted-foreground text-xs shrink-0">·</span>
|
||
<code className="font-mono text-xs text-muted-foreground truncate min-w-0">
|
||
{inboxAddress.address}
|
||
</code>
|
||
<button
|
||
type="button"
|
||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||
onClick={handleCopyAddress}
|
||
title="Kopiera adress"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||
onClick={handleRotateAddress}
|
||
disabled={isRotating}
|
||
title="Rotera till ny adress"
|
||
>
|
||
{isRotating ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<RotateCcw className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleRotateAddress}
|
||
disabled={isRotating}
|
||
className="ml-2 shrink-0 h-7 text-xs"
|
||
>
|
||
{isRotating ? (
|
||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||
) : (
|
||
<Mail className="h-3.5 w-3.5 mr-1.5" />
|
||
)}
|
||
Aktivera inkorgsadress
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept="application/pdf,image/jpeg,image/png,image/heic,image/heif,image/webp"
|
||
className="hidden"
|
||
onChange={handleFileInputChange}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isUploading}
|
||
>
|
||
{isUploading ? (
|
||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||
) : (
|
||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||
)}
|
||
{uploadQueue
|
||
? `Laddar ${Math.min(uploadQueue.done + 1, uploadQueue.total)} av ${uploadQueue.total}…`
|
||
: isUploading
|
||
? 'Laddar…'
|
||
: 'Ladda upp'}
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Three-pane body. On phone (<md) we toggle between list and detail
|
||
via `mobileView`; at md+ all three panes show side-by-side. */}
|
||
<div className="flex-1 grid grid-cols-1 md:grid-cols-[240px_minmax(0,1fr)_320px] lg:grid-cols-[280px_minmax(0,1fr)_340px] min-h-0">
|
||
{/* List */}
|
||
<aside
|
||
className={cn(
|
||
'border-r overflow-y-auto bg-muted/20 pt-3 md:block',
|
||
mobileView === 'detail' && 'hidden'
|
||
)}
|
||
>
|
||
{items.length > 0 && (
|
||
<div className="px-3 pb-3 space-y-2 border-b">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Sök i inkorgen…"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="pl-8 h-8 text-xs"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{(
|
||
[
|
||
{ key: 'all', label: 'Alla' },
|
||
{ key: 'needs_action', label: 'Behöver åtgärd' },
|
||
{ key: 'done', label: 'Bearbetade' },
|
||
{ key: 'error', label: 'Fel' },
|
||
] as const
|
||
).map((pill) => (
|
||
<button
|
||
key={pill.key}
|
||
type="button"
|
||
onClick={() => setFilter(pill.key)}
|
||
className={cn(
|
||
'text-[11px] px-2 py-0.5 rounded-full border transition-colors',
|
||
filter === pill.key
|
||
? 'bg-primary text-primary-foreground border-primary'
|
||
: 'bg-background text-muted-foreground border-border hover:text-foreground'
|
||
)}
|
||
>
|
||
{pill.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{selectedIds.size > 0 && (
|
||
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b bg-background/95 backdrop-blur px-3 py-2 text-xs">
|
||
<span className="font-medium">{selectedIds.size} valda</span>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
onClick={clearSelection}
|
||
disabled={isBulkDeleting}
|
||
>
|
||
Avmarkera
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
onClick={handleBulkDelete}
|
||
disabled={isBulkDeleting}
|
||
>
|
||
{isBulkDeleting ? (
|
||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-3 w-3 mr-1" />
|
||
)}
|
||
Ta bort
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{items.length === 0 ? (
|
||
showOnboarding ? (
|
||
<OnboardingCard
|
||
hasInboxAddress={hasInboxAddress}
|
||
hasAnyItem={hasAnyItem}
|
||
hasResolvedItem={hasResolvedItem}
|
||
onActivateInbox={handleRotateAddress}
|
||
onUploadClick={() => fileInputRef.current?.click()}
|
||
onDismiss={handleDismissOnboarding}
|
||
isActivating={isRotating}
|
||
compact
|
||
/>
|
||
) : (
|
||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||
<Inbox className="h-6 w-6 mx-auto mb-2 opacity-50" />
|
||
Inkorgen är tom.
|
||
</div>
|
||
)
|
||
) : filteredItems.length === 0 ? (
|
||
<div className="p-6 text-center text-xs text-muted-foreground">
|
||
Inga poster matchar filtret.
|
||
</div>
|
||
) : (
|
||
<ul>
|
||
{filteredItems.map((item) => (
|
||
<InboxRow
|
||
key={item.id}
|
||
item={item}
|
||
selected={item.id === selectedId}
|
||
onClick={() => handleSelect(item.id)}
|
||
isChecked={selectedIds.has(item.id)}
|
||
onToggleChecked={() => toggleSelected(item.id)}
|
||
anyChecked={selectedIds.size > 0}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</aside>
|
||
|
||
{/* Document preview (hero) */}
|
||
<main
|
||
className={cn(
|
||
'overflow-hidden bg-muted/10 relative md:block',
|
||
mobileView === 'list' && 'hidden'
|
||
)}
|
||
>
|
||
{/* Phone-only back-to-list button */}
|
||
{selected && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setMobileView('list')}
|
||
className="md:hidden absolute top-2 left-2 z-10 flex items-center gap-1 rounded-md bg-background/90 backdrop-blur px-2 py-1 text-xs text-foreground border shadow-sm"
|
||
>
|
||
<ArrowRight className="h-3 w-3 rotate-180" />
|
||
Inkorg
|
||
</button>
|
||
)}
|
||
{selected ? (
|
||
<DocumentPreview docUrl={docUrl} docMime={docMime} isProcessing={!!selected.isPlaceholder} />
|
||
) : showOnboarding ? (
|
||
<div className="h-full flex items-center justify-center px-4 py-6">
|
||
<OnboardingCard
|
||
hasInboxAddress={hasInboxAddress}
|
||
hasAnyItem={hasAnyItem}
|
||
hasResolvedItem={hasResolvedItem}
|
||
onActivateInbox={handleRotateAddress}
|
||
onUploadClick={() => fileInputRef.current?.click()}
|
||
onDismiss={handleDismissOnboarding}
|
||
isActivating={isRotating}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<EmptyPreview
|
||
onUploadClick={() => fileInputRef.current?.click()}
|
||
onActivateInbox={inboxAddress ? null : handleRotateAddress}
|
||
isActivating={isRotating}
|
||
/>
|
||
)}
|
||
{isDragging && (
|
||
<div className="absolute inset-0 bg-primary/5 border-2 border-dashed border-primary rounded-md m-4 flex items-center justify-center pointer-events-none">
|
||
<p className="text-sm font-medium text-primary">Släpp filen för att ladda upp</p>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{/* Fields rail. On phone, sits below the preview (same screen as detail);
|
||
on md+ it's the third pane. */}
|
||
<aside
|
||
className={cn(
|
||
'border-l overflow-y-auto pt-4 md:block',
|
||
mobileView === 'list' && 'hidden'
|
||
)}
|
||
>
|
||
{selected ? (
|
||
<FieldsRail
|
||
item={selected}
|
||
onDelete={() => handleDelete(selected.id)}
|
||
onAttach={() => setAttachOpen(true)}
|
||
isDeleting={isDeleting}
|
||
onRetryRequested={async () => {
|
||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||
}}
|
||
onFieldsUpdated={(nextData) => {
|
||
// Guard against stale closure: if the user navigated to a
|
||
// different item between sending the PATCH and the response
|
||
// arriving, the captured `selected` is no longer the
|
||
// currently-selected one. Without the id check we'd write
|
||
// item A's payload onto item B's row.
|
||
const targetId = selected.id
|
||
setSelected((prev) =>
|
||
prev?.id === targetId ? { ...prev, extracted_data: nextData } : prev
|
||
)
|
||
setItems((prev) =>
|
||
prev.map((it) =>
|
||
it.id === targetId ? { ...it, extracted_data: nextData } : it
|
||
)
|
||
)
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||
Välj en post för att se extraherade fält.
|
||
</div>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
|
||
{selected && (
|
||
<AttachToTransactionDialog
|
||
open={attachOpen}
|
||
onOpenChange={setAttachOpen}
|
||
item={selected}
|
||
onAttached={async (transactionId) => {
|
||
setAttachOpen(false)
|
||
await fetchItems()
|
||
toast({
|
||
title: 'Bilaga kopplad till transaktion',
|
||
description: 'Bokför direkt, eller fortsätt med inkorgen och bokför senare.',
|
||
action: (
|
||
<ToastAction
|
||
altText="Bokför nu"
|
||
onClick={() => router.push(`/transactions?highlight=${transactionId}`)}
|
||
>
|
||
Bokför nu
|
||
</ToastAction>
|
||
),
|
||
})
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Attach-to-transaction dialog ─────────────────────────────
|
||
|
||
interface PickerTransaction {
|
||
id: string
|
||
date: string
|
||
description: string
|
||
amount: number
|
||
currency: string
|
||
}
|
||
|
||
function AttachToTransactionDialog({
|
||
open,
|
||
onOpenChange,
|
||
item,
|
||
onAttached,
|
||
}: {
|
||
open: boolean
|
||
onOpenChange: (v: boolean) => void
|
||
item: InboxItem
|
||
onAttached: (transactionId: string) => void | Promise<void>
|
||
}) {
|
||
const { toast } = useToast()
|
||
const [transactions, setTransactions] = useState<PickerTransaction[]>([])
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [attachingId, setAttachingId] = useState<string | null>(null)
|
||
const [isCreating, setIsCreating] = useState(false)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
|
||
const targetAmount = pickAmount(item)
|
||
const targetCurrency = pickCurrency(item)
|
||
|
||
// Prefill for "create transaction from this document" — defaults to a
|
||
// negative amount because the typical inbox item is an expense receipt
|
||
// (money out). The user can flip the sign in the form if it's an income
|
||
// document.
|
||
const defaultDate =
|
||
item.extracted_data?.invoice?.invoiceDate ||
|
||
new Date().toISOString().slice(0, 10)
|
||
const defaultAmount = targetAmount != null ? -Math.abs(targetAmount) : 0
|
||
const defaultDescription = [
|
||
pickSupplierName(item),
|
||
item.extracted_data?.invoice?.invoiceNumber,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ') || 'Manuell transaktion'
|
||
|
||
const [formDate, setFormDate] = useState(defaultDate)
|
||
const [formAmount, setFormAmount] = useState<string>(
|
||
defaultAmount !== 0 ? String(defaultAmount) : ''
|
||
)
|
||
const [formDescription, setFormDescription] = useState(defaultDescription)
|
||
|
||
useEffect(() => {
|
||
setFormDate(defaultDate)
|
||
setFormAmount(defaultAmount !== 0 ? String(defaultAmount) : '')
|
||
setFormDescription(defaultDescription)
|
||
setSearchTerm('')
|
||
// Reset whenever a different inbox item opens the dialog.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [item.id])
|
||
|
||
const filteredTransactions = useMemo(() => {
|
||
const term = searchTerm.trim().toLowerCase()
|
||
if (term === '') return transactions
|
||
return transactions.filter((t) => (t.description || '').toLowerCase().includes(term))
|
||
}, [transactions, searchTerm])
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
let cancelled = false
|
||
setIsLoading(true)
|
||
;(async () => {
|
||
try {
|
||
const res = await fetch('/api/transactions?unmatched=true')
|
||
const json = await res.json()
|
||
if (cancelled) return
|
||
const rows: PickerTransaction[] = (Array.isArray(json.data) ? json.data : [])
|
||
.map((t: PickerTransaction) => ({
|
||
id: t.id,
|
||
date: t.date,
|
||
description: t.description,
|
||
amount: t.amount,
|
||
currency: t.currency || 'SEK',
|
||
}))
|
||
setTransactions(rankByAmount(rows, targetAmount, targetCurrency))
|
||
} catch (err) {
|
||
console.error('[invoice-inbox/attach] fetch failed:', err)
|
||
toast({ title: 'Kunde inte ladda transaktioner', variant: 'destructive' })
|
||
} finally {
|
||
if (!cancelled) setIsLoading(false)
|
||
}
|
||
})()
|
||
return () => { cancelled = true }
|
||
}, [open, targetAmount, targetCurrency, toast])
|
||
|
||
const handleAttach = async (tx: PickerTransaction) => {
|
||
if (!item.document_id) return
|
||
setAttachingId(tx.id)
|
||
try {
|
||
const res = await fetch(`/api/transactions/${tx.id}/attach-document`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ document_id: item.document_id }),
|
||
})
|
||
if (!res.ok) {
|
||
const json = await res.json().catch(() => ({}))
|
||
toast({ title: json.error || 'Kunde inte koppla bilaga', variant: 'destructive' })
|
||
return
|
||
}
|
||
await onAttached(tx.id)
|
||
} finally {
|
||
setAttachingId(null)
|
||
}
|
||
}
|
||
|
||
const handleCreateTransaction = async () => {
|
||
const amountNum = Number(formAmount)
|
||
if (!Number.isFinite(amountNum) || amountNum === 0) {
|
||
toast({ title: 'Ange ett belopp skilt från noll', variant: 'destructive' })
|
||
return
|
||
}
|
||
if (!formDate || !formDescription.trim()) {
|
||
toast({ title: 'Fyll i datum och beskrivning', variant: 'destructive' })
|
||
return
|
||
}
|
||
setIsCreating(true)
|
||
try {
|
||
const res = await fetch('/api/transactions/create-from-document', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
inbox_item_id: item.id,
|
||
amount: amountNum,
|
||
transaction_date: formDate,
|
||
description: formDescription.trim(),
|
||
}),
|
||
})
|
||
const json = await res.json().catch(() => ({}))
|
||
if (!res.ok) {
|
||
toast({
|
||
title: json.error || 'Kunde inte skapa transaktion',
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
const newTxId = json?.data?.transaction_id as string | undefined
|
||
if (newTxId) {
|
||
await onAttached(newTxId)
|
||
} else {
|
||
// No id back — fall back to closing without the "Bokför nu" CTA.
|
||
toast({
|
||
title: 'Transaktion skapad',
|
||
description: 'Hittas under Transaktioner för kategorisering.',
|
||
})
|
||
}
|
||
} finally {
|
||
setIsCreating(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Koppla bilaga till transaktion</DialogTitle>
|
||
<DialogDescription>
|
||
{targetAmount != null
|
||
? `Belopp på fakturan: ${formatCurrency(targetAmount, pickCurrency(item))}. Listan är sorterad efter beloppsmatch.`
|
||
: 'Välj en transaktion att koppla bilagan till.'}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{!isLoading && transactions.length > 0 && (
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
autoFocus
|
||
placeholder="Sök på beskrivning…"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Laddar transaktioner…
|
||
</div>
|
||
) : transactions.length === 0 ? (
|
||
<div className="py-10">
|
||
<div className="text-center space-y-1.5 mb-6">
|
||
<p className="text-sm font-medium">Hittade ingen transaktion</p>
|
||
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
|
||
Skapa en manuell transaktion från underlaget och kategorisera
|
||
den i transaktionsvyn efter att den är skapad.
|
||
</p>
|
||
</div>
|
||
<div className="max-w-sm mx-auto space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<label
|
||
htmlFor="manual-tx-date"
|
||
className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||
>
|
||
Datum
|
||
</label>
|
||
<Input
|
||
id="manual-tx-date"
|
||
type="date"
|
||
value={formDate}
|
||
onChange={(e) => setFormDate(e.target.value)}
|
||
disabled={isCreating}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label
|
||
htmlFor="manual-tx-amount"
|
||
className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||
>
|
||
Belopp
|
||
</label>
|
||
<Input
|
||
id="manual-tx-amount"
|
||
type="number"
|
||
step="0.01"
|
||
inputMode="decimal"
|
||
value={formAmount}
|
||
onChange={(e) => setFormAmount(e.target.value)}
|
||
placeholder="-0.00"
|
||
className="tabular-nums"
|
||
disabled={isCreating}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label
|
||
htmlFor="manual-tx-description"
|
||
className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||
>
|
||
Beskrivning
|
||
</label>
|
||
<Input
|
||
id="manual-tx-description"
|
||
type="text"
|
||
value={formDescription}
|
||
onChange={(e) => setFormDescription(e.target.value)}
|
||
disabled={isCreating}
|
||
/>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
Negativt belopp för utgift, positivt för inkomst.
|
||
</p>
|
||
<Button
|
||
type="button"
|
||
className="w-full"
|
||
onClick={handleCreateTransaction}
|
||
disabled={isCreating}
|
||
>
|
||
{isCreating ? (
|
||
<>
|
||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
||
Skapar transaktion…
|
||
</>
|
||
) : (
|
||
<>
|
||
<Plus className="h-3.5 w-3.5 mr-2" />
|
||
Skapa transaktion från underlag
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : filteredTransactions.length === 0 ? (
|
||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||
Inga transaktioner matchar “{searchTerm}”.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y">
|
||
{filteredTransactions.map((tx) => (
|
||
<li key={tx.id}>
|
||
<button
|
||
type="button"
|
||
className="w-full flex items-center justify-between gap-4 px-1 py-3 text-left hover:bg-accent/40 disabled:opacity-50"
|
||
onClick={() => handleAttach(tx)}
|
||
disabled={attachingId !== null}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-medium truncate">{tx.description}</p>
|
||
<p className="text-xs text-muted-foreground">{tx.date}</p>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
'text-sm tabular-nums whitespace-nowrap',
|
||
targetAmount != null
|
||
&& tx.currency === targetCurrency
|
||
&& Math.abs(Math.abs(tx.amount) - Math.abs(targetAmount)) < 0.01
|
||
? 'font-semibold'
|
||
: '',
|
||
)}
|
||
>
|
||
{formatCurrency(tx.amount, tx.currency)}
|
||
</span>
|
||
{attachingId === tx.id && <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function rankByAmount(
|
||
rows: PickerTransaction[],
|
||
target: number | null,
|
||
targetCurrency: string,
|
||
): PickerTransaction[] {
|
||
if (target == null) return rows
|
||
const t = Math.abs(target)
|
||
// Same-currency rows rank by amount distance. Cross-currency rows go to
|
||
// the bottom — comparing a EUR invoice's amount to a SEK transaction's
|
||
// amount numerically would be misleading and could cause a wrong attachment
|
||
// (which then becomes verifikation underlag, BFL 5 kap 6 §). The user can
|
||
// still manually pick a cross-currency match by scrolling down.
|
||
return [...rows].sort((a, b) => {
|
||
const aMatch = a.currency === targetCurrency
|
||
const bMatch = b.currency === targetCurrency
|
||
if (aMatch !== bMatch) return aMatch ? -1 : 1
|
||
if (!aMatch) return 0
|
||
const da = Math.abs(Math.abs(a.amount) - t)
|
||
const db = Math.abs(Math.abs(b.amount) - t)
|
||
return da - db
|
||
})
|
||
}
|
||
|
||
// ── List row ─────────────────────────────────────────────────
|
||
|
||
function InboxRow({
|
||
item,
|
||
selected,
|
||
onClick,
|
||
isChecked,
|
||
onToggleChecked,
|
||
anyChecked,
|
||
}: {
|
||
item: InboxItem
|
||
selected: boolean
|
||
onClick: () => void
|
||
isChecked: boolean
|
||
onToggleChecked: () => void
|
||
/** True when bulk-select mode is active anywhere in the list — keeps the
|
||
checkbox visible (otherwise it's hover-only on desktop). */
|
||
anyChecked: boolean
|
||
}) {
|
||
const amount = pickAmount(item)
|
||
const supplierName = pickSupplierName(item)
|
||
const isErrored = item.status === 'error'
|
||
const isProcessed = !!item.created_supplier_invoice_id
|
||
const isLinkedToTransaction = !isProcessed && !!item.matched_transaction_id
|
||
const isPlaceholder = !!item.isPlaceholder
|
||
|
||
return (
|
||
<li
|
||
className={cn(
|
||
'group flex items-stretch border-b transition-colors',
|
||
selected ? 'bg-background border-l-2 border-l-primary' : 'hover:bg-background',
|
||
isErrored && !selected && 'bg-destructive/[0.03]'
|
||
)}
|
||
>
|
||
{!isPlaceholder && (
|
||
<div
|
||
className={cn(
|
||
'flex items-center pl-2.5 pr-1.5 transition-opacity',
|
||
// Always visible on touch (no hover), or when any selection is active.
|
||
anyChecked ? 'opacity-100' : 'md:opacity-0 md:group-hover:opacity-100 opacity-100'
|
||
)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Checkbox
|
||
checked={isChecked}
|
||
onCheckedChange={onToggleChecked}
|
||
aria-label="Markera post"
|
||
className="h-3.5 w-3.5"
|
||
/>
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={isPlaceholder}
|
||
className={cn(
|
||
'flex-1 text-left px-3 py-2 flex flex-col gap-0.5 min-w-0',
|
||
isPlaceholder && 'cursor-default'
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
{isPlaceholder ? (
|
||
<Loader2 className="h-3 w-3 text-muted-foreground shrink-0 animate-spin" />
|
||
) : item.source === 'email' ? (
|
||
<Mail className="h-3 w-3 text-muted-foreground shrink-0" />
|
||
) : (
|
||
<Upload className="h-3 w-3 text-muted-foreground shrink-0" />
|
||
)}
|
||
<span className="text-sm font-medium truncate flex-1 min-w-0">
|
||
{isPlaceholder
|
||
? (item.fileName ?? 'Nytt dokument')
|
||
: (supplierName ?? item.email_subject ?? 'Okänt dokument')}
|
||
</span>
|
||
{isErrored && (
|
||
<AlertTriangle className="h-3 w-3 text-destructive shrink-0" />
|
||
)}
|
||
{isLinkedToTransaction && (
|
||
<Link2 className="h-3 w-3 text-emerald-600 shrink-0" aria-label="Kopplad till transaktion" />
|
||
)}
|
||
{isProcessed && (
|
||
<Check className="h-3 w-3 text-emerald-600 shrink-0" />
|
||
)}
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||
{isPlaceholder ? (
|
||
<span className="italic">Tolkar dokument med AI…</span>
|
||
) : (
|
||
<span className="truncate">{timeAgo(item.email_received_at ?? item.created_at)}</span>
|
||
)}
|
||
{!isPlaceholder && amount != null && (
|
||
<span className="tabular-nums shrink-0">
|
||
{formatCurrency(amount, pickCurrency(item))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
</li>
|
||
)
|
||
}
|
||
|
||
// ── Document preview pane ────────────────────────────────────
|
||
// (placed below the row so editors can fold the row cleanly)
|
||
|
||
function DocumentPreview({
|
||
docUrl,
|
||
docMime,
|
||
isProcessing = false,
|
||
}: {
|
||
docUrl: string | null
|
||
docMime: string | null
|
||
isProcessing?: boolean
|
||
}) {
|
||
if (isProcessing) {
|
||
return (
|
||
<div className="h-full flex flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||
<Loader2 className="h-6 w-6 animate-spin" />
|
||
<span>Tolkar dokument med AI…</span>
|
||
</div>
|
||
)
|
||
}
|
||
if (!docUrl) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||
<FileText className="h-5 w-5 mr-2" />
|
||
Inget underlag bifogat
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="h-full w-full p-4 flex items-start justify-center overflow-hidden">
|
||
{docMime?.startsWith('image/') ? (
|
||
// Image: frame hugs the image, capped at the parent's visible box.
|
||
<div className="max-h-full max-w-3xl bg-background rounded-md border shadow-sm overflow-hidden flex">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={docUrl}
|
||
alt="Underlag"
|
||
className="block max-h-[calc(100vh-9rem)] max-w-full w-auto h-auto object-contain"
|
||
/>
|
||
</div>
|
||
) : (
|
||
// PDF: iframe needs explicit height — frame fills the available pane.
|
||
<div className="h-full w-full max-w-3xl bg-background rounded-md border shadow-sm overflow-hidden">
|
||
<iframe src={docUrl} className="w-full h-full border-0" title="Underlag" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Empty preview state ──────────────────────────────────────
|
||
|
||
// ── Onboarding card ──────────────────────────────────────────
|
||
|
||
interface OnboardingCardProps {
|
||
hasInboxAddress: boolean
|
||
hasAnyItem: boolean
|
||
hasResolvedItem: boolean
|
||
onActivateInbox: () => void
|
||
onUploadClick: () => void
|
||
onDismiss: () => void
|
||
isActivating: boolean
|
||
compact?: boolean
|
||
}
|
||
|
||
function OnboardingCard({
|
||
hasInboxAddress,
|
||
hasAnyItem,
|
||
hasResolvedItem,
|
||
onActivateInbox,
|
||
onUploadClick,
|
||
onDismiss,
|
||
isActivating,
|
||
compact = false,
|
||
}: OnboardingCardProps) {
|
||
const steps = [
|
||
{
|
||
done: hasInboxAddress,
|
||
title: 'Aktivera din inkorgsadress',
|
||
hint: 'Få en unik e-postadress som leverantörer kan skicka fakturor och kvitton till.',
|
||
},
|
||
{
|
||
done: hasAnyItem,
|
||
title: 'Ladda upp eller maila in ett underlag',
|
||
hint: 'gnubok tolkar fakturan eller kvittot åt dig och fyller i fält automatiskt.',
|
||
},
|
||
{
|
||
done: hasResolvedItem,
|
||
title: 'Matcha mot en transaktion eller bokför',
|
||
hint: 'Eller skapa en manuell transaktion om underlaget saknar bankhändelse.',
|
||
},
|
||
]
|
||
// First incomplete step drives the active CTA. Falls back to -1 if all done
|
||
// (the parent should have hidden the card by then, but guard anyway).
|
||
const currentStep = steps.findIndex((s) => !s.done)
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'relative rounded-xl border bg-card shadow-sm',
|
||
compact ? 'mx-3 my-3 p-4 text-xs' : 'max-w-md mx-auto p-6'
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={onDismiss}
|
||
aria-label="Dölj guide"
|
||
className="absolute top-2 right-2 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
|
||
<div className={cn('space-y-1', compact ? 'pr-6' : 'pr-8')}>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<h2
|
||
className={cn(
|
||
'font-display font-medium tracking-tight',
|
||
compact ? 'text-sm' : 'text-lg'
|
||
)}
|
||
>
|
||
Så funkar dokumentinkorgen
|
||
</h2>
|
||
<Badge variant="secondary" className="text-[10px] uppercase tracking-wider">
|
||
Beta
|
||
</Badge>
|
||
</div>
|
||
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
|
||
Underlagen samlas här — från mail eller filuppladdning — och kan
|
||
matchas mot bankhändelser eller bokföras direkt.
|
||
</p>
|
||
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
|
||
Gratis under beta för Open-användare. Ingår senare i Pro-planen.{' '}
|
||
<a
|
||
href="https://www.gnubok.se/priser"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-primary hover:underline"
|
||
>
|
||
Se priser →
|
||
</a>
|
||
</p>
|
||
</div>
|
||
|
||
<ol className={cn('space-y-2.5', compact ? 'mt-3' : 'mt-5')}>
|
||
{steps.map((step, i) => {
|
||
const isDone = step.done
|
||
const isCurrent = !isDone && i === currentStep
|
||
return (
|
||
<li
|
||
key={step.title}
|
||
className={cn('flex items-start gap-2.5', compact && 'gap-2')}
|
||
>
|
||
<span className="shrink-0 mt-0.5">
|
||
{isDone ? (
|
||
<Check className={cn('text-emerald-600', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||
) : isCurrent ? (
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground font-medium',
|
||
compact ? 'h-3.5 w-3.5 text-[9px]' : 'h-4 w-4 text-[10px]'
|
||
)}
|
||
>
|
||
{i + 1}
|
||
</span>
|
||
) : (
|
||
<Circle className={cn('text-muted-foreground/40', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||
)}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p
|
||
className={cn(
|
||
'font-medium',
|
||
isDone ? 'text-muted-foreground line-through decoration-muted-foreground/40' : 'text-foreground',
|
||
compact ? 'text-xs' : 'text-sm'
|
||
)}
|
||
>
|
||
{step.title}
|
||
</p>
|
||
{!isDone && (
|
||
<p
|
||
className={cn(
|
||
'text-muted-foreground mt-0.5',
|
||
compact ? 'text-[11px]' : 'text-xs'
|
||
)}
|
||
>
|
||
{step.hint}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ol>
|
||
|
||
{/* CTA matches the current step. No CTA for step 3 — it requires the user to pick a row. */}
|
||
{currentStep === 0 && (
|
||
<Button
|
||
size={compact ? 'sm' : 'default'}
|
||
className={cn('w-full mt-4', compact && 'h-8 text-xs')}
|
||
onClick={onActivateInbox}
|
||
disabled={isActivating}
|
||
>
|
||
{isActivating ? (
|
||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||
) : (
|
||
<Mail className="h-3.5 w-3.5 mr-1.5" />
|
||
)}
|
||
Aktivera inkorgsadress
|
||
</Button>
|
||
)}
|
||
{currentStep === 1 && (
|
||
<div className={cn('mt-4 space-y-2', compact && 'mt-3')}>
|
||
<Button
|
||
size={compact ? 'sm' : 'default'}
|
||
className={cn('w-full', compact && 'h-8 text-xs')}
|
||
onClick={onUploadClick}
|
||
>
|
||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||
Ladda upp en fil
|
||
</Button>
|
||
<p className={cn('text-center text-muted-foreground', compact ? 'text-[10px]' : 'text-[11px]')}>
|
||
…eller maila underlagen till din inkorgsadress.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{currentStep === 2 && (
|
||
<p
|
||
className={cn(
|
||
'mt-4 text-center italic text-muted-foreground',
|
||
compact ? 'text-[11px]' : 'text-xs'
|
||
)}
|
||
>
|
||
Välj en post i listan för att matcha eller bokföra.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EmptyPreview({
|
||
onUploadClick,
|
||
onActivateInbox,
|
||
isActivating,
|
||
}: {
|
||
onUploadClick: () => void
|
||
onActivateInbox: (() => void) | null
|
||
isActivating: boolean
|
||
}) {
|
||
return (
|
||
<div className="h-full flex flex-col items-center justify-center text-center px-6 gap-3">
|
||
<Inbox className="h-10 w-10 text-muted-foreground/40" />
|
||
<div>
|
||
<p className="text-sm font-medium">
|
||
{onActivateInbox ? 'Aktivera din inkorgsadress' : 'Välj ett dokument från listan'}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
{onActivateInbox
|
||
? 'Ditt bolag får en unik e-postadress som leverantörer kan skicka fakturor till.'
|
||
: 'Eller dra och släpp en fil var som helst på sidan för att ladda upp.'}
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{onActivateInbox && (
|
||
<Button size="sm" onClick={onActivateInbox} disabled={isActivating}>
|
||
{isActivating ? (
|
||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||
) : (
|
||
<Mail className="h-3.5 w-3.5 mr-1.5" />
|
||
)}
|
||
Aktivera inkorgsadress
|
||
</Button>
|
||
)}
|
||
<Button variant="outline" size="sm" onClick={onUploadClick}>
|
||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||
Ladda upp en fil
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Fields rail ──────────────────────────────────────────────
|
||
|
||
function FieldsRail({
|
||
item,
|
||
onDelete,
|
||
onAttach,
|
||
isDeleting,
|
||
onFieldsUpdated,
|
||
onRetryRequested,
|
||
}: {
|
||
item: InboxItem
|
||
onDelete: () => void
|
||
onAttach: () => void
|
||
isDeleting: boolean
|
||
onFieldsUpdated: (data: InvoiceExtractionResult) => void
|
||
onRetryRequested: () => Promise<void>
|
||
}) {
|
||
const { toast } = useToast()
|
||
const data = item.extracted_data
|
||
const isProcessed = !!item.created_supplier_invoice_id
|
||
const isLinkedToTransaction = !isProcessed && !!item.matched_transaction_id
|
||
const isResolved = isProcessed || 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.
|
||
const extractedSupplierName = data?.supplier?.name?.trim() || null
|
||
const showCreateSupplierCta =
|
||
!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 {
|
||
const res = await fetch(
|
||
`/api/extensions/ext/invoice-inbox/items/${item.id}/retry-extraction`,
|
||
{ method: 'POST' },
|
||
)
|
||
const json = await res.json().catch(() => ({}))
|
||
if (!res.ok) {
|
||
toast({
|
||
title: 'Tolkning misslyckades',
|
||
description: json.error || 'Försök igen om en stund.',
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
toast({ title: 'Tolkning lyckades' })
|
||
await onRetryRequested()
|
||
} finally {
|
||
setIsRetrying(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Email metadata */}
|
||
{item.source === 'email' && (item.email_from || item.email_subject) && (
|
||
<div className="border-b px-4 py-3 text-xs space-y-1">
|
||
{item.email_from && (
|
||
<div className="flex gap-2">
|
||
<span className="text-muted-foreground w-14 shrink-0">Från</span>
|
||
<span className="truncate">{item.email_from}</span>
|
||
</div>
|
||
)}
|
||
{item.email_subject && (
|
||
<div className="flex gap-2">
|
||
<span className="text-muted-foreground w-14 shrink-0">Ämne</span>
|
||
<span className="truncate">{item.email_subject}</span>
|
||
</div>
|
||
)}
|
||
{item.email_received_at && (
|
||
<div className="flex gap-2">
|
||
<span className="text-muted-foreground w-14 shrink-0">Mottaget</span>
|
||
<span>{new Date(item.email_received_at).toLocaleString('sv-SE')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{item.error_message && (
|
||
<div className="border-b border-destructive/30 bg-destructive/5 px-4 py-3 text-xs space-y-2">
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium">Fel vid bearbetning</p>
|
||
<p className="text-muted-foreground mt-0.5">{item.error_message}</p>
|
||
</div>
|
||
</div>
|
||
{item.document_id && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full h-7 text-xs"
|
||
onClick={handleRetry}
|
||
disabled={isRetrying}
|
||
>
|
||
{isRetrying ? (
|
||
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||
) : (
|
||
<RotateCcw className="h-3 w-3 mr-1.5" />
|
||
)}
|
||
Försök igen
|
||
</Button>
|
||
)}
|
||
</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>
|
||
</div>
|
||
)}
|
||
|
||
{/* Extracted fields */}
|
||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||
<h3 className="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-3">
|
||
Extraherade fält
|
||
</h3>
|
||
{item.isPlaceholder ? (
|
||
<div className="space-y-2">
|
||
<div className="text-xs text-muted-foreground italic flex items-center gap-2 mb-2">
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
Tolkar dokument med AI…
|
||
</div>
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-8 w-full" />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EditableFieldsList
|
||
itemId={item.id}
|
||
data={data ?? emptyExtraction()}
|
||
disabled={isResolved}
|
||
onUpdated={onFieldsUpdated}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Actions — hidden while AI extraction is in flight */}
|
||
{!item.isPlaceholder && (
|
||
<div className="border-t px-4 py-3 space-y-2">
|
||
{isProcessed && item.created_supplier_invoice_id ? (
|
||
<Link href={`/supplier-invoices/${item.created_supplier_invoice_id}`} className="block">
|
||
<Button variant="default" size="sm" className="w-full">
|
||
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
||
Öppna leverantörsfaktura
|
||
</Button>
|
||
</Link>
|
||
) : isLinkedToTransaction && item.matched_transaction_id ? (
|
||
<Link href={`/transactions?highlight=${item.matched_transaction_id}`} className="block">
|
||
<Button variant="default" size="sm" className="w-full">
|
||
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
||
Bokför transaktionen
|
||
</Button>
|
||
</Link>
|
||
) : (
|
||
<>
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={onAttach}
|
||
disabled={!item.document_id}
|
||
title={!item.document_id ? 'Ingen bilaga att koppla' : undefined}
|
||
>
|
||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||
Koppla till transaktion
|
||
</Button>
|
||
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
|
||
<Button variant="outline" size="sm" className="w-full">
|
||
Skapa leverantörsfaktura
|
||
</Button>
|
||
</Link>
|
||
</>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={onDelete}
|
||
disabled={isDeleting || isResolved}
|
||
title={
|
||
isProcessed
|
||
? 'Kopplad till leverantörsfaktura — kan inte tas bort'
|
||
: isLinkedToTransaction
|
||
? 'Kopplad till transaktion — koppla loss innan borttagning'
|
||
: undefined
|
||
}
|
||
>
|
||
{isDeleting ? (
|
||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||
)}
|
||
Ta bort
|
||
</Button>
|
||
{isProcessed && (
|
||
<Badge variant="secondary" className="w-full justify-center text-[10px]">
|
||
<Check className="h-2.5 w-2.5 mr-1" />
|
||
Bearbetad
|
||
</Badge>
|
||
)}
|
||
{isLinkedToTransaction && (
|
||
<Badge variant="secondary" className="w-full justify-center text-[10px]">
|
||
<Link2 className="h-2.5 w-2.5 mr-1" />
|
||
Kopplad till transaktion
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Extracted fields list ────────────────────────────────────
|
||
|
||
function emptyExtraction(): InvoiceExtractionResult {
|
||
return {
|
||
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
||
lineItems: [],
|
||
totals: { subtotal: null, vatAmount: null, total: null },
|
||
vatBreakdown: [],
|
||
confidence: 0,
|
||
}
|
||
}
|
||
|
||
// Inline edit + debounced auto-save. The field set mirrors the
|
||
// UpdateExtractedDataSchema in extensions/general/invoice-inbox/index.ts.
|
||
type FieldKey =
|
||
| 'supplier.name'
|
||
| 'supplier.orgNumber'
|
||
| 'supplier.vatNumber'
|
||
| 'supplier.bankgiro'
|
||
| 'supplier.plusgiro'
|
||
| 'invoice.invoiceNumber'
|
||
| 'invoice.paymentReference'
|
||
| 'invoice.invoiceDate'
|
||
| 'invoice.dueDate'
|
||
| 'invoice.currency'
|
||
| 'totals.total'
|
||
| 'totals.vatAmount'
|
||
|
||
interface FieldDef {
|
||
key: FieldKey
|
||
label: string
|
||
type: 'text' | 'date' | 'number'
|
||
inputMode?: 'numeric' | 'decimal'
|
||
}
|
||
|
||
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: '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 {
|
||
const [group, name] = key.split('.') as [keyof InvoiceExtractionResult, string]
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const value = (data[group] as any)?.[name]
|
||
if (value == null) return ''
|
||
return String(value)
|
||
}
|
||
|
||
function buildPatchBody(key: FieldKey, raw: string, currency: string) {
|
||
const [group, name] = key.split('.')
|
||
const trimmed = raw.trim()
|
||
|
||
if (group === 'totals') {
|
||
const num = trimmed === '' ? null : Number(trimmed.replace(',', '.'))
|
||
if (num != null && !Number.isFinite(num)) return null
|
||
return { totals: { [name]: num } }
|
||
}
|
||
if (group === 'invoice' && (name === 'invoiceDate' || name === 'dueDate')) {
|
||
const value = trimmed === '' ? null : trimmed
|
||
return { invoice: { [name]: value } }
|
||
}
|
||
if (group === 'invoice' && name === 'currency') {
|
||
return { invoice: { currency: trimmed === '' ? currency : trimmed.toUpperCase() } }
|
||
}
|
||
return { [group]: { [name]: trimmed === '' ? null : trimmed } }
|
||
}
|
||
|
||
function EditableFieldsList({
|
||
itemId,
|
||
data,
|
||
disabled,
|
||
onUpdated,
|
||
}: {
|
||
itemId: string
|
||
data: InvoiceExtractionResult
|
||
disabled: boolean
|
||
onUpdated: (data: InvoiceExtractionResult) => void
|
||
}) {
|
||
const { toast } = useToast()
|
||
const [drafts, setDrafts] = useState<Record<FieldKey, string>>(() =>
|
||
Object.fromEntries(FIELD_DEFS.map((f) => [f.key, readField(data, f.key)])) as Record<FieldKey, string>
|
||
)
|
||
const timersRef = useRef<Partial<Record<FieldKey, ReturnType<typeof setTimeout>>>>({})
|
||
// Last-known server values per field. Used to detect when the server
|
||
// normalises a value (currency upper-cased, whitespace trimmed) so we can
|
||
// pick up the canonical value into the input without clobbering an
|
||
// in-progress edit.
|
||
const lastServerRef = useRef<Record<FieldKey, string>>(
|
||
Object.fromEntries(FIELD_DEFS.map((f) => [f.key, readField(data, f.key)])) as Record<FieldKey, string>
|
||
)
|
||
|
||
// Reset drafts when the user switches to a different inbox item.
|
||
useEffect(() => {
|
||
const seeded = Object.fromEntries(
|
||
FIELD_DEFS.map((f) => [f.key, readField(data, f.key)])
|
||
) as Record<FieldKey, string>
|
||
setDrafts(seeded)
|
||
lastServerRef.current = seeded
|
||
return () => {
|
||
for (const t of Object.values(timersRef.current)) {
|
||
if (t) clearTimeout(t)
|
||
}
|
||
timersRef.current = {}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [itemId])
|
||
|
||
// Re-seed drafts when the server returns normalised values (e.g. uppercased
|
||
// currency, trimmed strings). Only update fields where the local draft
|
||
// matches the previous server value — i.e. the user hasn't typed anything
|
||
// newer that we'd otherwise clobber.
|
||
useEffect(() => {
|
||
let dirty = false
|
||
const next: Record<FieldKey, string> = { ...lastServerRef.current }
|
||
setDrafts((prev) => {
|
||
const updated = { ...prev }
|
||
for (const f of FIELD_DEFS) {
|
||
const newServer = readField(data, f.key)
|
||
const prevServer = lastServerRef.current[f.key]
|
||
if (newServer !== prevServer) {
|
||
next[f.key] = newServer
|
||
// Only sync into the input if the user hadn't started a new edit.
|
||
if (prev[f.key] === prevServer) {
|
||
updated[f.key] = newServer
|
||
dirty = true
|
||
}
|
||
}
|
||
}
|
||
return dirty ? updated : prev
|
||
})
|
||
lastServerRef.current = next
|
||
}, [data])
|
||
|
||
const currency = data.invoice?.currency ?? 'SEK'
|
||
|
||
const persist = useCallback(
|
||
async (key: FieldKey, raw: string) => {
|
||
const body = buildPatchBody(key, raw, currency)
|
||
if (!body) {
|
||
toast({ variant: 'destructive', title: 'Ogiltigt värde' })
|
||
setDrafts((prev) => ({ ...prev, [key]: readField(data, key) }))
|
||
return
|
||
}
|
||
try {
|
||
const res = await fetch(
|
||
`/api/extensions/ext/invoice-inbox/items/${itemId}/fields`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
}
|
||
)
|
||
const json = await res.json()
|
||
if (!res.ok) {
|
||
// 409 means the item is already linked to a supplier invoice and
|
||
// the server has rejected the edit. Surface the specific Swedish
|
||
// message ("Posten är redan kopplad…") instead of the generic
|
||
// fallback so the user understands why the field locked.
|
||
const isConflict = res.status === 409
|
||
toast({
|
||
variant: 'destructive',
|
||
title: isConflict ? 'Posten är låst' : 'Kunde inte spara',
|
||
description: json.error ?? 'Försök igen',
|
||
})
|
||
setDrafts((prev) => ({ ...prev, [key]: readField(data, key) }))
|
||
return
|
||
}
|
||
if (json.data?.extracted_data) {
|
||
onUpdated(json.data.extracted_data as InvoiceExtractionResult)
|
||
}
|
||
} catch (err) {
|
||
toast({
|
||
variant: 'destructive',
|
||
title: 'Nätverksfel',
|
||
description: err instanceof Error ? err.message : 'Kunde inte spara',
|
||
})
|
||
setDrafts((prev) => ({ ...prev, [key]: readField(data, key) }))
|
||
}
|
||
},
|
||
[itemId, currency, data, onUpdated, toast]
|
||
)
|
||
|
||
const onChange = useCallback(
|
||
(key: FieldKey, raw: string) => {
|
||
setDrafts((prev) => ({ ...prev, [key]: raw }))
|
||
const existing = timersRef.current[key]
|
||
if (existing) clearTimeout(existing)
|
||
timersRef.current[key] = setTimeout(() => {
|
||
timersRef.current[key] = undefined
|
||
if (raw === readField(data, key)) return
|
||
void persist(key, raw)
|
||
}, 800)
|
||
},
|
||
[data, persist]
|
||
)
|
||
|
||
const onBlur = useCallback(
|
||
(key: FieldKey) => {
|
||
const pending = timersRef.current[key]
|
||
if (pending) {
|
||
clearTimeout(pending)
|
||
timersRef.current[key] = undefined
|
||
const raw = drafts[key]
|
||
if (raw !== readField(data, key)) void persist(key, raw)
|
||
}
|
||
},
|
||
[data, drafts, persist]
|
||
)
|
||
|
||
const vatRows = useMemo(() => data.vatBreakdown ?? [], [data.vatBreakdown])
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{FIELD_DEFS.map((f) => (
|
||
<div key={f.key} className="flex flex-col gap-0.5">
|
||
<label
|
||
htmlFor={`field-${f.key}`}
|
||
className="text-[10px] uppercase tracking-wide text-muted-foreground/80"
|
||
>
|
||
{f.label}
|
||
</label>
|
||
<Input
|
||
id={`field-${f.key}`}
|
||
type={f.type}
|
||
inputMode={f.inputMode}
|
||
value={drafts[f.key]}
|
||
onChange={(e) => onChange(f.key, e.target.value)}
|
||
onBlur={() => onBlur(f.key)}
|
||
disabled={disabled}
|
||
placeholder="—"
|
||
className={cn(
|
||
'h-8 text-sm border-transparent bg-transparent px-2 -mx-2 hover:border-border focus-visible:border-ring',
|
||
drafts[f.key] === '' && 'text-muted-foreground/50 italic'
|
||
)}
|
||
/>
|
||
</div>
|
||
))}
|
||
{vatRows.length > 0 && (
|
||
<div className="pt-2 border-t mt-3">
|
||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground/80 mb-1.5">
|
||
Momsfördelning
|
||
</p>
|
||
<div className="space-y-1">
|
||
{vatRows.map((row, i) => (
|
||
<div key={i} className="text-xs flex justify-between">
|
||
<span className="text-muted-foreground">{row.rate}%</span>
|
||
<span className="tabular-nums">
|
||
{formatCurrency(row.base, currency)} +{' '}
|
||
{formatCurrency(row.amount, currency)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{disabled && (
|
||
<p className="text-[10px] text-muted-foreground/70 pt-2">
|
||
Posten är kopplad till en leverantörsfaktura — fälten kan inte ändras.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|