0ca9c25aba
* feat(bookkeeping): make blocked fiscal-year creation actionable When creating a new räkenskapsår is blocked because a prior period is still open, the "Skapa räkenskapsår" dialog no longer dead-ends on an English toast. The API now returns the canonical bilingual error envelope with the blocking periods (id/name/dates) under details, and the dialog renders a Swedish panel that locks them inline (reversible locked_at) via the existing /lock endpoint and retries creation. The guard rule is unchanged and remains BFL-compliant: BFL 6 kap allows löpande bokföring of the new year in parallel with the prior year's bokslut, so a lock (not a full close) is sufficient and reversible. - Add PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS structured error code - Return envelope + details.blockingPeriods from the 409 (was English string) - CreatePeriodDialog: inline "lås och skapa" panel + lock-and-retry - Update route tests for the new envelope shape Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): prevent mouse wheel from mutating number inputs A focused <input type="number"> would change its value on scroll, silently turning e.g. a 20000 salary into 19998. Blur number inputs on wheel so the page scrolls instead of editing the value. Applied at the Input primitive so all number fields are protected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): auto-derive skattetabell and kolumn for employees Replace the opaque manual "Skattetabell (29-42)" and "Kolumn (1-6)" inputs on the employee form with a self-deriving flow: the user picks their folkbokföringskommun from a searchable dropdown and the tax table fills itself in, while the column derives from the personnummer we already collect. - Add a searchable municipality picker (MunicipalityCombobox) backed by a new cached GET /api/salary/tax-tables/kommuner endpoint. - Wrap the whole "Skatt" card in a self-contained EmployeeTaxCard used by both the create and edit pages, with InfoTooltips and named column options. - deriveTaxColumn(): auto-select column 1 for under-66 employees; leave the ambiguous 66+ case (pension vs working senior) to a clearly-named manual choice. - Fix fetchKommunTaxRates() to page through all ~1300 församling rows instead of a single 500-row page (which silently dropped ~200 kommuner, incl. Göteborg) and normalize the uppercase names to title case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): correct CSV amount-column guess and surface skipped rows Manual CSV column-mapping auto-guess walked each data row right-to-left and picked the first numeric cell as the amount, so on the common ...;Belopp;Saldo layout it grabbed the trailing running-balance column. Extract the guess into a pure, tested suggestColumnMapping(): match header labels first (belopp/amount -> amount, saldo/balance -> balance), auto-fill the balance field, and fall back to value heuristics that skip the balance column and prefer a column carrying negative values. Also surface stats.skipped_rows + parse warnings in BankFileConfirmStep - the manual-mapping path skips the preview step that was the only place they showed, so skipped rows were silently dropped from view. Add a unit test reproducing the Saldo-as-amount regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: add "Save as draft" functionality for invoices - Implemented a new feature to allow users to save invoices as unnumbered drafts without generating an invoice number until finalized. - Added a `save_as_draft` flag to the CreateInvoiceInput schema to handle draft saving logic. - Updated the invoice creation API to skip number allocation when saving as a draft. - Introduced a new endpoint for finalizing drafts, which allocates an invoice number and emits an `invoice.created` event. - Enhanced the UI to include a "Save as draft" button, with loading states and tooltips. - Updated tests to cover the new draft saving and finalization logic, including race conditions for concurrent modifications. - Added relevant error handling for draft finalization and deletion scenarios. * feat(employee): add employment start and end date fields to employee forms * feat: enhance invoice and salary run handling with improved validation and event logging --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1986 lines
75 KiB
TypeScript
1986 lines
75 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 {
|
||
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 { cn, formatCurrency } from '@/lib/utils'
|
||
import { createClient } from '@/lib/supabase/client'
|
||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||
import type { InvoiceExtractionResult } from '@/types'
|
||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
|
||
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
||
|
||
type AccountingMethod = 'accrual' | 'cash'
|
||
|
||
// ── 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
|
||
created_journal_entry_id: string | null
|
||
error_message: string | null
|
||
// True when AI extraction was skipped — either because the upload caller
|
||
// passed skip_extraction=true (MCP/agent path) or because the server's
|
||
// page-count gate skipped a PDF above the auto-extract limit (issue #553).
|
||
// Distinct from status='error' (extraction failed) and from extracted_data
|
||
// having empty fields (extraction ran but found nothing).
|
||
extraction_skipped: boolean
|
||
// 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
|
||
}
|
||
|
||
// Lifecycle stage of an inbox item. Single source of truth shared by the list
|
||
// filter, the count pills, and the row icons so they never drift apart.
|
||
//
|
||
// Precedence mirrors the FieldsRail: a booked item (supplier invoice OR a
|
||
// direct journal entry) is done and drops out of the active inbox. A
|
||
// matched-but-unbooked item is "linked" — it STAYS in the inbox as its own
|
||
// category because the bank payment still needs booking (a document attached
|
||
// to a transaction is not the same as a booked one). An extraction failure is
|
||
// "error"; everything else needs a first action.
|
||
type InboxStatus = 'needs_action' | 'linked' | 'booked' | 'error'
|
||
|
||
function deriveInboxStatus(item: InboxItem): InboxStatus {
|
||
if (item.created_supplier_invoice_id || item.created_journal_entry_id) return 'booked'
|
||
if (item.matched_transaction_id) return 'linked'
|
||
if (item.status === 'error') return 'error'
|
||
return 'needs_action'
|
||
}
|
||
|
||
// ── Skeleton ─────────────────────────────────────────────────
|
||
// Mirrors the live layout (top bar + 3-pane card) so the transition from
|
||
// the route-level loading.tsx to data-loaded content has no visible reflow.
|
||
// Keep in sync with app/(dashboard)/e/[sector]/[slug]/loading.tsx.
|
||
|
||
function WorkspaceSkeleton() {
|
||
return (
|
||
<div className="h-[calc(100vh-1px)] p-4 md:p-6">
|
||
<div className="h-full flex flex-col rounded-lg border bg-card overflow-hidden">
|
||
<header className="flex items-center justify-between gap-4 border-b px-4 py-2.5">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Skeleton className="h-4 w-4 shrink-0" />
|
||
<Skeleton className="h-4 w-32 shrink-0" />
|
||
<Skeleton className="hidden md:block h-3 w-56" />
|
||
</div>
|
||
<Skeleton className="h-8 w-28 shrink-0" />
|
||
</header>
|
||
<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">
|
||
<aside className="border-r overflow-hidden bg-muted/20 pt-3">
|
||
<div className="px-3 pb-3 space-y-2 border-b">
|
||
<Skeleton className="h-8 w-full" />
|
||
<div className="flex flex-wrap gap-1">
|
||
<Skeleton className="h-5 w-10 rounded-full" />
|
||
<Skeleton className="h-5 w-24 rounded-full" />
|
||
<Skeleton className="h-5 w-20 rounded-full" />
|
||
<Skeleton className="h-5 w-8 rounded-full" />
|
||
</div>
|
||
</div>
|
||
<ul>
|
||
{Array.from({ length: 7 }).map((_, i) => (
|
||
<li key={i} className="border-b px-3 py-2 flex flex-col gap-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<Skeleton className="h-3 w-3 shrink-0" />
|
||
<Skeleton className="h-3.5 flex-1 max-w-[180px]" />
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<Skeleton className="h-3 w-16" />
|
||
<Skeleton className="h-3 w-12" />
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</aside>
|
||
<main className="overflow-hidden bg-muted/10 hidden md:block" />
|
||
<aside className="border-l overflow-hidden hidden md:block" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main component ───────────────────────────────────────────
|
||
|
||
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||
const { toast } = useToast()
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||
const { openAgentSheet, identity } = useAgentSheet()
|
||
|
||
const [items, setItems] = useState<InboxItem[]>([])
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
// List filter + search (client-side over the already-fetched items list).
|
||
// Defaults to 'todo' — the active inbox (everything not yet booked) — so
|
||
// booked underlag drop out of the default view while attached-but-unbooked
|
||
// ones stay visible.
|
||
const [filter, setFilter] = useState<'todo' | 'linked' | 'booked' | 'error' | 'all'>('todo')
|
||
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 [bookDirectOpen, setBookDirectOpen] = useState(false)
|
||
// Match-to-bank-transaction picker (opens when user clicks "Matcha mot
|
||
// transaktion" on an unmatched inbox item).
|
||
const [matchPickerOpen, setMatchPickerOpen] = useState(false)
|
||
// Cash method users see "Bokför direkt" as the primary CTA; accrual users
|
||
// see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read
|
||
// the company settings so we don't flicker the CTA order on first paint.
|
||
const [accountingMethod, setAccountingMethod] = useState<AccountingMethod>('accrual')
|
||
|
||
// ── Data loading ───────────────────────────────────────────
|
||
|
||
const fetchItems = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=500')
|
||
const json = await res.json()
|
||
if (res.ok) {
|
||
const serverItems: InboxItem[] = json.data?.items ?? []
|
||
// Preserve optimistic upload placeholders that haven't resolved to a
|
||
// server row yet. A refetch can now fire mid-upload (a realtime event
|
||
// from an unrelated booking), and a wholesale replace would briefly
|
||
// drop the in-flight placeholder. Placeholders carry a `temp-` id that
|
||
// never collides with a real row, and uploadFile() removes its own
|
||
// placeholder before its fetchItems(), so this never duplicates.
|
||
setItems((prev) => {
|
||
const pending = prev.filter((it) => it.isPlaceholder)
|
||
return pending.length > 0 ? [...pending, ...serverItems] : serverItems
|
||
})
|
||
}
|
||
} 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()
|
||
// Resolve the company's bookkeeping method — drives CTA hierarchy.
|
||
fetch('/api/settings')
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((body) => {
|
||
const method = body?.data?.accounting_method
|
||
if (method === 'cash' || method === 'accrual') {
|
||
setAccountingMethod(method)
|
||
}
|
||
})
|
||
.catch(() => { /* keep 'accrual' default */ })
|
||
}, [fetchItems, fetchInboxAddress])
|
||
|
||
// Realtime: refetch when any invoice_inbox_items row changes for this
|
||
// company. The inbox is routinely resolved "out of band" — the in-app agent
|
||
// sheet commits a staged create_supplier_invoice_from_inbox / book-direct
|
||
// operation, the /pending page approves one, or another tab books it — and
|
||
// none of those paths call this component's fetchItems(). Without this, a
|
||
// booked underlag stayed in "Att göra" until a manual reload (issue #600).
|
||
// RLS scopes the channel to the user's company, so we never receive other
|
||
// tenants' events; we refetch the whole list (rather than patch in place) so
|
||
// the derived status, count pills, and ordering stay authoritative. Mirrors
|
||
// the /pending page subscription (app/(dashboard)/pending/page.tsx).
|
||
useEffect(() => {
|
||
const supabase = createClient()
|
||
const channel = supabase
|
||
.channel('invoice_inbox_items:list')
|
||
.on(
|
||
'postgres_changes',
|
||
{ event: '*', schema: 'public', table: 'invoice_inbox_items' },
|
||
() => {
|
||
fetchItems()
|
||
}
|
||
)
|
||
.subscribe()
|
||
return () => {
|
||
void supabase.removeChannel(channel)
|
||
}
|
||
}, [fetchItems])
|
||
|
||
// 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 ||
|
||
!!it.created_journal_entry_id
|
||
)
|
||
const showOnboarding =
|
||
!onboardingDismissed && !(hasInboxAddress && hasAnyItem && hasResolvedItem)
|
||
|
||
// ── List filter + search (client-side over the fetched list) ─
|
||
|
||
// Per-status counts for the filter pills. Computed once over the full list.
|
||
const statusCounts = useMemo(() => {
|
||
const counts = { todo: 0, linked: 0, booked: 0, error: 0, all: items.length }
|
||
for (const item of items) {
|
||
const status = deriveInboxStatus(item)
|
||
if (status !== 'booked') counts.todo += 1
|
||
if (status === 'linked') counts.linked += 1
|
||
if (status === 'booked') counts.booked += 1
|
||
if (status === 'error') counts.error += 1
|
||
}
|
||
return counts
|
||
}, [items])
|
||
|
||
// Pills, in order. The error pill only appears when there's something errored
|
||
// (or it's the active filter) — keeps the happy-path inbox uncluttered.
|
||
const pills = useMemo(() => {
|
||
const list: { key: typeof filter; label: string; count: number }[] = [
|
||
{ key: 'todo', label: 'Att göra', count: statusCounts.todo },
|
||
{ key: 'linked', label: 'Kopplade', count: statusCounts.linked },
|
||
{ key: 'booked', label: 'Bokförda', count: statusCounts.booked },
|
||
]
|
||
if (statusCounts.error > 0 || filter === 'error') {
|
||
list.push({ key: 'error', label: 'Fel', count: statusCounts.error })
|
||
}
|
||
list.push({ key: 'all', label: 'Alla', count: statusCounts.all })
|
||
return list
|
||
}, [statusCounts, filter])
|
||
|
||
const filteredItems = useMemo(() => {
|
||
const term = searchTerm.trim().toLowerCase()
|
||
return items.filter((item) => {
|
||
// Status filter. "todo" is the active inbox — everything except booked.
|
||
const status = deriveInboxStatus(item)
|
||
if (filter === 'todo' && status === 'booked') return false
|
||
if (filter === 'linked' && status !== 'linked') return false
|
||
if (filter === 'booked' && status !== 'booked') return false
|
||
if (filter === 'error' && status !== 'error') return false
|
||
// 'all' → no status narrowing
|
||
|
||
// 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)
|
||
// Intentionally no auto-scroll: in the vertical-stack layout (below xl)
|
||
// scrolling the preview into view pushes the list off-screen, and the
|
||
// user has no obvious way back to pick another item. The row-highlight
|
||
// + the preview content update are enough feedback that the tap took.
|
||
|
||
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,
|
||
created_journal_entry_id: null,
|
||
error_message: null,
|
||
extraction_skipped: false,
|
||
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')
|
||
if (json.data?.extraction_skipped) {
|
||
const pages = json.data?.page_count
|
||
toast({
|
||
title: 'Dokument uppladdat',
|
||
description: pages
|
||
? `Stort dokument (${pages} sidor) — AI-tolkning skippad. Du kan koppla det till en transaktion eller skapa leverantörsfaktura manuellt.`
|
||
: 'AI-tolkning skippad. Du kan koppla dokumentet till en transaktion eller skapa leverantörsfaktura manuellt.',
|
||
})
|
||
} else {
|
||
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 && !it.created_journal_entry_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="min-h-[calc(100vh-1px)] xl: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="xl:h-full flex flex-col rounded-lg border bg-card xl: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-section body. Below xl (iPad portrait/landscape + phone) the
|
||
sections stack vertically as a single scrollable feed. With the app
|
||
sidebar eating ~256px, even iPad landscape (1024–1180px viewport)
|
||
has only ~570px of workspace — too tight for 3 panes. At xl+ they
|
||
sit side-by-side as three panes. */}
|
||
<div className="xl:flex-1 grid grid-cols-1 xl:grid-cols-[280px_minmax(0,1fr)_340px] xl:min-h-0 xl:overflow-hidden">
|
||
{/* List — flows naturally below xl; bounded with internal scroll at xl+ */}
|
||
<aside className="border-b xl:border-b-0 xl:border-r bg-muted/20 pt-3 xl:overflow-y-auto xl:block">
|
||
{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">
|
||
{pills.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}
|
||
{pill.count > 0 && (
|
||
<span
|
||
className={cn(
|
||
'ml-1 tabular-nums',
|
||
filter === pill.key ? 'opacity-80' : 'opacity-50'
|
||
)}
|
||
>
|
||
{pill.count}
|
||
</span>
|
||
)}
|
||
</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 ? (
|
||
// On desktop the preview pane is always visible alongside this
|
||
// column, so showing the onboarding card here would duplicate it.
|
||
// On mobile the layout is a master-detail toggle and the user is
|
||
// stuck on the list view until they pick a row — without a card
|
||
// here they'd have no way to reach the explainer at all. So:
|
||
// compact card on mobile only, quiet empty state on desktop.
|
||
showOnboarding ? (
|
||
<>
|
||
<div className="xl:hidden">
|
||
<OnboardingCard
|
||
hasInboxAddress={hasInboxAddress}
|
||
hasAnyItem={hasAnyItem}
|
||
hasResolvedItem={hasResolvedItem}
|
||
onActivateInbox={handleRotateAddress}
|
||
onUploadClick={() => fileInputRef.current?.click()}
|
||
onDismiss={handleDismissOnboarding}
|
||
isActivating={isRotating}
|
||
compact
|
||
/>
|
||
</div>
|
||
<div className="hidden xl:block 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>
|
||
</>
|
||
) : (
|
||
<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">
|
||
{filter === 'todo'
|
||
? 'Inget att åtgärda — allt är bearbetat.'
|
||
: '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="xl:overflow-hidden bg-muted/10 relative xl:block min-h-[55vh] xl:min-h-0"
|
||
>
|
||
{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. Below xl it stacks below the preview as part of the
|
||
single vertical feed (top border for separation). At xl+ it's the
|
||
third pane with a left border. */}
|
||
<aside
|
||
className="border-t xl:border-t-0 xl:border-l xl:overflow-y-auto pt-4 xl:block pb-4"
|
||
>
|
||
{selected ? (
|
||
<FieldsRail
|
||
item={selected}
|
||
accountingMethod={accountingMethod}
|
||
onDelete={() => handleDelete(selected.id)}
|
||
onBookDirect={() => setBookDirectOpen(true)}
|
||
onMatchTransaction={() => setMatchPickerOpen(true)}
|
||
onUnmatchTransaction={async () => {
|
||
const targetId = selected.id
|
||
const res = await fetch(
|
||
`/api/extensions/ext/invoice-inbox/items/${targetId}/unmatch-transaction`,
|
||
{ method: 'POST' },
|
||
)
|
||
if (!res.ok) {
|
||
const json = await res.json().catch(() => ({}))
|
||
toast({
|
||
title: 'Kunde inte avbryta matchningen',
|
||
description: json.error ?? `HTTP ${res.status}`,
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
await Promise.all([fetchItems(), handleSelect(targetId)])
|
||
}}
|
||
onAskAssistant={
|
||
identity.isVerified
|
||
? (transactionId) => {
|
||
openAgentSheet({
|
||
intentId: 'transaction.categorization',
|
||
intentArgs: { transaction_id: transactionId },
|
||
contextRef: `transaction:${transactionId}`,
|
||
})
|
||
}
|
||
: undefined
|
||
}
|
||
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 && (
|
||
<BookDirectlyDialog
|
||
open={bookDirectOpen}
|
||
onOpenChange={setBookDirectOpen}
|
||
item={selected}
|
||
onSuccess={async () => {
|
||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||
}}
|
||
/>
|
||
)}
|
||
{selected && (
|
||
<TransactionMatchPicker
|
||
open={matchPickerOpen}
|
||
onClose={() => setMatchPickerOpen(false)}
|
||
inboxItemId={selected.id}
|
||
extractedData={selected.extracted_data}
|
||
onMatched={async () => {
|
||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
// ── 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 isPlaceholder = !!item.isPlaceholder
|
||
const status = deriveInboxStatus(item)
|
||
const isErrored = status === 'error'
|
||
const isBooked = status === 'booked'
|
||
const isLinkedToTransaction = status === 'linked'
|
||
|
||
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" aria-label="Fel vid bearbetning" />
|
||
)}
|
||
{isLinkedToTransaction && (
|
||
<Link2 className="h-3 w-3 text-emerald-600 shrink-0" aria-label="Kopplad till transaktion" />
|
||
)}
|
||
{isBooked && (
|
||
<Check className="h-3 w-3 text-emerald-600 shrink-0" aria-label="Bokförd" />
|
||
)}
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||
{isPlaceholder ? (
|
||
<span className="italic">Tolkar dokument med AI…</span>
|
||
) : item.extraction_skipped ? (
|
||
<span className="flex items-center gap-1.5 min-w-0">
|
||
<Badge variant="outline" className="font-normal">Inte AI-tolkad</Badge>
|
||
<span className="truncate">{timeAgo(item.email_received_at ?? item.created_at)}</span>
|
||
</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)
|
||
|
||
export 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: 'accounted 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,
|
||
accountingMethod,
|
||
onDelete,
|
||
onBookDirect,
|
||
onMatchTransaction,
|
||
onUnmatchTransaction,
|
||
onAskAssistant,
|
||
isDeleting,
|
||
onFieldsUpdated,
|
||
onRetryRequested,
|
||
}: {
|
||
item: InboxItem
|
||
accountingMethod: AccountingMethod
|
||
onDelete: () => void
|
||
onBookDirect: () => void
|
||
onMatchTransaction: () => void
|
||
onUnmatchTransaction: () => Promise<void>
|
||
onAskAssistant?: (transactionId: string) => 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 isBookedDirectly = !isProcessed && !!item.created_journal_entry_id
|
||
// "Resolved" now means a journal entry exists — matched_transaction_id alone
|
||
// is not resolved, it's the prerequisite for booking against that tx.
|
||
const isLinkedToTransaction = !isProcessed && !isBookedDirectly && !!item.matched_transaction_id
|
||
const isResolved = isProcessed || isBookedDirectly
|
||
const [isUnmatchingTx, setIsUnmatchingTx] = useState(false)
|
||
const [isRetrying, setIsRetrying] = useState(false)
|
||
|
||
// 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 showNoMatchHint =
|
||
!isResolved &&
|
||
!item.matched_supplier_id &&
|
||
!!extractedSupplierName
|
||
|
||
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>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* Skipped-extraction hint: explains the empty fields and points the
|
||
user to the manual paths (transaction link or supplier invoice). */}
|
||
{item.extraction_skipped && !isResolved && (
|
||
<div className="border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
|
||
AI-tolkning skippades p.g.a. dokumentets storlek (fler än 3 sidor).
|
||
Du kan koppla dokumentet till en transaktion eller skapa
|
||
leverantörsfaktura manuellt.
|
||
</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>
|
||
) : isBookedDirectly && item.created_journal_entry_id ? (
|
||
<Link href={`/bookkeeping/${item.created_journal_entry_id}`} className="block">
|
||
<Button variant="default" size="sm" className="w-full">
|
||
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
||
Öppna verifikation
|
||
</Button>
|
||
</Link>
|
||
) : isLinkedToTransaction && item.matched_transaction_id ? (
|
||
<>
|
||
{/* Matched-to-tx state: show the bridge to booking. The user
|
||
picks one of two actions — book themselves with the
|
||
deterministic dialog, or hand off to the assistant. */}
|
||
<div className="rounded-md border border-success/30 bg-success/5 px-3 py-2 text-xs">
|
||
<div className="flex items-center gap-1.5 text-success font-medium mb-1">
|
||
<Link2 className="h-3 w-3" />
|
||
Matchad mot transaktion
|
||
</div>
|
||
<Link
|
||
href={`/transactions?highlight=${item.matched_transaction_id}`}
|
||
className="text-muted-foreground hover:text-foreground hover:underline"
|
||
>
|
||
Öppna transaktionen →
|
||
</Link>
|
||
</div>
|
||
{onAskAssistant && (
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={() => onAskAssistant(item.matched_transaction_id!)}
|
||
>
|
||
Fråga assistenten
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={onBookDirect}
|
||
>
|
||
Bokför manuellt
|
||
</Button>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
setIsUnmatchingTx(true)
|
||
try {
|
||
await onUnmatchTransaction()
|
||
} finally {
|
||
setIsUnmatchingTx(false)
|
||
}
|
||
}}
|
||
disabled={isUnmatchingTx}
|
||
className="w-full text-xs text-muted-foreground hover:text-foreground hover:underline pt-1"
|
||
>
|
||
{isUnmatchingTx ? 'Avbryter…' : 'Avbryt matchning'}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* Unmatched state: the canonical next step is to find the bank
|
||
transaction this underlag belongs to. Two escape hatches sit
|
||
below it — "Skapa leverantörsfaktura" for users who want
|
||
supplier-invoice tracking (accrual flow), and "Bokför som
|
||
verifikat" for underlag that aren't a supplier invoice at all
|
||
(bank fees, owner expenses, the underlag for a correction). The
|
||
latter opens the same BookDirectlyDialog as the matched state,
|
||
which works without a bank transaction and lets the user attach
|
||
one if they want. Per BFL 5 kap 6-7 § the underlag must be
|
||
bookable as a verifikat, not forced into a supplier invoice. */}
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={onMatchTransaction}
|
||
>
|
||
Matcha mot 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="outline"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={onBookDirect}
|
||
>
|
||
Bokför som verifikat
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={onDelete}
|
||
disabled={isDeleting || isResolved}
|
||
title={
|
||
isProcessed
|
||
? 'Kopplad till leverantörsfaktura — kan inte tas bort'
|
||
: isBookedDirectly
|
||
? 'Bokförd — 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>
|
||
)}
|
||
{isBookedDirectly && (
|
||
<Badge variant="secondary" className="w-full justify-center text-[10px]">
|
||
<Check className="h-2.5 w-2.5 mr-1" />
|
||
Bokförd
|
||
</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 ────────────────────────────────────
|
||
|
||
export 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: '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' },
|
||
]
|
||
|
||
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 } }
|
||
}
|
||
|
||
export 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>
|
||
)
|
||
}
|