Bug/open banking flow (#854)

* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-01 18:13:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2da9c71eb3
commit f63d3e3100
83 changed files with 6769 additions and 1360 deletions
+230
View File
@@ -0,0 +1,230 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Search, X, Loader2, MessageSquare, Pencil } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import {
type ConversationRow,
BUCKET_LABELS,
relativeTime,
intentLabel,
groupConversations,
} from './conversation-display'
interface Props {
// Highlight the row for the conversation currently open in the sheet.
activeConversationId?: string | null
// Fired when the user picks a conversation to resume. The sheet fetches its
// messages and swaps back to the chat view — the list itself stays dumb.
onSelect: (id: string) => void
}
// In-sheet conversation picker. Renders the same grouped/searchable list as the
// /chat sidebar (shared helpers in conversation-display.ts), but instead of
// navigating to /chat/[id] it hands the id back so the conversation opens
// inline in the sheet and the user keeps chatting without leaving the page.
// Rows are renameable inline (PATCH /api/agent/conversations/[id]).
export default function AgentSessionList({ activeConversationId, onSelect }: Props) {
const [conversations, setConversations] = useState<ConversationRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
// Set by Esc so the blur that fires when the input unmounts doesn't save.
const cancelRef = useRef(false)
const { toast } = useToast()
useEffect(() => {
let cancelled = false
void (async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/agent/conversations?limit=100')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = (await res.json()) as { data?: ConversationRow[] }
if (!cancelled) setConversations(json.data ?? [])
} catch {
if (!cancelled) setError('Kunde inte hämta konversationer.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return conversations
return conversations.filter(
(c) =>
(c.title ?? '').toLowerCase().includes(q) ||
(c.last_message_preview ?? '').toLowerCase().includes(q) ||
(c.context_ref ?? '').toLowerCase().includes(q) ||
c.intent_id.toLowerCase().includes(q),
)
}, [conversations, query])
const grouped = useMemo(() => groupConversations(filtered), [filtered])
function startEdit(c: ConversationRow) {
setEditingId(c.id)
setEditValue(c.title ?? '')
cancelRef.current = false
}
function cancelEdit() {
cancelRef.current = true
setEditingId(null)
}
async function commitEdit(id: string) {
if (cancelRef.current) {
cancelRef.current = false
return
}
setEditingId(null)
const title = editValue.trim()
const current = conversations.find((c) => c.id === id)
if (!title || title === current?.title) return
// Capture the pre-rename title so we can roll back if the PATCH fails.
const previousTitle = current?.title ?? null
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)))
try {
const res = await fetch(`/api/agent/conversations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch {
// Revert the optimistic rename so the list stays in sync with the server.
setConversations((prev) =>
prev.map((c) => (c.id === id ? { ...c, title: previousTitle } : c)),
)
toast({
variant: 'destructive',
title: 'Kunde inte byta namn på konversationen.',
})
}
}
return (
<div className="flex flex-1 flex-col min-h-0">
<div className="border-b border-border px-5 py-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Sök konversationer…"
className="w-full rounded-md border border-border bg-background pl-8 pr-7 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => setQuery('')}
aria-label="Rensa sökning"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Hämtar…
</div>
) : error ? (
<div className="p-6 text-sm text-destructive">{error}</div>
) : grouped.length === 0 ? (
<div className="flex flex-col items-center gap-2 p-10 text-center text-sm text-muted-foreground">
<MessageSquare className="h-6 w-6 opacity-40" />
{conversations.length === 0 ? 'Inga konversationer ännu.' : 'Inga träffar.'}
</div>
) : (
grouped.map(({ bucket, rows }) => (
<section key={bucket} className="py-2">
<p className="px-4 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
{BUCKET_LABELS[bucket]}
</p>
<ul className="space-y-1">
{rows.map((c) => (
<li key={c.id}>
{editingId === c.id ? (
<div className="px-4 py-2">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => void commitEdit(c.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
;(e.target as HTMLInputElement).blur()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
}}
placeholder="Namnge konversationen…"
maxLength={200}
aria-label="Nytt namn på konversationen"
className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
) : (
<div
className={cn(
'group flex items-stretch border-l-2 transition-colors',
activeConversationId === c.id
? 'bg-secondary/50 border-foreground'
: 'border-transparent hover:bg-secondary/60',
)}
>
<button
type="button"
onClick={() => onSelect(c.id)}
className="flex flex-1 min-w-0 items-start gap-2 px-4 py-2 text-left"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate flex-1 min-w-0">
{c.title ?? intentLabel(c.intent_id)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{relativeTime(c.last_message_at ?? c.created_at)}
</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{c.last_message_preview ?? intentLabel(c.intent_id)}
</p>
</div>
</button>
<button
type="button"
onClick={() => startEdit(c)}
title="Byt namn"
aria-label="Byt namn på konversation"
className="shrink-0 flex w-10 items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</li>
))}
</ul>
</section>
))
)}
</div>
</div>
)
}
+208 -26
View File
@@ -1,13 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { X, Expand } from 'lucide-react'
import Link from 'next/link'
import AgentChat from './AgentChat'
import { X, Expand, Shrink, PanelRightClose, Eraser, History, ChevronLeft, Loader2 } from 'lucide-react'
import AgentChat, { normalizeStoredMessages, type ChatMessage } from './AgentChat'
import AgentAvatar from './AgentAvatar'
import AgentSessionList from './AgentSessionList'
import SandboxAgentPreview from './SandboxAgentPreview'
import { useAgentSheet } from './AgentSheetProvider'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { cn } from '@/lib/utils'
// Undimmed non-modal side sheet — sits above the page on a hairline border +
// shadow, but the page underneath stays fully interactive. Plan §3b.
@@ -21,72 +22,251 @@ interface Props {
intentArgs?: Record<string, unknown>
contextRef?: string
seedUserMessage?: string
// Hidden (display:none) but still mounted so the conversation survives. The
// provider keeps rendering this component; we just visually remove it.
collapsed: boolean
onCollapse: () => void
onRestart: () => void
onClose: () => void
}
interface LoadedConversation {
id: string
intentId: string
contextRef: string | null
title: string | null
messages: ChatMessage[]
}
export default function AgentSheet({
intentId,
intentArgs,
contextRef,
seedUserMessage,
collapsed,
onCollapse,
onRestart,
onClose,
}: Props) {
// Live conversation id from the active AgentChat (fresh sessions report it via
// onConversationIdChange; resumed ones we set directly on select).
const [conversationId, setConversationId] = useState<string | null>(null)
// 'chat' shows the conversation; 'list' shows the session picker.
const [view, setView] = useState<'chat' | 'list'>('chat')
// A past conversation the user picked from the list, hydrated for resume. When
// set, it replaces the intent-driven fresh chat.
const [loaded, setLoaded] = useState<LoadedConversation | null>(null)
const [loadingConversation, setLoadingConversation] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
// Enlarge the panel IN PLACE (no navigation) — the user stays on the current
// page (e.g. /bookkeeping) with a wider reading/verifying surface.
const [expanded, setExpanded] = useState(false)
const { identity } = useAgentSheet()
const companyCtx = useCompanyOptional()
const isSandbox = companyCtx?.isSandbox ?? false
const agentName = identity.displayName?.trim() || null
const sheetTitle = intentToTitle(intentId, agentName)
const displayTitle = loaded ? (loaded.title ?? intentToTitle(loaded.intentId, agentName)) : sheetTitle
const activeConversationId = loaded?.id ?? conversationId
// Esc closes the sheet.
// Esc: back out of the session list first, otherwise close. Never while
// collapsed (the sheet is hidden off-screen, so Esc belongs elsewhere).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (collapsed || e.key !== 'Escape') return
if (view === 'list') setView('chat')
else onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
}, [onClose, collapsed, view])
// Move focus off the sheet before hiding it, so it never sits on a
// display:none node (accessibility).
const handleCollapse = () => {
if (typeof document !== 'undefined') {
;(document.activeElement as HTMLElement | null)?.blur()
}
onCollapse()
}
// Resume a past conversation inline: fetch its messages, hydrate, and swap the
// sheet back to the chat view. Picking the one already open just closes the
// list (keeps its live in-memory state instead of re-hydrating it).
async function handleSelectConversation(id: string) {
if (id === activeConversationId) {
setView('chat')
return
}
setView('chat')
setLoaded(null)
setLoadingConversation(true)
setLoadError(null)
try {
const res = await fetch(`/api/agent/conversations/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = (await res.json()) as {
data?: {
conversation: {
id: string
intent_id: string
context_ref: string | null
title: string | null
}
messages: { role: string; content: unknown; hidden?: boolean | null }[]
}
}
const data = json.data
if (!data) throw new Error('missing data')
setLoaded({
id: data.conversation.id,
intentId: data.conversation.intent_id,
contextRef: data.conversation.context_ref,
title: data.conversation.title,
messages: normalizeStoredMessages(data.messages),
})
setConversationId(data.conversation.id)
} catch {
setLoadError('Kunde inte öppna konversationen.')
} finally {
setLoadingConversation(false)
}
}
return (
<div
role="dialog"
aria-label={sheetTitle}
aria-label={displayTitle}
// z-[60] sits above the mobile bottom nav (z-50) so on phones the sheet
// covers the full screen including where the nav would otherwise show.
className="fixed inset-y-0 right-0 z-[60] flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-lg"
// `hidden` (display:none) when collapsed keeps the component mounted — the
// conversation state in AgentChat survives — while removing it from view
// and layout entirely (no stray horizontal scroll from an off-screen box).
className={cn(
'fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg transition-[max-width] duration-200 ease-out',
collapsed && 'hidden',
// Expanded grows the panel leftward over the page (still non-modal — the
// page stays interactive); normal is the compact side sheet.
expanded ? 'max-w-[min(100vw,1100px)]' : 'max-w-[480px]',
)}
style={{
// iOS notch / Android cutout — the sheet top edge needs to clear the
// status bar. Bottom is handled inside the form below.
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
>
<header className="flex items-center gap-3 border-b border-border px-5 py-4">
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
<h2 className="font-display text-lg tracking-tight truncate">{sheetTitle}</h2>
<div className="ml-auto flex items-center gap-1">
{conversationId && !isSandbox && (
<Link
href={`/chat/${conversationId}`}
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Öppna i fullskärm"
title="Öppna i fullskärm"
>
<Expand className="h-4 w-4" />
</Link>
)}
{view === 'list' ? (
<header className="flex items-center gap-3 border-b border-border px-5 py-4">
<button
onClick={() => setView('chat')}
className="h-9 w-9 -ml-1 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Tillbaka"
title="Tillbaka"
>
<ChevronLeft className="h-4 w-4" />
</button>
<h2 className="font-display text-lg tracking-tight truncate">Konversationer</h2>
<button
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
className="ml-auto h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Stäng"
title="Avsluta sessionen"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
</header>
) : (
<header className="flex items-center gap-2 border-b border-border px-4 py-4">
{!isSandbox && (
<button
onClick={() => setView('list')}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Tidigare konversationer"
title="Tidigare konversationer"
>
<History className="h-4 w-4" />
</button>
)}
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
<h2 className="font-display text-lg tracking-tight truncate">{displayTitle}</h2>
<div className="ml-auto flex items-center gap-1">
{/* Grow/shrink the panel in place — NEVER navigates away, so the
user stays on the current page. Hidden on mobile where the sheet
is already full-width (the toggle would be a no-op). */}
{!isSandbox && (
<button
onClick={() => setExpanded((v) => !v)}
className="hidden md:inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label={expanded ? 'Förminska' : 'Förstora'}
title={expanded ? 'Förminska' : 'Förstora'}
>
{expanded ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</button>
)}
{/* Labeled (not icon-only) so it isn't mistaken for close/minimize —
and gated on an existing conversation so there's nothing to
mis-click on a fresh, empty chat. */}
{activeConversationId && !isSandbox && (
<button
onClick={onRestart}
className="h-9 inline-flex items-center gap-2 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Rensa — börja en ny konversation"
title="Rensa — börja en ny konversation"
>
<Eraser className="h-4 w-4" />
Rensa
</button>
)}
<button
onClick={handleCollapse}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Minimera"
title="Minimera — behåll sessionen"
>
<PanelRightClose className="h-4 w-4" />
</button>
<button
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Stäng"
title="Avsluta sessionen"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
)}
{isSandbox ? (
<SandboxAgentPreview agentName={agentName} />
) : view === 'list' ? (
<AgentSessionList
activeConversationId={activeConversationId}
onSelect={handleSelectConversation}
/>
) : loadingConversation ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Öppnar konversation…
</div>
) : loadError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
<p className="text-destructive">{loadError}</p>
<button
onClick={() => setView('list')}
className="text-xs font-medium text-foreground hover:underline"
>
Tillbaka till konversationer
</button>
</div>
) : loaded ? (
<AgentChat
key={loaded.id}
intentId={loaded.intentId}
contextRef={loaded.contextRef ?? undefined}
initialConversationId={loaded.id}
initialMessages={loaded.messages}
onConversationIdChange={(id) => setConversationId(id)}
/>
) : (
<AgentChat
intentId={intentId}
@@ -106,6 +286,8 @@ function intentToTitle(intentId: string, agentName: string | null): string {
return agentName ? `Fråga ${agentName}` : 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'verifikation.draft':
return 'Hjälp med verifikation'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
+50 -4
View File
@@ -38,7 +38,20 @@ export interface OpenAgentSheetArgs {
interface AgentSheetContextValue {
openAgentSheet: (args: OpenAgentSheetArgs) => void
closeAgentSheet: () => void
// Collapse hides the sheet WITHOUT unmounting it, so the in-memory
// conversation (messages, streaming, pending approval cards) survives — the
// floating trigger re-expands the same session. Distinct from close, which
// ends the session entirely.
collapseAgentSheet: () => void
expandAgentSheet: () => void
// Discard the current thread and start a fresh conversation on the same
// intent (the header "Ny konversation" control). Implemented by remounting
// the sheet via a nonce in its key.
restartAgentSheet: () => void
// True while a session exists (open or collapsed).
isOpen: boolean
// True while a session exists but is minimized off-screen.
collapsed: boolean
// Agent name + avatar — set once from the server-loaded agent_profile
// and exposed through context so the trigger / chat headers can render
// them without their own fetches. Null when the user hasn't verified a
@@ -55,26 +68,56 @@ interface AgentSheetProviderProps {
export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) {
const [activeArgs, setActiveArgs] = useState<OpenAgentSheetArgs | null>(null)
// Collapsed = session alive but hidden. Kept separate from activeArgs so
// collapsing never unmounts AgentChat (which would wipe the conversation).
const [collapsed, setCollapsed] = useState(false)
// Bumped by restartAgentSheet to force a fresh AgentChat mount (a new thread)
// on the same intent, without closing the sheet.
const [restartNonce, setRestartNonce] = useState(0)
const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => {
setActiveArgs(args)
setCollapsed(false)
}, [])
const closeAgentSheet = useCallback(() => {
setActiveArgs(null)
setCollapsed(false)
}, [])
const resolvedIdentity: AgentIdentity =
identity ?? { displayName: null, avatarId: null, isVerified: false }
const collapseAgentSheet = useCallback(() => setCollapsed(true), [])
const expandAgentSheet = useCallback(() => setCollapsed(false), [])
const restartAgentSheet = useCallback(() => {
setRestartNonce((n) => n + 1)
setCollapsed(false)
}, [])
const resolvedIdentity = useMemo<AgentIdentity>(
() => identity ?? { displayName: null, avatarId: null, isVerified: false },
[identity],
)
const value = useMemo<AgentSheetContextValue>(
() => ({
openAgentSheet,
closeAgentSheet,
collapseAgentSheet,
expandAgentSheet,
restartAgentSheet,
isOpen: activeArgs !== null,
collapsed,
identity: resolvedIdentity,
}),
[openAgentSheet, closeAgentSheet, activeArgs, resolvedIdentity],
[
openAgentSheet,
closeAgentSheet,
collapseAgentSheet,
expandAgentSheet,
restartAgentSheet,
activeArgs,
collapsed,
resolvedIdentity,
],
)
return (
@@ -82,11 +125,14 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
{children}
{activeArgs && (
<AgentSheet
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${activeArgs.seedUserMessage ?? ''}`}
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${activeArgs.seedUserMessage ?? ''}:${restartNonce}`}
intentId={activeArgs.intentId}
intentArgs={activeArgs.intentArgs}
contextRef={activeArgs.contextRef}
seedUserMessage={activeArgs.seedUserMessage}
collapsed={collapsed}
onCollapse={collapseAgentSheet}
onRestart={restartAgentSheet}
onClose={closeAgentSheet}
/>
)}
+45 -26
View File
@@ -27,21 +27,28 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
// "Fråga assistenten" in Dokumentinkorgen — both passing a transaction_id the
// pathname-only FAB can't know.)
export default function AgentTrigger() {
const { openAgentSheet, isOpen, identity } = useAgentSheet()
const { openAgentSheet, expandAgentSheet, isOpen, collapsed, identity } = useAgentSheet()
const pathname = usePathname()
const router = useRouter()
const hasAi = useCapability(CAPABILITY.ai)
if (isOpen) return null
// The /chat surface IS the chat — a floating "Fråga …" pill on top of it
// is redundant and overlaps the input. Suppress while the user is here.
if (pathname?.startsWith('/chat')) return null
// The verifikation editor is a dense regulatory surface (debits/credits,
// BAS codes, period locks) — a floating "Fråga … om denna verifikation"
// pill on top of it adds noise without earning its place. Suppress on
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
// and /bookkeeping/year-end still get the FAB.
{
// Sheet open AND visible → hide the FAB so the icon doesn't double up. When
// the session is merely collapsed we KEEP the FAB — it's the handle that
// brings the minimized conversation back.
if (isOpen && !collapsed) return null
// The page-suppression rules below apply only to a FRESH open. A collapsed
// session always gets its reopen handle, regardless of page — otherwise a
// conversation minimized on /chat or /bookkeeping/[id] could never be
// brought back.
if (!collapsed) {
// The /chat surface IS the chat — a floating "Fråga …" pill on top of it
// is redundant and overlaps the input. Suppress while the user is here.
if (pathname?.startsWith('/chat')) return null
// The verifikation editor is a dense regulatory surface (debits/credits,
// BAS codes, period locks) — a floating "Fråga … om denna verifikation"
// pill on top of it adds noise without earning its place. Suppress on
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
// and /bookkeeping/year-end still get the FAB.
const segs = pathname?.split('/').filter(Boolean) ?? []
if (segs[0] === 'bookkeeping' && segs[1] && segs[1] !== 'year-end' && segs[1] !== 'new') {
return null
@@ -49,7 +56,8 @@ export default function AgentTrigger() {
}
// Pre-onboarding: no agent_profile.verified_at yet. The FAB would lead
// into a generic chat with no specialization. Better to hide it until
// the user has finished /onboarding/agent.
// the user has finished /onboarding/agent. (A collapsed session implies the
// agent is already in use, so this only gates fresh opens in practice.)
if (!identity.isVerified) return null
const name = identity.displayName?.trim() || 'min assistent'
@@ -57,23 +65,34 @@ export default function AgentTrigger() {
// AI assistant runs on a paid cloud service. Without the capability, opening
// the sheet would land the user in a chat whose send is dead. Keep the FAB
// visible (it's the conversion surface) but route it to billing instead.
const labelText = !hasAi
? `Uppgradera för att använda ${name}`
: dispatch.labelSuffix
? `Fråga ${name} ${dispatch.labelSuffix}`
: `Fråga ${name}`
const labelText = collapsed
? `Fortsätt med ${name}`
: !hasAi
? `Uppgradera för att använda ${name}`
: dispatch.labelSuffix
? `Fråga ${name} ${dispatch.labelSuffix}`
: `Fråga ${name}`
const handleClick = () => {
// Collapsed → bring the existing session back, don't start a new one.
if (collapsed) {
expandAgentSheet()
return
}
if (!hasAi) {
router.push('/settings/billing')
return
}
openAgentSheet({
intentId: dispatch.intentId,
intentArgs: dispatch.intentArgs,
contextRef: dispatch.contextRef,
})
}
return (
<button
onClick={() =>
!hasAi
? router.push('/settings/billing')
: openAgentSheet({
intentId: dispatch.intentId,
intentArgs: dispatch.intentArgs,
contextRef: dispatch.contextRef,
})
}
onClick={handleClick}
// Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home
// indicator (env(safe-area-inset-bottom)). Desktop: standard 20px lift,
// no mobile nav to worry about.
+28
View File
@@ -72,6 +72,12 @@ interface CommitResultData {
invoice_id?: string | null
customer_id?: string | null
supplier_invoice_id?: string | null
// bulk_book_inbox_items creates N verifikationer, not one artifact — the
// executor returns per-item counts instead of a single id. Surfaced as a
// "N bokförda" summary + a link to the ledger (or the sole verifikat).
booked_count?: number
skipped_count?: number
booked?: Array<{ journal_entry_id?: string | null }>
}
export default function ApprovalCard({
@@ -234,6 +240,22 @@ export default function ApprovalCard({
label: 'Öppna kund',
}
}
// Bulk operations (bulk_book_inbox_items) book N underlag at once and return
// counts instead of a single id. Show the outcome ("N bokförda · M
// överhoppade") — a bulk commit silently skips non-bookable items, so
// without this the user can't tell whether anything was booked — and link to
// the ledger list, or straight to the sole verifikat when exactly one landed.
const bulkSummary =
typeof commitResult?.booked_count === 'number'
? { booked: commitResult.booked_count, skipped: commitResult.skipped_count ?? 0 }
: null
if (bulkSummary && !deepLink) {
const soleEntryId =
bulkSummary.booked === 1 ? commitResult?.booked?.[0]?.journal_entry_id : null
deepLink = soleEntryId
? { href: `/bookkeeping/${soleEntryId}`, label: 'Öppna verifikation' }
: { href: '/bookkeeping', label: 'Öppna bokföringen' }
}
// The server's `message` field (e.g. "Operation staged for review …
// Open the Accounted web app to approve or reject it.") was written for
// MCP clients without an inline approval surface. Inside the in-app
@@ -248,6 +270,12 @@ export default function ApprovalCard({
<p className="flex items-center gap-2 font-medium">
<Check className="h-4 w-4" /> Godkänt
</p>
{bulkSummary && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{bulkSummary.booked} {bulkSummary.booked === 1 ? 'underlag bokfört' : 'underlag bokförda'}
{bulkSummary.skipped > 0 ? ` · ${bulkSummary.skipped} överhoppade` : ''}
</p>
)}
{deepLink && (
<Link
href={deepLink.href}
+139 -154
View File
@@ -1,73 +1,24 @@
'use client'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { Pin, PinOff, Archive, Search, X, PanelLeftOpen, PanelLeftClose } from 'lucide-react'
import { Pin, PinOff, Archive, Pencil, Search, X, PanelLeftOpen, PanelLeftClose } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAgentSheet } from './AgentSheetProvider'
import AgentAvatar from './AgentAvatar'
interface ConversationRow {
id: string
intent_id: string
context_ref: string | null
title: string | null
pinned: boolean
archived: boolean
last_message_at: string | null
last_message_preview: string | null
created_at: string
}
import {
type ConversationRow,
BUCKET_LABELS,
relativeTime,
intentLabel,
groupConversations,
} from './conversation-display'
interface Props {
initialConversations: ConversationRow[]
}
// Time buckets for date grouping. Computed once per render against now().
// Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from
// Mail and iMessage.
type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older'
const BUCKET_LABELS: Record<DateBucket, string> = {
pinned: 'Fästade',
today: 'Idag',
yesterday: 'Igår',
thisWeek: 'Denna vecka',
older: 'Äldre',
}
function bucketFor(c: ConversationRow): DateBucket {
if (c.pinned) return 'pinned'
const when = c.last_message_at ?? c.created_at
if (!when) return 'older'
const t = new Date(when)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
if (t >= todayStart) return 'today'
if (t >= yesterdayStart) return 'yesterday'
if (t >= weekStart) return 'thisWeek'
return 'older'
}
// Compact relative-time label shown to the right of each row. Locale-tuned
// to feel native in Swedish without going full date-fns.
function relativeTime(iso: string | null | undefined): string {
if (!iso) return ''
const t = new Date(iso).getTime()
const now = Date.now()
const diffMin = Math.round((now - t) / 60000)
if (diffMin < 1) return 'nu'
if (diffMin < 60) return `${diffMin} min`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr} h`
const diffDay = Math.round(diffHr / 24)
if (diffDay < 7) return `${diffDay} d`
return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
}
export default function ChatSidebar({ initialConversations }: Props) {
const router = useRouter()
const pathname = usePathname()
@@ -110,20 +61,7 @@ export default function ChatSidebar({ initialConversations }: Props) {
// Group filtered into ordered buckets, preserving the sort order already
// applied server-side (pinned first, then last_message_at desc).
const grouped = useMemo(() => {
const buckets: Record<DateBucket, ConversationRow[]> = {
pinned: [],
today: [],
yesterday: [],
thisWeek: [],
older: [],
}
for (const c of filtered) buckets[bucketFor(c)].push(c)
const order: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older']
return order
.map((b) => ({ bucket: b, rows: buckets[b] }))
.filter((g) => g.rows.length > 0)
}, [filtered])
const grouped = useMemo(() => groupConversations(filtered), [filtered])
async function togglePin(id: string, current: boolean) {
setConversations((prev) =>
@@ -146,6 +84,38 @@ export default function ChatSidebar({ initialConversations }: Props) {
if (activeId === id) startTransition(() => router.push('/chat'))
}
// Inline rename of a conversation's title (PATCH /api/agent/conversations/[id]).
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
// Set by Esc so the blur that fires when the input unmounts doesn't save.
const cancelRef = useRef(false)
function startEdit(c: ConversationRow) {
setEditingId(c.id)
setEditValue(c.title ?? '')
cancelRef.current = false
}
function cancelEdit() {
cancelRef.current = true
setEditingId(null)
}
async function commitEdit(id: string) {
if (cancelRef.current) {
cancelRef.current = false
return
}
setEditingId(null)
const title = editValue.trim()
const current = conversations.find((c) => c.id === id)
if (!title || title === current?.title) return
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)))
await fetch(`/api/agent/conversations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
}
// Collapsed rail (desktop only). Mobile keeps the existing behavior where
// the sidebar IS the page when no conversation is open, so the rail is
// hidden below md. On desktop the rail keeps a thin column with toggle
@@ -248,69 +218,107 @@ export default function ChatSidebar({ initialConversations }: Props) {
<p className="px-4 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
{BUCKET_LABELS[bucket]}
</p>
<ul>
<ul className="space-y-1">
{rows.map((c) => (
<li key={c.id}>
<Link
href={`/chat/${c.id}`}
className={cn(
'group flex items-start gap-2 px-4 py-2 hover:bg-secondary/60 transition-colors border-l-2',
activeId === c.id
? 'bg-secondary/50 border-foreground'
: 'border-transparent',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate flex-1 min-w-0">
{c.title ?? intentLabel(c.intent_id)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{relativeTime(c.last_message_at ?? c.created_at)}
{editingId === c.id ? (
<div className="px-4 py-2">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => void commitEdit(c.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
;(e.target as HTMLInputElement).blur()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
}}
placeholder="Namnge konversationen…"
maxLength={200}
aria-label="Nytt namn på konversationen"
className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
) : (
<Link
href={`/chat/${c.id}`}
className={cn(
'group flex items-start gap-2 px-4 py-2 hover:bg-secondary/60 transition-colors border-l-2',
activeId === c.id
? 'bg-secondary/50 border-foreground'
: 'border-transparent',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate flex-1 min-w-0">
{c.title ?? intentLabel(c.intent_id)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{relativeTime(c.last_message_at ?? c.created_at)}
</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{c.last_message_preview ?? intentLabel(c.intent_id)}
</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{c.last_message_preview ?? intentLabel(c.intent_id)}
</p>
</div>
{/* Always-visible action icons. Touch-friendly, no
hover-only invisibility on mobile. */}
<div className="flex flex-col gap-1 shrink-0 -mr-1">
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void togglePin(c.id, c.pinned)
}}
title={c.pinned ? 'Avfäst' : 'Fäst'}
aria-label={c.pinned ? 'Avfäst konversation' : 'Fäst konversation'}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded transition-colors',
c.pinned
? 'text-foreground'
: 'text-muted-foreground/50 hover:text-foreground hover:bg-secondary',
)}
>
{c.pinned ? (
<Pin className="h-3 w-3" fill="currentColor" />
) : (
<PinOff className="h-3 w-3" />
)}
</button>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void archive(c.id)
}}
title="Arkivera"
aria-label="Arkivera konversation"
className="inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary transition-colors"
>
<Archive className="h-3 w-3" />
</button>
</div>
</Link>
{/* Always-visible action icons. Touch-friendly, no
hover-only invisibility on mobile. Laid out
horizontally so three icons don't stack and inflate
the row height. */}
<div className="flex items-center gap-1 shrink-0 -mr-1">
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
startEdit(c)
}}
title="Byt namn"
aria-label="Byt namn på konversation"
className="inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary transition-colors"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void togglePin(c.id, c.pinned)
}}
title={c.pinned ? 'Avfäst' : 'Fäst'}
aria-label={c.pinned ? 'Avfäst konversation' : 'Fäst konversation'}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded transition-colors',
c.pinned
? 'text-foreground'
: 'text-muted-foreground/50 hover:text-foreground hover:bg-secondary',
)}
>
{c.pinned ? (
<Pin className="h-3 w-3" fill="currentColor" />
) : (
<PinOff className="h-3 w-3" />
)}
</button>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void archive(c.id)
}}
title="Arkivera"
aria-label="Arkivera konversation"
className="inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary transition-colors"
>
<Archive className="h-3 w-3" />
</button>
</div>
</Link>
)}
</li>
))}
</ul>
@@ -322,26 +330,3 @@ export default function ChatSidebar({ initialConversations }: Props) {
</>
)
}
function intentLabel(intentId: string): string {
switch (intentId) {
case 'general.help':
return 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
return 'Granska leverantörsfaktura'
case 'vat.review':
return 'Granska moms­deklaration'
case 'bokslut.step':
return 'Hjälp med bokslut'
case 'verifikation.draft':
return 'Hjälp med verifikation'
case 'kpi.explain':
return 'Förklara nyckeltal'
default:
return intentId
}
}
+103
View File
@@ -0,0 +1,103 @@
// Shared display helpers for the agent conversation list — used by both the
// full-page /chat sidebar (ChatSidebar) and the in-sheet "resume conversation"
// list (AgentSessionList). Pure functions; no React. Keeping them in one place
// means the Idag / Igår / Denna vecka / Äldre grouping and the relative-time
// labels stay identical across both surfaces.
export interface ConversationRow {
id: string
intent_id: string
context_ref: string | null
title: string | null
pinned: boolean
archived: boolean
last_message_at: string | null
last_message_preview: string | null
created_at: string
}
// Time buckets for date grouping. Computed once per render against now().
// Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from
// Mail and iMessage.
export type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older'
export const BUCKET_LABELS: Record<DateBucket, string> = {
pinned: 'Fästade',
today: 'Idag',
yesterday: 'Igår',
thisWeek: 'Denna vecka',
older: 'Äldre',
}
export const BUCKET_ORDER: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older']
export function bucketFor(c: ConversationRow): DateBucket {
if (c.pinned) return 'pinned'
const when = c.last_message_at ?? c.created_at
if (!when) return 'older'
const t = new Date(when)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
if (t >= todayStart) return 'today'
if (t >= yesterdayStart) return 'yesterday'
if (t >= weekStart) return 'thisWeek'
return 'older'
}
// Compact relative-time label shown to the right of each row. Locale-tuned
// to feel native in Swedish without going full date-fns.
export function relativeTime(iso: string | null | undefined): string {
if (!iso) return ''
const t = new Date(iso).getTime()
const now = Date.now()
const diffMin = Math.round((now - t) / 60000)
if (diffMin < 1) return 'nu'
if (diffMin < 60) return `${diffMin} min`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr} h`
const diffDay = Math.round(diffHr / 24)
if (diffDay < 7) return `${diffDay} d`
return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
}
export function intentLabel(intentId: string): string {
switch (intentId) {
case 'general.help':
return 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
return 'Granska leverantörsfaktura'
case 'vat.review':
return 'Granska moms­deklaration'
case 'bokslut.step':
return 'Hjälp med bokslut'
case 'verifikation.draft':
return 'Hjälp med verifikation'
case 'kpi.explain':
return 'Förklara nyckeltal'
default:
return intentId
}
}
// Group a flat (already server-sorted: pinned first, then last_message_at desc)
// list into ordered, non-empty buckets. Shared so both list surfaces render
// the same section order.
export function groupConversations(
rows: ConversationRow[],
): { bucket: DateBucket; rows: ConversationRow[] }[] {
const buckets: Record<DateBucket, ConversationRow[]> = {
pinned: [],
today: [],
yesterday: [],
thisWeek: [],
older: [],
}
for (const c of rows) buckets[bucketFor(c)].push(c)
return BUCKET_ORDER.map((b) => ({ bucket: b, rows: buckets[b] })).filter((g) => g.rows.length > 0)
}
+65 -34
View File
@@ -4,6 +4,12 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import {
buildAccountIndex,
searchAccounts,
type SearchableAccount,
type AccountSearchItem,
} from '@/lib/bookkeeping/account-search'
import type { BASAccount } from '@/types'
interface AccountComboboxProps {
@@ -19,51 +25,60 @@ interface AccountComboboxProps {
// dropdown's empty state. The current search string is passed so the caller
// can prefill the create dialog.
onCreateAccount?: (prefill: string) => void
// The full BAS catalogue. When provided, accounts not yet in `accounts`
// (the company's active chart) become searchable by name and are surfaced
// with the `notActivatedLabel` marker; picking one activates it at commit
// via the existing ACCOUNTS_NOT_IN_CHART rail.
catalog?: SearchableAccount[]
// Label shown next to catalogue-only (not-yet-activated) accounts. Defaults
// to Swedish; bilingual hosts pass a localized string.
notActivatedLabel?: string
// Extra classes merged into the trigger Input — callers pass `h-8` for dense
// table rows, omit it to use the default Input height.
className?: string
// Optional callback ref to the underlying <input>, invoked alongside the
// internal one. Lets a parent imperatively focus the field (e.g. auto-advance
// to the next konteringsrad's account on Enter — see JournalEntryForm.focusAccount).
inputRef?: React.RefCallback<HTMLInputElement>
}
const MAX_RESULTS = 50
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, className }: AccountComboboxProps) {
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const internalInputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// Attach the internal ref (used for focus bookkeeping) and forward the element
// to any external callback ref the parent passed.
const setInputRef = useCallback((el: HTMLInputElement | null) => {
internalInputRef.current = el
inputRef?.(el)
}, [inputRef])
// Sync external value changes into the search field
useEffect(() => {
setSearch(value)
}, [value])
// Filter accounts based on search input
const filteredAccounts = useMemo(() => {
if (!search) return accounts.slice(0, MAX_RESULTS)
// Index the active chart + the full BAS catalogue once per source change.
// Searching it per keystroke is then just substring checks over pre-folded
// haystacks (number + name + description, diacritics stripped).
const accountIndex = useMemo(
() => buildAccountIndex({ active: accounts, catalog }),
[accounts, catalog]
)
const trimmed = search.trim()
if (!trimmed) return accounts.slice(0, MAX_RESULTS)
const startsWithDigit = /^\d/.test(trimmed)
if (startsWithDigit) {
return accounts
.filter((a) => a.account_number.startsWith(trimmed))
.slice(0, MAX_RESULTS)
}
const lowerSearch = trimmed.toLowerCase()
return accounts
.filter((a) => a.account_name.toLowerCase().includes(lowerSearch))
.slice(0, MAX_RESULTS)
}, [accounts, search])
const filteredAccounts = useMemo(
() => searchAccounts(accountIndex, search),
[accountIndex, search]
)
// Group filtered accounts by class
const groupedAccounts = useMemo(() => {
const groups: { className: string; accounts: BASAccount[] }[] = []
const groupMap = new Map<string, BASAccount[]>()
const groups: { className: string; accounts: AccountSearchItem[] }[] = []
const groupMap = new Map<string, AccountSearchItem[]>()
for (const account of filteredAccounts) {
const className = getAccountClassName(account.account_class)
@@ -163,8 +178,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
if (/^\d{4}$/.test(newValue)) {
onChange(newValue)
// Only treat as a commit when the value newly becomes this account, so
// editing an already-committed number doesn't keep stealing focus.
if (newValue !== value) onCommit?.(newValue)
// editing an already-committed number doesn't keep stealing focus. On
// commit, close the dropdown too — focus advances to the amount field, so
// a lingering open list would just cover the rows below.
if (newValue !== value) {
onCommit?.(newValue)
setIsOpen(false)
return
}
}
if (!isOpen) {
setIsOpen(true)
@@ -176,6 +197,9 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}
const handleBlur = () => {
// Close the dropdown as soon as focus leaves, so it never lingers open over
// the rows below when focus advances via keyboard (Enter/Tab).
setIsOpen(false)
// Small delay to allow dropdown click to fire first. Keep any 4-digit
// numeric value even if it's not in the currently-active chart — the
// submit handler will prompt to activate it.
@@ -190,7 +214,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
return (
<div ref={containerRef} className="relative">
<Input
ref={inputRef}
ref={setInputRef}
value={search}
onChange={handleInputChange}
onFocus={handleFocus}
@@ -213,12 +237,12 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
{group.className}
</div>
{group.accounts.map((account) => {
const flatIndex = flatList.indexOf(account)
{group.accounts.map((item) => {
const flatIndex = flatList.indexOf(item)
const isHighlighted = flatIndex === highlightedIndex
return (
<button
key={account.account_number}
key={item.account_number}
type="button"
data-highlighted={isHighlighted}
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
@@ -226,12 +250,19 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}`}
onMouseDown={(e) => {
e.preventDefault()
selectAccount(account.account_number)
selectAccount(item.account_number)
}}
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className="font-mono shrink-0">{account.account_number}</span>
<span className="flex-1 min-w-0 break-words">{account.account_name}</span>
<span className={`font-mono shrink-0 ${item.isActive ? '' : 'text-muted-foreground'}`}>
{item.account_number}
</span>
<span className="flex-1 min-w-0 break-words">{item.account_name}</span>
{!item.isActive && (
<span className="shrink-0 self-center text-[11px] text-muted-foreground whitespace-nowrap">
{notActivatedLabel}
</span>
)}
</button>
)
})}
@@ -0,0 +1,159 @@
'use client'
import { useMemo, useState, useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import OpeningBalanceRowEditor, {
type EditableRow,
type OpeningBalanceEditorState,
} from '@/components/import/OpeningBalanceRowEditor'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface Props {
/** The currently-linked, posted opening-balance verifikat being corrected. */
entry: JournalEntry
open: boolean
onOpenChange: (open: boolean) => void
onCorrected: () => void
}
let seedIdCounter = 0
// Map the booked IB's lines into editable rows. account_name isn't stored on
// the line, so resolve it from BAS for display (cosmetic — only account_number
// + amounts are sent on save).
function seedRowsFromEntry(entry: JournalEntry): EditableRow[] {
const lines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
return lines.map((l) => {
const bas = BAS_REFERENCE.find((a) => a.account_number === l.account_number)
return {
id: l.id || `seed_${++seedIdCounter}`,
account_number: l.account_number,
account_name: bas?.account_name ?? '',
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
validation_errors: [],
bas_match: bas?.account_name ?? null,
}
})
}
/**
* Inline correction of an already-booked opening-balance verifikat. The user
* edits the IB's lines directly; on save we POST to
* /api/import/opening-balance/correct, which (BFL-compliant) stornoes the old
* IB, books a corrected one, and relinks the period to it. Works regardless of
* how the IB was created (SIE import, CSV/Excel import, or year-end carry).
*/
export default function CorrectOpeningBalanceDialog({
entry,
open,
onOpenChange,
onCorrected,
}: Props) {
const { toast } = useToast()
const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry])
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = useCallback(async () => {
if (!state?.canSubmit || isSubmitting) return
setIsSubmitting(true)
try {
const lines = state.rows
.filter((r) => r.debit_amount > 0 || r.credit_amount > 0)
.map((r) => ({
account_number: r.account_number,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
}))
const res = await fetch('/api/import/opening-balance/correct', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fiscal_period_id: entry.fiscal_period_id, lines }),
})
const result = await res.json()
if (!res.ok) {
const err = new Error('Failed to correct opening balances') as Error & {
body?: unknown
status?: number
}
err.body = result
err.status = res.status
throw err
}
toast({
title: 'Ingående balanser korrigerade',
description: 'Den gamla IB-verifikationen stornades och en ny bokfördes.',
})
onOpenChange(false)
onCorrected()
} catch (err) {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte korrigera ingående balanser',
description: getErrorMessage(anyErr.body ?? err, {
context: 'journal_entry',
statusCode: anyErr.status,
}),
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}, [state, isSubmitting, entry.fiscal_period_id, toast, onOpenChange, onCorrected])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Korrigera ingående balanser</DialogTitle>
<DialogDescription>
Ändra beloppen nedan och spara. Den befintliga IB-verifikationen (
{formatVoucher(entry)}) makuleras och en ny bokförs med de korrigerade beloppen.
</DialogDescription>
</DialogHeader>
{/* Storno explanation — a booked verifikat can't be edited in place */}
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
En bokförd verifikation kan inte ändras direkt (Bokföringslagen). När du sparar stornas
den gamla IB-verifikationen och en ny bokförs — båda sparas som en spårbar rättelse.
</p>
</div>
<OpeningBalanceRowEditor initialRows={initialRows} onChange={setState} />
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={!state?.canSubmit || isSubmitting}>
{isSubmitting ? 'Sparar...' : 'Korrigera ingående balanser'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,189 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { ExternalLink, FileText } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
/**
* Side-by-side document viewer used while booking manually, so the user can
* read the figures off a receipt/invoice while filling in the journal entry.
*
* Renders by document id through the same-origin inline proxy
* (/api/documents/:id/inline). PDFs use <object type="application/pdf"> rather
* than <iframe> — Chrome intermittently blocks PDFs in a frame even with a
* permissive CSP (see the note in AttachmentPreviewSheet), and <object>
* invokes the PDF plugin directly. Images use <img>.
*
* Unlike AttachmentPreviewSheet this is keyed off a document id, not a
* journal_entry_id — during manual booking the entry does not exist yet.
*/
function isImageType(type: string | null, fileName?: string | null): boolean {
if (type?.startsWith('image/')) return true
// Legacy uploads sometimes leave mime_type null or application/octet-stream —
// fall back to the filename extension.
if (type === null || type === 'application/octet-stream') {
return /\.(jpe?g|png|gif|webp|svg)$/i.test(fileName ?? '')
}
return false
}
function isPdfType(type: string | null, fileName?: string | null): boolean {
if (type === 'application/pdf') return true
if (type === null || type === 'application/octet-stream') {
return fileName?.toLowerCase().endsWith('.pdf') ?? false
}
return false
}
interface DocumentViewerPaneProps {
/** Document id. Bytes are served via the same-origin inline proxy. */
documentId?: string | null
/** Pre-known mime type. When omitted it's fetched from /api/documents/:id. */
mime?: string | null
/** Pre-known signed URL for the "open in new tab" link. Optional. */
downloadUrl?: string | null
/** Optional filename — used for mime sniffing on legacy/octet-stream files. */
fileName?: string | null
className?: string
}
export default function DocumentViewerPane({
documentId,
mime: mimeProp = null,
downloadUrl: downloadUrlProp = null,
fileName = null,
className,
}: DocumentViewerPaneProps) {
const t = useTranslations('document_viewer')
// Fetched metadata is tagged with the document id it belongs to, so a stale
// response for a previously-shown document is ignored rather than flashed.
const [fetched, setFetched] = useState<
{ id: string; mime: string | null; url: string | null } | null
>(null)
// Resolve mime / download_url from the documents API only when the caller did
// not supply a mime (e.g. a pre-linked transaction document). When a mime is
// provided (fresh upload, inbox preview) we skip the round-trip entirely.
// setState happens only inside the async callbacks — never synchronously in
// the effect body — to avoid cascading renders (react-hooks/set-state-in-effect).
useEffect(() => {
if (!documentId || mimeProp) return
let cancelled = false
fetch(`/api/documents/${documentId}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (cancelled) return
setFetched({
id: documentId,
mime: body?.data?.mime_type ?? null,
url: body?.data?.download_url ?? null,
})
})
.catch(() => {
// preview is best-effort — record an empty result so we stop "loading"
if (!cancelled) setFetched({ id: documentId, mime: null, url: null })
})
return () => {
cancelled = true
}
}, [documentId, mimeProp])
const fetchedForThis = fetched?.id === documentId ? fetched : null
const mime = mimeProp ?? fetchedForThis?.mime ?? null
const downloadUrl = downloadUrlProp ?? fetchedForThis?.url ?? null
const loadingMeta = !!documentId && !mimeProp && !fetchedForThis
if (!documentId) {
return (
<div
className={cn(
'flex h-full w-full items-center justify-center rounded-lg border bg-muted/20 text-sm text-muted-foreground',
className,
)}
>
<FileText className="mr-2 h-5 w-5" />
{t('empty')}
</div>
)
}
const inlineSrc = `/api/documents/${documentId}/inline`
const newTabHref = downloadUrl ?? inlineSrc
const showAsImage = isImageType(mime, fileName)
const showAsPdf = isPdfType(mime, fileName)
return (
<div
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-lg border bg-muted/20',
className,
)}
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5">
<span className="truncate text-xs text-muted-foreground">
{fileName ?? t('header_label')}
</span>
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('open_in_new_tab')}
</a>
</div>
<div className="min-h-0 flex-1 overflow-auto bg-background">
{loadingMeta ? (
<div className="p-3">
<Skeleton className="h-full min-h-[40vh] w-full rounded-md" />
</div>
) : showAsPdf ? (
<object
data={inlineSrc}
type="application/pdf"
aria-label={fileName ?? t('header_label')}
className="h-full w-full"
>
<div className="flex h-full w-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
{t('not_previewable')}
{' — '}
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="ml-1 underline"
>
{t('open_in_new_tab')}
</a>
</div>
</object>
) : showAsImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inlineSrc}
alt={fileName ?? t('header_label')}
className="mx-auto max-w-full object-contain"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<FileText className="h-6 w-6" />
<span>{t('not_previewable')}</span>
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t('open_in_new_tab')}
</a>
</div>
)}
</div>
</div>
)
}
+53 -3
View File
@@ -16,6 +16,7 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
@@ -124,6 +125,10 @@ export default function JournalEntryForm({
const [showNoDocWarning, setShowNoDocWarning] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
// Full BAS catalogue (static reference data, fetched once per session). Lets
// the account picker surface standard accounts the company hasn't activated
// yet; picking one activates it at commit via the existing rail.
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [entryCurrency, setEntryCurrency] = useState<Currency>('SEK')
const [exchangeRate, setExchangeRate] = useState('')
const [isFetchingRate, setIsFetchingRate] = useState(false)
@@ -147,6 +152,8 @@ export default function JournalEntryForm({
// cards + desktop table); we focus whichever one is actually visible.
const desktopDebitRefs = useRef<(HTMLInputElement | null)[]>([])
const mobileDebitRefs = useRef<(HTMLInputElement | null)[]>([])
// Confirm button in the inline (bare) review, focused on open so Enter posts.
const bareConfirmRef = useRef<HTMLButtonElement>(null)
const isForeign = entryCurrency !== 'SEK'
@@ -185,6 +192,7 @@ export default function JournalEntryForm({
useEffect(() => {
fetchPeriods()
fetchAccounts()
loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */})
// Fetch default voucher series from company settings — prefer the
// per-source-type mapping when present; fall back to the legacy
// default_voucher_series, then to 'A'.
@@ -363,7 +371,11 @@ export default function JournalEntryForm({
// surprising when splitting across several lines. The balancing amount is
// now opt-in via double-clicking a debit/credit field (handleFillBalance).
if (field === 'account_number' && value) {
const account = accounts.find((a) => a.account_number === value)
// Fall back to the BAS catalogue so the description still auto-fills when
// the chosen account isn't in the active chart yet.
const account =
accounts.find((a) => a.account_number === value) ??
catalog.find((a) => a.account_number === value)
if (account) {
updated[index].line_description = account.account_name
// Fortnox-style: seed the verifikationstext from the first row's account
@@ -429,6 +441,14 @@ export default function JournalEntryForm({
})
}, [lines])
// Inline (bare) review: move focus to the confirm button when it opens so
// Enter posts — parity with the ConfirmationDialog's autoFocusConfirm.
useEffect(() => {
if (bare && showReview) {
requestAnimationFrame(() => bareConfirmRef.current?.focus())
}
}, [bare, showReview])
// Only lines with both an account and a non-zero amount end up in the submit
// payload (see the filter in handleConfirm). Compute totals and balance from
// those same lines so the enable-gate matches what the API will actually see.
@@ -530,6 +550,31 @@ export default function JournalEntryForm({
setShowReview(true)
}
// Whether an Enter should open the review — mirrors the review button's
// enable gate exactly, so Enter never submits something the button wouldn't.
const canSubmitReview = () =>
isBalanced &&
!!description &&
!!selectedPeriod &&
!periodMismatch &&
!isUploading &&
canWrite &&
!isSubmitting &&
!isSavingDraft
// Enter anywhere in the form = "Granska & skapa": opens the review exactly as
// the button does, from any field. Navigation is Tab's job. Two Enter
// exceptions stay intact: the account combobox (it calls preventDefault to
// select the highlighted account — we skip when defaultPrevented) and the
// internal-note textarea (newlines). The inline review owns its own Enter.
const handleFormKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Enter') return
if (e.defaultPrevented || showReview) return
if ((e.target as HTMLElement).tagName === 'TEXTAREA') return
e.preventDefault()
if (canSubmitReview()) handleReview()
}
// Inner submit: builds payload, POSTs, throws a structured error on failure
// (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART).
const postJournalEntry = useCallback(async () => {
@@ -829,7 +874,7 @@ export default function JournalEntryForm({
<Button variant="outline" onClick={() => setShowReview(false)} disabled={isSubmitting}>
{t('review_back')}
</Button>
<Button onClick={handleConfirm} disabled={isSubmitting}>
<Button ref={bareConfirmRef} onClick={handleConfirm} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{/* No underlag attached → explicit acknowledgement, equivalent to the
blocking "Bokför utan underlag" dialog in the non-bare flow (BFL
@@ -843,7 +888,7 @@ export default function JournalEntryForm({
)
const formContent = (
<div className="space-y-4">
<div className="space-y-4" onKeyDown={handleFormKeyDown}>
{bare && showReview ? reviewPanel : (
<>
{/* Verifikat metadata — compact bar on top (Fortnox-style). Date, series
@@ -1041,6 +1086,8 @@ export default function JournalEntryForm({
<AccountCombobox
value={line.account_number}
accounts={accounts}
catalog={catalog}
notActivatedLabel={t('account_not_activated')}
onChange={(num) => updateLine(index, 'account_number', num)}
onCommit={() => focusDebit(index)}
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
@@ -1158,6 +1205,8 @@ export default function JournalEntryForm({
<AccountCombobox
value={line.account_number}
accounts={accounts}
catalog={catalog}
notActivatedLabel={t('account_not_activated')}
onChange={(num) => updateLine(index, 'account_number', num)}
onCommit={() => focusDebit(index)}
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
@@ -1378,6 +1427,7 @@ export default function JournalEntryForm({
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
autoFocusConfirm
title={
!embedded && nextVoucherNumber != null
? t('review_title_with_voucher', { voucher: formatVoucher({ voucher_series: voucherSeries, voucher_number: nextVoucherNumber }) })
@@ -1,7 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { Copy, Loader2 } from 'lucide-react'
import { Copy, Loader2, MessageCircle } from 'lucide-react'
import {
Dialog,
DialogContent,
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
export interface CopyPrefill {
sourceId: string
@@ -42,16 +43,19 @@ export default function NewJournalEntryDialog({
isLoading,
}: Props) {
const t = useTranslations('bookkeeping')
const { openAgentSheet, identity } = useAgentSheet()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// A half-typed verifikat must survive an accidental click on the
// backdrop (easy to do across multiple windows/screens). Closing is
// explicit — the header X or Cancel. This also stops nested popovers
// (AccountCombobox, date pickers) and the form's own confirm dialogs
// from collapsing the parent when they portal outside it.
// A half-typed verifikat must survive an accidental backdrop click or a
// stray Escape (easy to hit across multiple windows/screens, or when you
// only meant to dismiss a combobox dropdown). Closing is explicit — the
// header X. This also stops nested popovers (AccountCombobox, date
// pickers) and the form's own confirm dialogs from collapsing the parent
// when they portal outside it.
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
@@ -59,6 +63,24 @@ export default function NewJournalEntryDialog({
<DialogTitle>{t('new_entry_dialog_title')}</DialogTitle>
</DialogHeader>
{identity.isVerified && !copyPrefill && (
// Hand off to the assistant: it reads the underlag (the figures the
// user often can't see), suggests accounts, and stages a balanced
// verifikat to approve — no copy-paste. Close the modal first so its
// focus trap doesn't fight the (non-modal) agent sheet.
<button
type="button"
onClick={() => {
onOpenChange(false)
openAgentSheet({ intentId: 'verifikation.draft', contextRef: 'verifikation:new' })
}}
className="inline-flex w-fit items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-3.5 w-3.5" />
{t('ask_assistant_handoff')}
</button>
)}
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -18,6 +18,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import { useCompany } from '@/contexts/CompanyContext'
@@ -71,6 +72,10 @@ interface Props {
open: boolean
onOpenChange: (v: boolean) => void
item: InboxItem
/** Signed URL + mime of the inbox document, threaded from the workspace so
the underlag can be shown beside the form without an extra round-trip. */
docUrl?: string | null
docMime?: string | null
onSuccess: () => void | Promise<void>
}
@@ -149,7 +154,7 @@ function rankBySekCloseness(
return [...rows].sort((a, b) => Math.abs(txSekAmount(a) - abs) - Math.abs(txSekAmount(b) - abs))
}
export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess }: Props) {
export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = null, docMime = null, onSuccess }: Props) {
const { toast } = useToast()
const { company } = useCompany()
@@ -530,7 +535,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför direkt</DialogTitle>
<DialogDescription>
@@ -538,7 +543,20 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
</DialogDescription>
</DialogHeader>
<div className="space-y-6 pt-2">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)]">
{/* Document column — sticky on desktop so the underlag stays visible
while the form scrolls; stacks above the form on smaller screens. */}
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
<DocumentViewerPane
documentId={item.document_id}
mime={docMime}
downloadUrl={docUrl}
className="h-full"
/>
</div>
{/* Booking form */}
<div className="space-y-6 pt-2">
{/* Metadata row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5">
@@ -887,6 +905,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
<ActivateAccountsDialog
@@ -0,0 +1,253 @@
'use client'
import { useMemo, useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type { InvoiceExtractionResult, VatTreatment } from '@/types'
// Minimal shape the dialog needs from the workspace's inbox items.
interface BulkBookInboxItem {
id: string
matched_transaction_id: string | null
created_journal_entry_id: string | null
created_supplier_invoice_id: string | null
extracted_data: InvoiceExtractionResult | null
}
interface Props {
open: boolean
onOpenChange: (v: boolean) => void
// The user's full checkbox selection. Non-bookable items are filtered out
// and surfaced as a "skipped" count so the user understands the outcome.
items: BulkBookInboxItem[]
onSuccess: () => void | Promise<void>
}
// Swedish category labels — mirrors lib/bookkeeping/category-mapping.ts
// (categoryLabels), ordered expenses-first since underlag are overwhelmingly
// costs. Values match TransactionCategorySchema in lib/api/schemas.ts.
const CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: 'expense_software', label: 'Programvara/IT-tjänster' },
{ value: 'expense_office', label: 'Kontorskostnad' },
{ value: 'expense_consumables', label: 'Förbrukningsvaror' },
{ value: 'expense_equipment', label: 'Förbrukningsinventarier' },
{ value: 'expense_telecom', label: 'Telefon & internet' },
{ value: 'expense_travel', label: 'Resekostnad' },
{ value: 'expense_marketing', label: 'Marknadsföring' },
{ value: 'expense_professional_services', label: 'Konsulttjänst' },
{ value: 'expense_education', label: 'Utbildning' },
{ value: 'expense_representation', label: 'Representation' },
{ value: 'expense_vehicle', label: 'Bil & drivmedel' },
{ value: 'expense_bank_fees', label: 'Bankavgift' },
{ value: 'expense_card_fees', label: 'Kortavgift' },
{ value: 'expense_currency_exchange', label: 'Valutaväxling' },
{ value: 'expense_other', label: 'Övrig kostnad' },
{ value: 'income_services', label: 'Tjänsteförsäljning' },
{ value: 'income_products', label: 'Varuförsäljning' },
{ value: 'income_other', label: 'Övrig intäkt' },
{ value: 'private', label: 'Privat' },
]
// VAT treatment options. `value` is typed as `VatTreatment` (types/index.ts)
// so this list can never drift from what the backend accepts: the bulk-book
// route feeds the value straight into buildMappingResultFromCategory, which
// only recognises these six. The 12% and 6% reduced rates are ALREADY covered
// here by `reduced_12` / `reduced_6` — there is deliberately no `standard_12` /
// `standard_6` (no such treatment exists; the backend would reject it). Keep
// this list in sync with the union, not with rate labels.
const VAT_OPTIONS: { value: VatTreatment; label: string }[] = [
{ value: 'standard_25', label: 'Moms 25%' },
{ value: 'reduced_12', label: 'Moms 12%' },
{ value: 'reduced_6', label: 'Moms 6%' },
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet (EU/utland)' },
{ value: 'export', label: 'Export (0%)' },
{ value: 'exempt', label: 'Momsfri' },
]
function isBookable(it: BulkBookInboxItem): boolean {
return Boolean(it.matched_transaction_id) && !it.created_journal_entry_id && !it.created_supplier_invoice_id
}
export default function BulkBookInboxDialog({ open, onOpenChange, items, onSuccess }: Props) {
const { toast } = useToast()
const [category, setCategory] = useState<string>('')
const [vatTreatment, setVatTreatment] = useState<VatTreatment>('standard_25')
const [isSubmitting, setIsSubmitting] = useState(false)
const bookable = useMemo(() => items.filter(isBookable), [items])
const notMatched = useMemo(
() => items.filter((it) => !it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id).length,
[items],
)
const alreadyBooked = useMemo(
() => items.filter((it) => it.created_journal_entry_id || it.created_supplier_invoice_id).length,
[items],
)
// Reset to the safe default (25% svensk moms) each time the dialog opens.
// Currency is deliberately NOT used to preselect omvänd skattskyldighet: a
// foreign currency does not imply a foreign seller — a Swedish supplier can
// invoice in EUR and still debit 25% moms. Reverse charge is a property of
// the seller (utländsk, utan svenskt momsnr), never of the currency, so
// defaulting to it from currency alone would silently mis-book domestic VAT.
// The advisory rendered under the Moms picker spells this out to the user.
useEffect(() => {
if (open) setVatTreatment('standard_25')
}, [open])
const totalSek = useMemo(
() => bookable.reduce((s, it) => s + (it.extracted_data?.totals?.total ?? 0), 0),
[bookable],
)
const submit = async () => {
if (!category || bookable.length === 0) return
setIsSubmitting(true)
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/items/bulk-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_ids: bookable.map((it) => it.id),
category,
vat_treatment: vatTreatment,
}),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(json.error ?? `HTTP ${res.status}`)
}
const bookedCount: number = json.data?.booked_count ?? 0
const skippedCount: number = json.data?.skipped_count ?? 0
const parts: string[] = []
if (bookedCount > 0) parts.push(`${bookedCount} bokförda`)
if (skippedCount > 0) parts.push(`${skippedCount} överhoppade`)
toast({
title: 'Bulkbokföring klar',
description: parts.join(' · ') || 'Inga underlag bokfördes',
variant: bookedCount === 0 ? 'destructive' : 'default',
})
onOpenChange(false)
await onSuccess()
} catch (err) {
toast({
title: 'Bokföringen misslyckades',
description: err instanceof Error ? err.message : 'Okänt fel',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
const skippedNote: string | null = useMemo(() => {
const bits: string[] = []
if (notMatched > 0) bits.push(`${notMatched} saknar matchad transaktion`)
if (alreadyBooked > 0) bits.push(`${alreadyBooked} redan bokförda`)
return bits.length > 0 ? bits.join(' · ') : null
}, [notMatched, alreadyBooked])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Bokför {bookable.length} underlag</DialogTitle>
<DialogDescription>
Varje underlag bokförs mot sin matchade banktransaktion med samma kategori och momsbehandling.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bulk-category">Kategori</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="bulk-category">
<SelectValue placeholder="Välj kategori" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label htmlFor="bulk-vat">Moms</Label>
<InfoTooltip
content={
<>
Välj <strong>Omvänd skattskyldighet</strong> för köp från en utländsk säljare utan
svenskt momsnummer (t.ex. EU-tjänster som moln/mjukvara). Svenska fakturor med moms:
välj den sats kvittot visar — valutan avgör inte.
</>
}
/>
</div>
<Select value={vatTreatment} onValueChange={(v) => setVatTreatment(v as VatTreatment)}>
<SelectTrigger id="bulk-vat">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{vatTreatment === 'reverse_charge' && (
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
<strong className="font-medium text-foreground">Kontrollera säljaren.</strong>{' '}
Omvänd skattskyldighet gäller bara köp från en <strong className="font-medium text-foreground">utländsk
säljare utan svenskt momsregistreringsnummer</strong> — t.ex. EU-tjänster, EU-varor,
byggtjänster eller viss elektronik. Valutan avgör inte: en svensk säljare kan fakturera i
EUR och ändå debitera 25% moms. Är säljaren svensk och momsen står på kvittot, välj i
stället rätt momssats ovan.
</div>
)}
</div>
{totalSek > 0 && (
<p className="text-xs text-muted-foreground tabular-nums">
Underlagens summa: {formatCurrency(totalSek)}
</p>
)}
{skippedNote && (
<p className="text-xs text-muted-foreground">
Hoppas över: {skippedNote}.
</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={submit} disabled={isSubmitting || !category || bookable.length === 0}>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Bokför {bookable.length} underlag
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -42,6 +42,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceExtractionResult } from '@/types'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
@@ -233,6 +234,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [isRotating, setIsRotating] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [bookDirectOpen, setBookDirectOpen] = useState(false)
// Bulk-book selected underlag (Modell B) — the "Bokför valda" selection-bar
// action. The dialog filters the selection to bookable items itself.
const [bulkBookOpen, setBulkBookOpen] = useState(false)
// Match-to-bank-transaction picker (opens when user clicks "Matcha mot
// transaktion" on an unmatched inbox item).
const [matchPickerOpen, setMatchPickerOpen] = useState(false)
@@ -605,6 +609,21 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
// The selected rows, and how many of them can actually be bulk-booked
// (matched to a transaction and not yet booked). Drives the "Bokför valda"
// button enabled-state and feeds the bulk-book dialog.
const selectedItems = useMemo(
() => items.filter((it) => selectedIds.has(it.id)),
[items, selectedIds],
)
const bookableSelectedCount = useMemo(
() =>
selectedItems.filter(
(it) => it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id,
).length,
[selectedItems],
)
const handleBulkDelete = useCallback(async () => {
if (selectedIds.size === 0) return
if (!confirm(`Ta bort ${selectedIds.size} poster ur inkorgen?`)) return
@@ -822,29 +841,62 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
</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">
<div className="sticky top-0 z-10 flex flex-col gap-3 border-b bg-background/95 backdrop-blur px-4 py-3">
{/* Count */}
<span className="text-xs text-muted-foreground tabular-nums">
<span className="font-medium text-foreground">{selectedIds.size}</span>{' '}
{selectedIds.size === 1 ? 'markerad' : 'markerade'}
</span>
{/* Primary action — the one solid button */}
<Button
variant="default"
size="sm"
className="h-8 w-full text-xs"
onClick={() => setBulkBookOpen(true)}
disabled={isBulkDeleting || bookableSelectedCount === 0}
title={
bookableSelectedCount === 0
? 'Inget av de valda underlagen är matchat mot en banktransaktion'
: undefined
}
>
<Check className="h-3.5 w-3.5 mr-1.5" />
Bokför valda
</Button>
{/* Secondary actions — outlined, so they read as buttons */}
<div className="flex items-center gap-2">
{identity.isVerified && (
<Button
variant="outline"
size="sm"
className="h-8 flex-1 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() =>
openAgentSheet({
intentId: 'inbox.bulk-book',
intentArgs: { item_ids: Array.from(selectedIds) },
contextRef: 'inbox:bulk',
})
}
disabled={isBulkDeleting}
>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Fråga assistenten
</Button>
)}
<Button
variant="ghost"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={clearSelection}
disabled={isBulkDeleting}
>
Avmarkera
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
className={cn(
'h-8 px-2 text-xs text-muted-foreground hover:text-destructive hover:border-destructive/40',
identity.isVerified ? 'flex-none' : 'flex-1'
)}
onClick={handleBulkDelete}
disabled={isBulkDeleting}
>
{isBulkDeleting ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3 w-3 mr-1" />
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
Ta bort
</Button>
@@ -1014,11 +1066,22 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
open={bookDirectOpen}
onOpenChange={setBookDirectOpen}
item={selected}
docUrl={docUrl}
docMime={docMime}
onSuccess={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
}}
/>
)}
<BulkBookInboxDialog
open={bulkBookOpen}
onOpenChange={setBulkBookOpen}
items={selectedItems}
onSuccess={async () => {
clearSelection()
await fetchItems()
}}
/>
{selected && (
<TransactionMatchPicker
open={matchPickerOpen}
+15 -371
View File
@@ -1,24 +1,13 @@
'use client'
import { useState, useMemo, useCallback, useRef } from 'react'
import Fuse from 'fuse.js'
import { useMemo, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { ParsedOpeningBalanceRow } from '@/lib/import/opening-balance/types'
interface EditableRow {
id: string
account_number: string
account_name: string
debit_amount: number
credit_amount: number
validation_errors: string[]
bas_match: string | null
}
import OpeningBalanceRowEditor, {
type EditableRow,
type OpeningBalanceEditorState,
} from './OpeningBalanceRowEditor'
interface OpeningBalanceEditStepProps {
rows: ParsedOpeningBalanceRow[]
@@ -26,37 +15,6 @@ interface OpeningBalanceEditStepProps {
onBack: () => void
}
// Filter BAS reference to balance sheet accounts only (class 1-2) for primary suggestions
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
let fuseInstance: Fuse<typeof BAS_REFERENCE[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return fuseInstance
}
let balanceFuseInstance: Fuse<typeof BAS_REFERENCE[0]> | null = null
function getBalanceFuse() {
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return balanceFuseInstance
}
let idCounter = 0
function generateId() {
return `row_${++idCounter}_${Date.now()}`
@@ -67,28 +25,9 @@ export default function OpeningBalanceEditStep({
onContinue,
onBack,
}: OpeningBalanceEditStepProps) {
const [rows, setRows] = useState<EditableRow[]>(() => {
// Defense-in-depth dedup: if the parser ever leaks duplicates by
// account_number, collapse them here before the user sees them. Union
// validation_errors so a warning surfaced only on the later row isn't
// silently dropped during the merge.
const byAccount = new Map<string, EditableRow>()
for (const r of initialRows) {
const key = r.account_number.replace(/\D/g, '')
const existing = byAccount.get(key)
if (existing) {
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
if (r.validation_errors?.length) {
const seen = new Set(existing.validation_errors)
for (const err of r.validation_errors) {
if (!seen.has(err)) existing.validation_errors.push(err)
}
}
continue
}
byAccount.set(key, {
const seedRows = useMemo<EditableRow[]>(
() =>
initialRows.map((r) => ({
id: generateId(),
account_number: r.account_number,
account_name: r.account_name,
@@ -96,323 +35,28 @@ export default function OpeningBalanceEditStep({
credit_amount: r.credit_amount,
validation_errors: [...r.validation_errors],
bas_match: r.bas_match,
})
}
return Array.from(byAccount.values())
})
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteRef = useRef<HTMLDivElement>(null)
// Compute totals
const totals = useMemo(() => {
let debit = 0
let credit = 0
for (const row of rows) {
debit = Math.round((debit + row.debit_amount) * 100) / 100
credit = Math.round((credit + row.credit_amount) * 100) / 100
}
const diff = Math.round((debit - credit) * 100) / 100
return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 }
}, [rows])
// Validation
const hasErrors = useMemo(() => {
return rows.some((r) => {
if (!/^\d{4}$/.test(r.account_number)) return true
if (r.debit_amount === 0 && r.credit_amount === 0) return true
if (r.validation_errors.length > 0) return true
return false
})
}, [rows])
const canContinue = totals.isBalanced && !hasErrors && rows.length >= 2
// Autocomplete results
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
// If the query is numeric, search all accounts; otherwise prefer balance sheet
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r
const updated = { ...r, ...updates }
// Re-validate
const errors: string[] = []
if (!/^\d{4}$/.test(updated.account_number)) {
errors.push('Ogiltigt kontonummer')
}
const cls = parseInt(updated.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) {
errors.push(`Resultatkonto (klass ${cls})`)
}
updated.validation_errors = errors
return updated
}),
)
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
id: generateId(),
account_number: '',
account_name: '',
debit_amount: 0,
credit_amount: 0,
validation_errors: ['Ogiltigt kontonummer'],
bas_match: null,
},
])
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,
bas_match: account.account_name,
})
setActiveAutocomplete(null)
setAutocompleteQuery('')
},
[updateRow],
})),
[initialRows],
)
const handleAutoBalance = useCallback(() => {
if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK
if (totals.isBalanced) return
const adjustmentRow: EditableRow = {
id: generateId(),
account_number: '2099',
account_name: 'Årets resultat',
debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff),
credit_amount: totals.diff > 0 ? totals.diff : 0,
validation_errors: [],
bas_match: 'Årets resultat',
}
setRows((prev) => [...prev, adjustmentRow])
}, [totals])
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
return (
<Card>
<CardHeader>
<CardTitle>Granska och redigera</CardTitle>
<CardDescription>
Kontrollera att kontonummer och belopp stämmer. Du kan lägga till, ta bort
och ändra rader. Debet och kredit måste balansera innan du kan fortsätta.
Kontrollera att kontonummer och belopp stämmer. Du kan lägga till, ta bort och ändra
rader. Debet och kredit måste balansera innan du kan fortsätta.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left w-28">Konto</th>
<th className="px-3 py-2 text-left">Kontonamn</th>
<th className="px-3 py-2 text-right w-32">Debet</th>
<th className="px-3 py-2 text-right w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
row.validation_errors.length > 0 && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5 relative">
<Input
value={row.account_number}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4)
updateRow(row.id, { account_number: val })
setActiveAutocomplete(row.id)
setAutocompleteQuery(val)
}}
onFocus={() => {
setActiveAutocomplete(row.id)
setAutocompleteQuery(row.account_number)
}}
onBlur={() => {
// Delay to allow click on autocomplete items
setTimeout(() => setActiveAutocomplete(null), 200)
}}
placeholder="1930"
className="h-8 font-mono tabular-nums w-20"
maxLength={4}
/>
{/* Autocomplete dropdown */}
{activeAutocomplete === row.id &&
autocompleteResults.length > 0 && (
<div
ref={autocompleteRef}
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-md border bg-popover shadow-md"
>
{autocompleteResults.map((item) => (
<button
key={item.account_number}
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
onMouseDown={(e) => {
e.preventDefault()
selectAutocompleteItem(row.id, item)
}}
>
<span className="font-mono text-muted-foreground tabular-nums">
{item.account_number}
</span>
<span className="truncate">{item.account_name}</span>
</button>
))}
</div>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-sm truncate max-w-xs">
{row.account_name}
</span>
{row.validation_errors.length > 0 && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.debit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.credit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="px-3 py-2" colSpan={2}>
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.debit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.credit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td />
</tr>
{!totals.isBalanced && (
<tr className="text-destructive">
<td className="px-3 py-1 text-sm" colSpan={2}>
Differens
</td>
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
{totals.diff.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
SEK
</td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
<OpeningBalanceRowEditor initialRows={seedRows} onChange={setState} />
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
<Scale className="h-3.5 w-3.5 mr-1.5" />
Avrunda ({totals.diff > 0 ? '+' : ''}{totals.diff.toFixed(2)} till 2099)
</Button>
)}
</div>
{/* Warnings */}
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Debet och kredit balanserar inte. Differens:{' '}
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK.
Kontrollera beloppen innan du fortsätter.
</p>
</div>
)}
{/* Navigation */}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={() => onContinue(rows)} disabled={!canContinue}>
<Button onClick={() => state && onContinue(state.rows)} disabled={!state?.canSubmit}>
Fortsätt
</Button>
</div>
+19 -14
View File
@@ -18,7 +18,9 @@ interface EditableRow {
interface OpeningBalancePeriodStepProps {
rows: EditableRow[]
onExecute: (fiscalPeriodId: string) => void
/** `replace` is true when the selected period already has opening balances
* (the existing IB verifikat will be stornoed and replaced). */
onExecute: (fiscalPeriodId: string, replace: boolean) => void
onBack: () => void
isLoading: boolean
error: string | null
@@ -71,22 +73,23 @@ export default function OpeningBalancePeriodStep({
}, [])
const selectedPeriod = periods.find((p) => p.id === selectedPeriodId)
const periodHasOB = selectedPeriod?.opening_balances_set
const periodHasOB = !!selectedPeriod?.opening_balances_set
const periodIsClosed = selectedPeriod?.is_closed
const periodIsLocked = !!selectedPeriod?.locked_at
// A period that already has IB can still be corrected, as long as it is open
// and unlocked — the existing IB verifikat is stornoed and replaced.
const canExecute =
selectedPeriodId &&
!periodHasOB &&
!!selectedPeriodId &&
!periodIsClosed &&
!periodIsLocked &&
!isLoading
const handleExecute = useCallback(() => {
if (canExecute) {
onExecute(selectedPeriodId)
onExecute(selectedPeriodId, periodHasOB)
}
}, [canExecute, selectedPeriodId, onExecute])
}, [canExecute, selectedPeriodId, periodHasOB, onExecute])
return (
<Card>
@@ -131,13 +134,13 @@ export default function OpeningBalancePeriodStep({
)}
</div>
{/* Warnings */}
{periodHasOB && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">
Denna period har redan ingående balanser. Ta bort den befintliga verifikationen
(via stornering) innan du importerar nya.
{/* Replace notice — selecting a period that already has IB corrects it */}
{periodHasOB && !periodIsClosed && !periodIsLocked && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Denna period har redan ingående balanser. Om du fortsätter makuleras (stornas) den
befintliga IB-verifikationen och en ny bokförs med beloppen nedan.
</p>
</div>
)}
@@ -180,8 +183,10 @@ export default function OpeningBalancePeriodStep({
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Bokför...
{periodHasOB ? 'Ersätter...' : 'Bokför...'}
</>
) : periodHasOB ? (
'Ersätt ingående balanser'
) : (
'Bokför ingående balanser'
)}
@@ -15,6 +15,7 @@ export default function OpeningBalanceResultStep({
result,
onNewImport,
}: OpeningBalanceResultStepProps) {
const isCorrection = !!result.reversed_entry_id
return (
<Card>
<CardHeader>
@@ -26,7 +27,9 @@ export default function OpeningBalanceResultStep({
)}
<CardTitle>
{result.success
? 'Ingående balanser bokförda'
? isCorrection
? 'Ingående balanser korrigerade'
: 'Ingående balanser bokförda'
: 'Importen misslyckades'}
</CardTitle>
</div>
@@ -0,0 +1,429 @@
'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import Fuse from 'fuse.js'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
export interface EditableRow {
id: string
account_number: string
account_name: string
debit_amount: number
credit_amount: number
validation_errors: string[]
bas_match: string | null
}
export interface OpeningBalanceEditorState {
rows: EditableRow[]
totals: { debit: number; credit: number; diff: number; isBalanced: boolean }
/** Balanced, no row errors, and at least two lines — safe to book/correct. */
canSubmit: boolean
}
interface OpeningBalanceRowEditorProps {
/** Seed rows. Read once on mount (later prop changes are ignored — remount
* via `key` or a conditional render to reset the grid). */
initialRows: EditableRow[]
/** Fires whenever rows / totals / validity change. Held in a ref internally,
* so it does NOT need to be referentially stable — passing an inline arrow
* is safe and will not cause an update-loop. */
onChange: (state: OpeningBalanceEditorState) => void
}
// Balance-sheet accounts (class 1-2) drive the primary suggestions; numeric
// queries fall back to the full chart.
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
let fuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return fuseInstance
}
let balanceFuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getBalanceFuse() {
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return balanceFuseInstance
}
let idCounter = 0
function generateId() {
return `row_${++idCounter}_${Date.now()}`
}
// Defense-in-depth dedup: if a seed source ever leaks duplicate accounts,
// collapse them before the user sees them. Union validation_errors so a
// warning surfaced only on the later row isn't silently dropped on merge.
function dedupeRows(initialRows: EditableRow[]): EditableRow[] {
const byAccount = new Map<string, EditableRow>()
for (const r of initialRows) {
const key = r.account_number.replace(/\D/g, '')
const existing = byAccount.get(key)
if (existing) {
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
if (r.validation_errors?.length) {
const seen = new Set(existing.validation_errors)
for (const err of r.validation_errors) {
if (!seen.has(err)) existing.validation_errors.push(err)
}
}
continue
}
byAccount.set(key, {
id: r.id || generateId(),
account_number: r.account_number,
account_name: r.account_name,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
validation_errors: [...r.validation_errors],
bas_match: r.bas_match,
})
}
return Array.from(byAccount.values())
}
/**
* The opening-balance line grid: editable account / debet / kredit rows with
* BAS autocomplete, a live debit-credit balance check, blocking of resultat-
* konton (class 3-8), and a "round to 2099" auto-balance for sub-1-SEK drift.
*
* Stateless about WHY it's editing — the wizard seeds it from a parsed file,
* the verifikat correction dialog seeds it from the booked IB's lines. Both
* read the current rows + validity through `onChange`.
*/
export default function OpeningBalanceRowEditor({
initialRows,
onChange,
}: OpeningBalanceRowEditorProps) {
const [rows, setRows] = useState<EditableRow[]>(() => dedupeRows(initialRows))
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteRef = useRef<HTMLDivElement>(null)
// Hold onChange in a ref so an unstable (inline) callback identity can't
// retrigger the notifying effect below. Synced after each commit rather than
// during render (react-hooks/refs: refs must not be written while rendering).
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
})
const totals = useMemo(() => {
let debit = 0
let credit = 0
for (const row of rows) {
debit = Math.round((debit + row.debit_amount) * 100) / 100
credit = Math.round((credit + row.credit_amount) * 100) / 100
}
const diff = Math.round((debit - credit) * 100) / 100
return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 }
}, [rows])
const hasErrors = useMemo(() => {
return rows.some((r) => {
if (!/^\d{4}$/.test(r.account_number)) return true
if (r.debit_amount === 0 && r.credit_amount === 0) return true
if (r.validation_errors.length > 0) return true
return false
})
}, [rows])
const canSubmit = totals.isBalanced && !hasErrors && rows.length >= 2
// Push state up via the ref so an unstable `onChange` identity can't retrigger
// this effect. Memoised `totals` means it only fires when the user actually
// edits a row, never in a loop.
useEffect(() => {
onChangeRef.current({ rows, totals, canSubmit })
}, [rows, totals, canSubmit])
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r
const updated = { ...r, ...updates }
// Re-validate
const errors: string[] = []
if (!/^\d{4}$/.test(updated.account_number)) {
errors.push('Ogiltigt kontonummer')
}
const cls = parseInt(updated.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) {
errors.push(`Resultatkonto (klass ${cls})`)
}
updated.validation_errors = errors
return updated
}),
)
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
id: generateId(),
account_number: '',
account_name: '',
debit_amount: 0,
credit_amount: 0,
validation_errors: ['Ogiltigt kontonummer'],
bas_match: null,
},
])
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,
bas_match: account.account_name,
})
setActiveAutocomplete(null)
setAutocompleteQuery('')
},
[updateRow],
)
const handleAutoBalance = useCallback(() => {
if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK
if (totals.isBalanced) return
const adjustmentRow: EditableRow = {
id: generateId(),
account_number: '2099',
account_name: 'Årets resultat',
debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff),
credit_amount: totals.diff > 0 ? totals.diff : 0,
validation_errors: [],
bas_match: 'Årets resultat',
}
setRows((prev) => [...prev, adjustmentRow])
}, [totals])
return (
<div className="space-y-4">
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left w-28">Konto</th>
<th className="px-3 py-2 text-left">Kontonamn</th>
<th className="px-3 py-2 text-right w-32">Debet</th>
<th className="px-3 py-2 text-right w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
row.validation_errors.length > 0 && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5 relative">
<Input
value={row.account_number}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4)
updateRow(row.id, { account_number: val })
setActiveAutocomplete(row.id)
setAutocompleteQuery(val)
}}
onFocus={() => {
setActiveAutocomplete(row.id)
setAutocompleteQuery(row.account_number)
}}
onBlur={() => {
// Delay to allow click on autocomplete items
setTimeout(() => setActiveAutocomplete(null), 200)
}}
placeholder="1930"
className="h-8 font-mono tabular-nums w-20"
maxLength={4}
/>
{/* Autocomplete dropdown */}
{activeAutocomplete === row.id && autocompleteResults.length > 0 && (
<div
ref={autocompleteRef}
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-md border bg-popover shadow-md"
>
{autocompleteResults.map((item) => (
<button
key={item.account_number}
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
onMouseDown={(e) => {
e.preventDefault()
selectAutocompleteItem(row.id, item)
}}
>
<span className="font-mono text-muted-foreground tabular-nums">
{item.account_number}
</span>
<span className="truncate">{item.account_name}</span>
</button>
))}
</div>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-sm truncate max-w-xs">{row.account_name}</span>
{row.validation_errors.length > 0 && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.debit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.credit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="px-3 py-2" colSpan={2}>
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.debit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.credit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td />
</tr>
{!totals.isBalanced && (
<tr className="text-destructive">
<td className="px-3 py-1 text-sm" colSpan={2}>
Differens
</td>
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
{totals.diff.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
SEK
</td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
<Scale className="h-3.5 w-3.5 mr-1.5" />
Avrunda ({totals.diff > 0 ? '+' : ''}
{totals.diff.toFixed(2)} till 2099)
</Button>
)}
</div>
{/* Warnings */}
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Debet och kredit balanserar inte. Differens:{' '}
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK. Kontrollera
beloppen innan du fortsätter.
</p>
</div>
)}
</div>
)
}
+47 -3
View File
@@ -1,8 +1,10 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency } from '@/lib/utils'
import {
Building2,
@@ -39,9 +41,20 @@ export default function SIEPreviewStep({
const errors = issues.filter((i) => i.severity === 'error')
const warnings = issues.filter((i) => i.severity === 'warning')
// Opening-balance imbalance. The importer plugs any diff > 0.01 to 2099, but a
// diff under ~1 SEK is genuine öresavrundning. Anything larger is a real
// imbalance (incomplete export — missing liabilities / unappropriated prior-year
// result) that would silently book a bogus amount to 2099. Mirrors the importer's
// own `fileImbalance > 1.00` "serious" threshold (lib/import/sie-import.ts).
const ibDiff = Math.round((preview.trialBalance.totalDebit - preview.trialBalance.totalCredit) * 100) / 100
const significantImbalance = !preview.trialBalance.isBalanced && Math.abs(ibDiff) > 1
const [ackImbalance, setAckImbalance] = useState(false)
// Only block on actual parsing errors, not unmapped accounts
// (users need to proceed to mapping step to fix unmapped accounts)
// (users need to proceed to mapping step to fix unmapped accounts).
// A significant IB imbalance is a soft block — the user must acknowledge it.
const hasBlockingErrors = errors.length > 0
const blockContinue = hasBlockingErrors || (significantImbalance && !ackImbalance)
return (
<div className="space-y-6">
@@ -164,11 +177,42 @@ export default function SIEPreviewStep({
<Badge variant="success">Balanserar</Badge>
) : (
<Badge variant="secondary">
Diff: {formatCurrency(preview.trialBalance.totalDebit - preview.trialBalance.totalCredit)}
Diff: {formatCurrency(ibDiff)}
</Badge>
)}
</div>
</div>
{/* Significant imbalance — explain + require acknowledgement before continuing */}
{significantImbalance && (
<div className="mt-4 space-y-3 rounded-lg border border-warning/40 bg-warning/5 px-4 py-3">
<div className="flex items-start gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="font-medium text-warning">
Ingående balanser balanserar inte ({formatCurrency(Math.abs(ibDiff))})
</p>
<p className="text-muted-foreground">
Det betyder oftast att exporten från ditt bokföringssystem är ofullständig —
t.ex. att skulder saknas eller att föregående års resultat inte är disponerat.
Om du fortsätter bokförs differensen på konto 2099 (Årets resultat), vilket
nästan alltid blir fel. Vi rekommenderar att du rättar exporten i källsystemet
och laddar upp filen på nytt.
</p>
</div>
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={ackImbalance}
onCheckedChange={(v) => setAckImbalance(v === true)}
className="mt-0.5"
/>
<span className="text-muted-foreground">
Jag förstår att differensen bokförs på 2099 och vill fortsätta ändå.
</span>
</label>
</div>
)}
</CardContent>
</Card>
@@ -343,7 +387,7 @@ export default function SIEPreviewStep({
<Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka
</Button>
<Button className="min-h-11" onClick={onContinue} disabled={hasBlockingErrors}>
<Button className="min-h-11" onClick={onContinue} disabled={blockContinue}>
{preview.mappingStatus.lowConfidence > 0 || preview.mappingStatus.unmapped > 0
? 'Granska mappningar'
: 'Fortsätt'}
+83 -3
View File
@@ -1,36 +1,56 @@
'use client'
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { CompanyProfileView } from '@/components/settings/CompanyProfileView'
import { refreshCompanyProfileAction } from '@/lib/company/tic-refresh'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
type Snapshot = Parameters<typeof CompanyProfileView>[0]['snapshot']
const ERROR_MESSAGES: Record<string, string> = {
org_number_invalid: 'Ogiltigt organisations- eller personnummer.',
not_found: 'Inga bolagsuppgifter hittades för det numret.',
unauthorized: 'Du har inte behörighet att hämta uppgifter.',
persist_failed: 'Något gick fel. Försök igen.',
}
// Företagsprofil — the cached TIC company snapshot (Bolagsuppgifter), rendered
// as a read-only section on the Företag tab. Fetched client-side (low-traffic
// settings) so it sits alongside the client-rendered company form. RLS scopes
// the read to the user's own company.
// the read to the user's own company. The "Hämta" form lets the user (re)fetch
// live when the snapshot is missing or wrong — the recovery path for an enskild
// firma whose personnummer previously resolved to the wrong entity.
export function CompanyProfileSection() {
const { company } = useCompany()
const [snapshot, setSnapshot] = useState<Snapshot>(null)
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [orgInput, setOrgInput] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!company?.id) return
const supabase = createClient()
let cancelled = false
supabase
.from('companies')
.select('tic_snapshot, tic_snapshot_fetched_at')
.select('tic_snapshot, tic_snapshot_fetched_at, org_number')
.eq('id', company.id)
.maybeSingle()
.then(({ data }) => {
if (cancelled) return
setSnapshot((data?.tic_snapshot as Snapshot) ?? null)
setFetchedAt((data?.tic_snapshot_fetched_at as string | null) ?? null)
setOrgInput((data?.org_number as string | null) ?? '')
setLoading(false)
})
return () => {
@@ -38,7 +58,67 @@ export function CompanyProfileSection() {
}
}, [company?.id])
async function handleFetch(e: React.FormEvent) {
e.preventDefault()
if (!company?.id || submitting) return
setSubmitting(true)
setError(null)
const result = await refreshCompanyProfileAction(company.id, orgInput)
if (result.ok) {
setSnapshot((result.snapshot as Snapshot) ?? null)
setFetchedAt(result.fetchedAt ?? null)
} else {
setError(ERROR_MESSAGES[result.error ?? ''] ?? ERROR_MESSAGES.persist_failed)
}
setSubmitting(false)
}
if (loading) return <Skeleton className="h-48 w-full rounded-lg" />
return <CompanyProfileView snapshot={snapshot} fetchedAt={fetchedAt} />
return (
<div className="space-y-4">
<CompanyProfileView snapshot={snapshot} fetchedAt={fetchedAt} />
<Card>
<CardHeader>
<CardTitle className="text-base">
{snapshot ? 'Uppdatera bolagsuppgifter' : 'Hämta bolagsuppgifter'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleFetch} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tic_org_number">Organisationsnummer eller personnummer</Label>
<div className="flex gap-2">
<Input
id="tic_org_number"
value={orgInput}
onChange={(e) => setOrgInput(e.target.value)}
placeholder="XXXXXX-XXXX"
inputMode="numeric"
autoComplete="off"
className="max-w-xs tabular-nums"
/>
<Button type="submit" disabled={submitting || !orgInput.trim()}>
{submitting ? (
<>
<Loader2 className="animate-spin" />
Hämtar…
</>
) : (
'Hämta'
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Uppgifterna hämtas från Bolagsverket. För enskild firma anges
personnumret.
</p>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</form>
</CardContent>
</Card>
</div>
)
}
+102 -2
View File
@@ -5,7 +5,13 @@ import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Plus } from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Lock, Unlock, Loader2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import type { FiscalPeriod } from '@/types'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
@@ -26,10 +32,18 @@ const STATUS_VARIANT: Record<'closed' | 'locked' | 'open', 'secondary' | 'warnin
export function FiscalYearsManager() {
const t = useTranslations('settings_bookkeeping')
const { toast } = useToast()
const { role } = useCompany()
const { dialogProps, confirm } = useDestructiveConfirm()
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [mutatingId, setMutatingId] = useState<string | null>(null)
// Only owners/admins may change a period's lock state. The API enforces this
// too (requireWrite); this just hides controls a viewer/member can't use.
const canManage = role === 'owner' || role === 'admin'
const fetchPeriods = useCallback(async () => {
try {
@@ -50,6 +64,53 @@ export function FiscalYearsManager() {
// Newest first — matches the API's ordering and reads most-recent-at-top.
const sorted = [...periods].sort((a, b) => b.period_start.localeCompare(a.period_start))
async function runLockAction(period: FiscalPeriod, action: 'lock' | 'unlock') {
setMutatingId(period.id)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}/${action}`, {
method: 'POST',
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
// Surface the backend's message verbatim — e.g. "X affärstransaktion(er)
// saknar bokföring", which tells the user exactly what to fix first.
throw new Error(body?.error?.message || t('fy_action_error'))
}
toast({ title: action === 'lock' ? t('fy_lock_success') : t('fy_unlock_success') })
await fetchPeriods()
} catch (err) {
toast({
title: t('fy_action_error'),
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setMutatingId(null)
}
}
async function handleLock(period: FiscalPeriod) {
const ok = await confirm({
title: t('fy_lock_confirm_title'),
description: t('fy_lock_confirm_body', { name: period.name }),
confirmLabel: t('fy_action_lock'),
cancelLabel: t('fy_confirm_cancel'),
variant: 'warning',
})
if (ok) await runLockAction(period, 'lock')
}
async function handleUnlock(period: FiscalPeriod) {
const ok = await confirm({
title: t('fy_unlock_confirm_title'),
description: t('fy_unlock_confirm_body', { name: period.name }),
confirmLabel: t('fy_action_unlock'),
cancelLabel: t('fy_confirm_cancel'),
variant: 'warning',
})
if (ok) await runLockAction(period, 'unlock')
}
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-4">
@@ -82,6 +143,7 @@ export function FiscalYearsManager() {
<div className="divide-y divide-border">
{sorted.map((p) => {
const status = periodStatus(p)
const isMutating = mutatingId === p.id
return (
<div key={p.id} className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
@@ -90,7 +152,43 @@ export function FiscalYearsManager() {
{formatDate(p.period_start)} – {formatDate(p.period_end)}
</span>
</div>
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
<div className="flex items-center gap-3 shrink-0">
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
{canManage && status === 'open' && (
<Button
variant="outline"
size="sm"
disabled={isMutating}
onClick={() => handleLock(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Lock className="mr-1.5 h-4 w-4" />
{t('fy_action_lock')}
</>
)}
</Button>
)}
{canManage && status === 'locked' && (
<Button
variant="ghost"
size="sm"
disabled={isMutating}
onClick={() => handleUnlock(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Unlock className="mr-1.5 h-4 w-4" />
{t('fy_action_unlock')}
</>
)}
</Button>
)}
</div>
</div>
)
})}
@@ -104,6 +202,8 @@ export function FiscalYearsManager() {
periods={periods}
onCreated={fetchPeriods}
/>
<DestructiveConfirmDialog {...dialogProps} />
</section>
)
}
+10 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { useRouter } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import {
@@ -22,6 +22,7 @@ import { SettingsShell } from './SettingsShell'
*/
export function SettingsModal({ sectionId }: { sectionId?: string }) {
const router = useRouter()
const pathname = usePathname()
const { company } = useCompany()
const t = useTranslations('settings_modal')
@@ -38,6 +39,14 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
if (!open) router.back()
}
// Parallel-route safety net. This modal lives in the @settingsModal slot and
// should only ever show on /settings/* routes. On a soft navigation to a
// non-settings route (e.g. a cross-link inside the modal like "Kontoplan"),
// Next.js can keep this intercepted slot mounted over the new page. Once the
// URL is no longer a settings route, render nothing so those links actually
// leave the modal instead of appearing to do nothing.
if (!pathname.startsWith('/settings')) return null
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent
@@ -172,14 +172,7 @@ export function BookkeepingSettingsContent() {
</h2>
<div className="flex flex-col gap-2">
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('related_fiscal_year')}
</Link>
<Link
href="/bookkeeping"
href="/bookkeeping?tab=accounts"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
+18 -41
View File
@@ -14,6 +14,7 @@ import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
@@ -68,7 +69,6 @@ export default function QuickReviewDialog({
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
const [isOpeningDoc, setIsOpeningDoc] = useState(false)
// Mirror of `transaction` so we can patch in a freshly-fetched SEK conversion
// before the user confirms — the verifikation must always be in SEK and the
// engine reads these fields straight off the transaction row.
@@ -78,24 +78,6 @@ export default function QuickReviewDialog({
const preAttachedDocumentId = transaction?.document_id ?? null
const handleOpenAttachedDoc = useCallback(async () => {
if (!preAttachedDocumentId || isOpeningDoc) return
setIsOpeningDoc(true)
try {
const res = await fetch(`/api/documents/${preAttachedDocumentId}`)
if (!res.ok) {
toast({ title: t('open_attached_failed'), variant: 'destructive' })
return
}
const { data } = await res.json()
if (data?.download_url) {
window.open(data.download_url, '_blank', 'noopener,noreferrer')
}
} finally {
setIsOpeningDoc(false)
}
}, [preAttachedDocumentId, isOpeningDoc, toast, t])
// Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
setAccountOverride(account)
@@ -248,7 +230,7 @@ export default function QuickReviewDialog({
}
onOpenChange(o)
}}>
<DialogContent className="max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogContent className={preAttachedDocumentId ? 'max-w-6xl max-h-[90vh] overflow-y-auto' : 'max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto'}>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
@@ -256,6 +238,17 @@ export default function QuickReviewDialog({
</DialogDescription>
</DialogHeader>
{/* When a document is pre-attached, show it side-by-side (receipt left,
review right). With no document the wrappers use display:contents so
the dialog collapses to the original single-column layout. */}
<div className={preAttachedDocumentId ? 'grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]' : 'contents'}>
{preAttachedDocumentId && (
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
<DocumentViewerPane documentId={preAttachedDocumentId} className="h-full" />
</div>
)}
<div className={preAttachedDocumentId ? 'space-y-4' : 'contents'}>
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
@@ -433,27 +426,9 @@ export default function QuickReviewDialog({
</>
)}
{/* Document — either show the doc the inbox attached pre-categorize,
or let the user upload one if none is attached yet. */}
{preAttachedDocumentId ? (
<div className="rounded-lg border flex items-center justify-between px-3 py-2.5 text-sm">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium">{t('attached_doc_label')}</span>
<span className="text-xs text-muted-foreground truncate">
{t('attached_doc_source')}
</span>
</div>
<button
type="button"
onClick={handleOpenAttachedDoc}
disabled={isOpeningDoc}
className="text-xs text-primary hover:underline shrink-0"
>
{isOpeningDoc ? t('opening') : t('view')}
</button>
</div>
) : (
{/* No pre-attached document — let the user upload one. (When a document
IS pre-attached it's shown in the left preview column instead.) */}
{!preAttachedDocumentId && (
<div className="rounded-lg border">
<button
type="button"
@@ -517,6 +492,8 @@ export default function QuickReviewDialog({
{isProcessing ? t('booking') : rateLoading ? t('fetching_rate') : t('book')}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
@@ -4,11 +4,11 @@ import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, FileText, Inbox, Paperclip, X } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, FileText, Inbox, X } from 'lucide-react'
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
@@ -115,7 +115,6 @@ export default function TransactionBookingDialog({
const { toast } = useToast()
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
const [bankAccount, setBankAccount] = useState<string | null>(null)
@@ -215,24 +214,32 @@ export default function TransactionBookingDialog({
setUploadedFiles([])
setPickedInboxDocs([])
setShowUploadZone(false)
onBooked(transactionId, journalEntryId, pinnedDocId)
}
const attachedCount =
uploadedFiles.filter((f) => f.status === 'uploaded').length + pickedInboxDocs.length
// The receipt to show beside the form. A transaction may arrive with a
// pre-linked document; otherwise the user attaches one in-dialog (upload or
// inbox pick) and it appears here as soon as it's available.
const uploadedDoc = uploadedFiles.find((f) => f.status === 'uploaded' && f.id)
const pickedDoc = pickedInboxDocs[0]
const preexistingDocId = transaction.document_id ?? null
const inDialogDocId = uploadedDoc?.id ?? pickedDoc?.document_id ?? null
const currentDocId = preexistingDocId ?? inDialogDocId
const currentDocMime = preexistingDocId ? null : uploadedDoc?.file.type ?? null
const currentDocName = preexistingDocId
? null
: uploadedDoc?.fileName ?? pickedDoc?.file_name ?? null
return (
<Dialog open={open} onOpenChange={(o) => {
if (!o) {
setUploadedFiles([])
setPickedInboxDocs([])
setShowUploadZone(false)
setInboxPickerOpen(false)
}
onOpenChange(o)
}}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
@@ -265,99 +272,108 @@ export default function TransactionBookingDialog({
</p>
</div>
{/* Document upload section */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{t('doc_label')}</span>
{attachedCount > 0 && (
<span className="text-xs text-muted-foreground">
{t('doc_attached_count', { count: attachedCount })}
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3 space-y-2">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]">
{/* Document column — sticky on desktop so the receipt stays visible
while the form scrolls; stacks above the form on smaller screens. */}
<div className="flex h-[45vh] flex-col gap-3 lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
{currentDocId ? (
<DocumentViewerPane
documentId={currentDocId}
mime={currentDocMime}
fileName={currentDocName}
className="min-h-0 flex-1"
/>
{pickedInboxDocs.length > 0 && (
<div className="space-y-1">
{pickedInboxDocs.map((doc) => (
<div
key={doc.document_id}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">
{doc.supplier_name ?? doc.file_name}
</span>
{doc.amount != null && (
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{formatCurrency(doc.amount, doc.currency ?? 'SEK')}
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
aria-label={t('doc_picked_remove')}
onClick={() =>
setPickedInboxDocs((prev) =>
prev.filter((d) => d.document_id !== doc.document_id),
)
}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setInboxPickerOpen(true)}
>
<Inbox className="h-4 w-4 mr-2" />
{t('doc_pick_existing')}
</Button>
</div>
)}
</div>
) : (
<div className="min-h-0 flex-1">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
/>
</div>
)}
{bankAccount !== null && (
<JournalEntryForm
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}-${bankAccount}`}
embedded
initialLines={
preselectedTemplate
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
: buildInitialLines(transaction, t('bank_line_description'), bankAccount)
}
initialDate={transaction.date}
initialDescription={transaction.description}
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
/>
)}
{/* Attach controls — only when the transaction has no pre-linked
document (a pre-linked one is already the verifikat's underlag). */}
{!preexistingDocId && (
<div className="shrink-0 space-y-2">
{pickedInboxDocs.length > 0 && (
<div className="space-y-1">
{pickedInboxDocs.map((doc) => (
<div
key={doc.document_id}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">
{doc.supplier_name ?? doc.file_name}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
aria-label={t('doc_picked_remove')}
onClick={() =>
setPickedInboxDocs((prev) =>
prev.filter((d) => d.document_id !== doc.document_id),
)
}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setInboxPickerOpen(true)}
>
<Inbox className="h-4 w-4 mr-2" />
{t('doc_pick_existing')}
</Button>
{inDialogDocId && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setUploadedFiles([])
setPickedInboxDocs([])
}}
>
<X className="h-3.5 w-3.5 mr-1.5" />
{t('doc_clear')}
</Button>
)}
</div>
</div>
)}
</div>
{/* Booking form */}
<div className="space-y-4">
{bankAccount !== null && (
<JournalEntryForm
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}-${bankAccount}`}
embedded
initialLines={
preselectedTemplate
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
: buildInitialLines(transaction, t('bank_line_description'), bankAccount)
}
initialDate={transaction.date}
initialDescription={transaction.description}
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
/>
)}
</div>
</div>
<InboxDocumentPicker
open={inboxPickerOpen}
+14 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { ReactNode } from 'react'
import { ReactNode, useRef } from 'react'
import {
Dialog,
DialogContent,
@@ -22,6 +22,9 @@ interface ConfirmationDialogProps {
confirmLabel?: string
extraActions?: ReactNode
children: ReactNode
// When true, initial focus lands on the confirm button so Enter fires the
// primary action. Opt-in: never arm Enter on unrelated/destructive dialogs.
autoFocusConfirm?: boolean
}
export function ConfirmationDialog({
@@ -34,10 +37,18 @@ export function ConfirmationDialog({
confirmLabel = 'Bekräfta & skapa',
extraActions,
children,
autoFocusConfirm,
}: ConfirmationDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl border-t-2 border-primary p-0 gap-0 max-h-[95dvh] sm:max-h-[90dvh] flex flex-col">
<DialogContent
className="sm:max-w-2xl border-t-2 border-primary p-0 gap-0 max-h-[95dvh] sm:max-h-[90dvh] flex flex-col"
onOpenAutoFocus={autoFocusConfirm ? (e) => {
e.preventDefault()
confirmRef.current?.focus()
} : undefined}
>
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 shrink-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 shrink-0">
@@ -72,7 +83,7 @@ export function ConfirmationDialog({
Tillbaka
</Button>
{extraActions}
<Button onClick={onConfirm} disabled={isSubmitting} className="min-h-11 w-full sm:w-auto">
<Button ref={confirmRef} onClick={onConfirm} disabled={isSubmitting} className="min-h-11 w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />