'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 (
)
}
// ── Main component ───────────────────────────────────────────
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
const router = useRouter()
const fileInputRef = useRef(null)
const [items, setItems] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [selectedId, setSelectedId] = useState(null)
// Phone-only master-detail toggle. On screens ('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>(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(null)
const [docUrl, setDocUrl] = useState(null)
const [docMime, setDocMime] = useState(null)
const [inboxAddress, setInboxAddress] = useState(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) => {
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
return (
{ 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}
>
) : (
// PDF: iframe needs explicit height — frame fills the available pane.
)}
)
}
// ── 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 (
Så funkar dokumentinkorgen
Beta
Underlagen samlas här — från mail eller filuppladdning — och kan
matchas mot bankhändelser eller bokföras direkt.
Gratis under beta för Open-användare. Ingår senare i Pro-planen.{' '}
Se priser →
{onActivateInbox ? 'Aktivera din inkorgsadress' : 'Välj ett dokument från listan'}
{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.'}
{onActivateInbox && (
)}
)
}
// ── Fields rail ──────────────────────────────────────────────
function FieldsRail({
item,
onDelete,
onAttach,
isDeleting,
onFieldsUpdated,
onRetryRequested,
}: {
item: InboxItem
onDelete: () => void
onAttach: () => void
isDeleting: boolean
onFieldsUpdated: (data: InvoiceExtractionResult) => void
onRetryRequested: () => Promise
}) {
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 (