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:
co-authored by
Claude Opus 4.8
parent
2da9c71eb3
commit
f63d3e3100
@@ -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
@@ -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':
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 momsdeklaration'
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 momsdeklaration'
|
||||
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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user