Files
accounted/app/(dashboard)/bookkeeping/[id]/page.tsx
T
Jakob WennbergandClaude Opus 5 bc5c673f7d fix(ui): reach touch-only actions, confirm before posting, drop a dead ring (#1280)
* fix(ui): reach touch-only actions, confirm before posting, drop a dead ring

Three defects from the UI craft audit where the interface is wrong, not
just inconsistent.

Unreachable on touch: 'Markera klar' on deadlines and the bulk-select
checkbox on /pending were 'opacity-0 group-hover:opacity-100'. Coarse
pointers never fire hover and DeadlineForm has no completion control, so
on a phone there was no way to mark a deadline done at all. Factored the
reveal into HOVER_REVEAL_CLASS, which adds pointer-coarse:opacity-100.

Unguarded ledger writes: 'Bokför' on the journal-entry detail page and on
the invoice detail page posted an immutable verifikat on one click, one
screen after the list confirmed the identical action. Both now open a
ConfirmDialog describing the outcome up front (convention 10), reusing
the list's indicative voucher-number prefetch.

Dead ring: SummaryCard emitted ring-1 ring-primary/40 and ring-1
ring-warning/40 on the same element, so the two set the same custom
property and one silently lost. The override is an exception, so it is a
Badge now (conventions 5 and 12).

Also corrects the design.md primitives table, which named ui/table.tsx as
the data-table primitive while 17 files use ui/dry-table.tsx; building a
list page from the documented row produced ~15% taller rows and a
different hover tint, which had already happened twice.

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

* fix(ui): let the post-confirm dialog close after a successful commit

ConfirmDialog calls onOpenChange(false) immediately after `await onConfirm()`
resolves, but handleCommit/handleBook clear their in-flight flag in a
`finally` block, so the guard's closure still saw isCommitting/isUpdating as
true and swallowed the close: the dialog would sit open over a booking that
had already succeeded.

The guard was redundant as well as wrong. ConfirmDialog already blocks
Radix-initiated closes while pending, via `onOpenChange={(next) => !pending
&& onOpenChange(next)}` on the Dialog itself. Passing the setter directly
matches every other dialog on both pages.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:33:36 +02:00

1221 lines
53 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AccountNumber } from '@/components/ui/account-number'
import { Textarea } from '@/components/ui/textarea'
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock, FileText, Link2, RotateCcw, Scissors, PenLine } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import CorrectOpeningBalanceDialog from '@/components/bookkeeping/CorrectOpeningBalanceDialog'
import StrikeLinesDialog from '@/components/bookkeeping/StrikeLinesDialog'
import CorrectMetadataDialog from '@/components/bookkeeping/CorrectMetadataDialog'
import EditDraftEntryDialog from '@/components/bookkeeping/EditDraftEntryDialog'
import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import CorrectionChain from '@/components/bookkeeping/CorrectionChain'
import RetagLineDialog, { type RetagLine } from '@/components/dimensions/RetagLineDialog'
import { useCompanySettings } from '@/components/settings/useSettings'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types'
import type { JournalEntry, JournalEntryLine } from '@/types'
import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references'
// Snapshot of a struck line, as stored in journal_entry_rattelse_log.
type StruckLineSnapshot = {
id: string
account_number: string
debit_amount: number | string
credit_amount: number | string
line_description: string | null
sort_order: number
}
type RattelseLogRow = {
id: string
rattelse_type: 'metadata' | 'lines'
old_description: string | null
new_description: string | null
old_entry_date: string | null
new_entry_date: string | null
struck_lines: StruckLineSnapshot[] | null
added_lines: StruckLineSnapshot[] | null
actor: string | null
created_at: string
}
export default function JournalEntryDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('journal_detail')
const sourceTypeLabels = useSourceTypeLabels()
const [entry, setEntry] = useState<JournalEntry | null>(null)
const [chain, setChain] = useState<JournalEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCorrection, setShowCorrection] = useState(false)
const [showCorrectIB, setShowCorrectIB] = useState(false)
const [showStrikeLines, setShowStrikeLines] = useState(false)
const [showCorrectMetadata, setShowCorrectMetadata] = useState(false)
const [rattelseLog, setRattelseLog] = useState<RattelseLogRow[]>([])
const [showEdit, setShowEdit] = useState(false)
const [showRecordate, setShowRecordate] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showReverseConfirm, setShowReverseConfirm] = useState(false)
const [isReversing, setIsReversing] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
// Confirm-before-posting (convention 10). The list already gates this exact
// action; the detail page used to fire the commit straight from the button.
const [showCommitConfirm, setShowCommitConfirm] = useState(false)
const [commitVoucherPreview, setCommitVoucherPreview] = useState<string | null>(null)
const [isLastInSeries, setIsLastInSeries] = useState(false)
const [attachmentCount, setAttachmentCount] = useState(0)
const [references, setReferences] = useState<UnderlagReference[]>([])
const [editingNotes, setEditingNotes] = useState(false)
const [notesValue, setNotesValue] = useState('')
const [savingNotes, setSavingNotes] = useState(false)
// Dimension registry, fetched once when any line carries a dimensions map:
// used to resolve display names for the line badges ('KS: Butik'); badges
// fall back to raw codes when the fetch fails or a code is unregistered.
const [registryDims, setRegistryDims] = useState<DimensionDto[] | null>(null)
// Tier-2 retro-tagging (dimensions plan PR6): pencil on posted lines opens
// the audited retag dialog; the log renders as a history disclosure below.
// Both render only when dimensions are enabled for the company.
const { settings } = useCompanySettings()
const dimensionsEnabled = settings?.dimensions_enabled === true
const [retagLine, setRetagLine] = useState<RetagLine | null>(null)
const [retagLog, setRetagLog] = useState<
{ id: string; line_id: string; old_dimensions: Record<string, string>; new_dimensions: Record<string, string>; reason: string; created_at: string }[]
>([])
useEffect(() => {
if (registryDims !== null) return
const entryLines = (entry?.lines || []) as JournalEntryLine[]
if (!entryLines.some((l) => l.dimensions && Object.keys(l.dimensions).length > 0)) return
let cancelled = false
fetchDimensions()
.then((dims) => {
if (!cancelled) setRegistryDims(dims)
})
.catch(() => {/* display-only, raw codes are fine */})
return () => {
cancelled = true
}
}, [entry, registryDims])
const fetchData = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const [chainRes, refsRes, retagRes, rattelseRes] = await Promise.all([
fetch(`/api/bookkeeping/journal-entries/${id}/chain`),
fetch(`/api/bookkeeping/journal-entries/${id}/references`),
fetch(`/api/bookkeeping/journal-entries/${id}/retag-log`),
fetch(`/api/bookkeeping/journal-entries/${id}/rattelse-log`),
])
if (retagRes.ok) {
const retagPayload = await retagRes.json()
setRetagLog(Array.isArray(retagPayload.data) ? retagPayload.data : [])
}
if (rattelseRes.ok) {
const rattelsePayload = await rattelseRes.json()
setRattelseLog(Array.isArray(rattelsePayload.data) ? rattelsePayload.data : [])
}
if (!chainRes.ok) {
const { error: msg } = await chainRes.json()
setError(msg || t('error_load_failed'))
return
}
const { data } = await chainRes.json()
setEntry(data.entry)
setChain(data.chain)
setIsLastInSeries(data.is_last_in_series ?? false)
// Underlag references (linked invoices), best-effort; the verifikat still
// renders if this fails, it just falls back to documents-only.
if (refsRes.ok) {
const { data: refData } = await refsRes.json()
setReferences(refData?.references ?? [])
} else {
setReferences([])
}
} catch {
setError(t('error_load_failed'))
} finally {
setIsLoading(false)
}
}, [id, t])
const saveNotes = useCallback(async (value: string) => {
setSavingNotes(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/notes`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: value || null }),
})
if (res.ok) {
setEntry(prev => prev ? { ...prev, notes: value || null } : prev)
setEditingNotes(false)
} else {
toast({ title: t('toast_save_note_failed'), variant: 'destructive' })
}
} catch {
toast({ title: t('toast_save_note_failed'), variant: 'destructive' })
} finally {
setSavingNotes(false)
}
}, [id, toast, t])
// Open the confirm dialog and fetch the predicted voucher number. The
// prediction is indicative (numbers are assigned atomically at commit); the
// success toast always shows the real one. Mirrors JournalEntryList.
const openCommitConfirm = useCallback(() => {
setShowCommitConfirm(true)
setCommitVoucherPreview(null)
fetch('/api/bookkeeping/voucher-sequences/next')
.then((r) => r.json())
.then(({ data }) => {
if (data?.next != null) setCommitVoucherPreview(`${data.series}${data.next}`)
})
.catch(() => {})
}, [])
const handleCommit = useCallback(async () => {
setIsCommitting(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/commit`, { method: 'POST' })
const result = await res.json()
if (res.ok) {
const posted = result.data
toast({
title: t('toast_posted_title'),
description: t('toast_posted_description', { voucher: formatVoucher(posted ?? {}) }),
})
await fetchData()
} else {
toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
}
} catch {
toast({ title: t('toast_post_failed_generic'), variant: 'destructive' })
} finally {
setIsCommitting(false)
}
}, [id, toast, fetchData, t])
const handleDelete = useCallback(async () => {
setIsDeleting(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${id}`, { method: 'DELETE' })
const result = await res.json()
if (res.ok) {
const wasDraft = result.data?.was_draft === true
toast({
title: wasDraft ? t('toast_delete_draft_title') : t('toast_delete_entry_title'),
description: wasDraft
? t('toast_delete_draft_description')
: t('toast_delete_entry_description', { voucher: formatVoucher(result.data ?? {}) }),
})
router.push('/bookkeeping')
} else {
toast({ title: t('toast_delete_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
setShowDeleteConfirm(false)
}
} catch {
toast({ title: t('toast_delete_failed_generic'), variant: 'destructive' })
setShowDeleteConfirm(false)
} finally {
setIsDeleting(false)
}
}, [id, router, toast, t])
// Pure reversal (storno): cancels the verifikat with a stornoverifikation and
// no replacement, per BFL 5 kap 5§. Distinct from "Rätta", which always books
// a replacement entry. Routes through the engine's reverseEntry (storno +
// reverses_id link; original → 'reversed', never deleted).
const handleReverse = useCallback(async () => {
setIsReversing(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/reverse`, { method: 'POST' })
const result = await res.json()
if (res.ok) {
const storno = result.data
toast({
title: t('toast_reverse_done_title'),
description: t('toast_reverse_done_description', { voucher: formatVoucher(storno ?? {}) }),
})
setShowReverseConfirm(false)
await fetchData()
} else {
toast({ title: t('toast_reverse_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
}
} catch {
toast({ title: t('toast_reverse_failed'), variant: 'destructive' })
} finally {
setIsReversing(false)
}
}, [id, toast, fetchData, t])
useEffect(() => {
fetchData()
}, [fetchData])
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</div>
)
}
if (error || !entry) {
return (
<div className="space-y-4">
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</Link>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">{error || t('error_not_found')}</p>
</CardContent>
</Card>
</div>
)
}
const lines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
const totalDebit = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
const totalCredit = lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
const foreignLines = lines.filter(l => l.currency && l.currency !== 'SEK' && l.amount_in_currency != null)
const hasForeignCurrency = foreignLines.length > 0
// For the summary: use the first foreign line's data (the settlement line)
const foreignCurrency = hasForeignCurrency ? foreignLines[0].currency! : null
const foreignTotal = hasForeignCurrency ? Math.abs(Number(foreignLines[0].amount_in_currency) || 0) : 0
const foreignExchangeRate = hasForeignCurrency ? (Number(foreignLines[0].exchange_rate) || null) : null
// A correction is itself a regular posted verifikation and can be corrected
// again (BFL 5 kap. 5 §, the chain just grows). Storno entries are pure
// reversals and cannot be corrected directly; the user walks to the latest
// correction (or the original) and corrects that one.
const canCorrect = entry.status === 'posted' && entry.source_type !== 'storno'
// Inline rättelse (strike lines in the same verifikat) keeps structural
// entry types on their dedicated flows: storno mirrors its original, IB
// feeds opening_balance_entry_id, year-end feeds dispositions. The RPC
// enforces the same rule server-side; this just hides the dead menu item.
const canInlineRattelse =
entry.status === 'posted' &&
!['storno', 'opening_balance', 'year_end', 'vat_settlement'].includes(entry.source_type)
// Struck lines (from the immutable rättelse log) render inline in the
// lines table with strikethrough, Fortnox-style: the original stays
// visible per BFL 5 kap 5 § even though it no longer counts.
const struckDisplayLines = rattelseLog
.filter((r) => r.rattelse_type === 'lines')
.flatMap((r) =>
(r.struck_lines ?? []).map((s) => ({ ...s, struck_at: r.created_at }))
)
// Live and struck lines interleaved by original position.
const displayRows: Array<
| { kind: 'live'; line: JournalEntryLine }
| { kind: 'struck'; line: StruckLineSnapshot & { struck_at: string } }
> = [
...lines.map((l) => ({ kind: 'live' as const, line: l })),
...struckDisplayLines.map((s) => ({ kind: 'struck' as const, line: s })),
].sort(
(a, b) =>
(a.line.sort_order ?? 0) - (b.line.sort_order ?? 0) ||
// On a sort_order tie the struck original renders above its replacement.
(a.kind === 'struck' ? -1 : 0) - (b.kind === 'struck' ? -1 : 0)
)
// An opening-balance verifikat must be corrected through the IB-aware flow
// (storno + rebook + relink the period's opening_balance_entry_id), never the
// generic "Rätta rader": that books a `correction` entry but leaves the
// period pointing at the stornoed IB, so the Balansrapport "Ingående balans"
// column goes stale. Only surface it on the *active* IB (posted; stornoed
// predecessors are `reversed`, so exactly one posted IB exists per period).
const isOpeningBalance = entry.source_type === 'opening_balance' && entry.status === 'posted'
// Include current entry in the chain for the visualization
const fullChain = [entry, ...chain]
// SIE dimension badge prefixes. 'KS' is the market-standard abbreviation
// for kostnadsställe; projekt has no standard abbreviation (Fortnox/Visma
// show the dimension name, and 'PR' collides with prisnivå in some BAS
// setups, flagged in the #859 compliance review), so dim 6 falls through
// to the registry name below. Stays Swedish per .claude/rules/i18n.md.
const DIM_BADGE_PREFIX: Record<string, string> = { '1': 'KS' }
// Display-only dimension badges for a line (e.g. 'KS: Butik', 'PR: P001').
// Names resolve through the registry when loaded; raw codes otherwise.
const renderDimensionBadges = (line: JournalEntryLine) => {
const entries = Object.entries(line.dimensions ?? {})
.filter(([, code]) => code)
.sort(([a], [b]) => Number(a) - Number(b))
if (entries.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1">
{entries.map(([dimNo, code]) => {
const dim = registryDims?.find((d) => String(d.sie_dim_no) === dimNo)
const value = dim?.values.find((v) => v.code === code)
const prefix = DIM_BADGE_PREFIX[dimNo] ?? dim?.name ?? `Dim ${dimNo}`
const hasName = !!value && value.name !== '' && value.name !== value.code
return (
<Badge
key={dimNo}
variant="outline"
className="font-mono text-[11px] font-normal"
title={`${dim?.name ?? prefix} ${code}${hasName ? `: ${value.name}` : ''}`}
>
{prefix}: {hasName ? value.name : code}
</Badge>
)
})}
</div>
)
}
return (
<div className="space-y-8">
{/* Back link */}
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</Link>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl leading-8 tracking-tight font-mono">
{formatVoucher(entry)}
</h1>
<JournalEntryStatusBadge entry={entry} />
{rattelseLog.length > 0 && (
<Badge variant="outline" className="text-xs font-normal" title={t('rattelse_history_title')}>
Rättad
</Badge>
)}
</div>
<p className="text-muted-foreground">{entry.description}</p>
</div>
{(entry.status === 'posted' || entry.status === 'draft') && (
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
{entry.status === 'draft' && (
<AgentSparkleButton
intentId="verifikation.draft"
intentArgs={{ journal_entry_id: id }}
contextRef={`verifikation:${id}`}
className="w-full sm:w-auto"
/>
)}
{entry.status === 'draft' && (
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => setShowEdit(true)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Pencil className="mr-2 h-4 w-4" />}
{t('edit_draft')}
</Button>
)}
{entry.status === 'draft' && (
<Button
size="sm"
className="w-full sm:w-auto"
onClick={openCommitConfirm}
disabled={!canWrite || isCommitting}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isCommitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('post')}
</Button>
)}
{(entry.status === 'draft' || isLastInSeries) && (
<Button
variant="destructive"
size="sm"
className="w-full sm:w-auto"
onClick={() => setShowDeleteConfirm(true)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
{entry.status === 'draft' ? t('delete_draft') : t('delete_entry')}
</Button>
)}
{canCorrect && !isOpeningBalance && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Pencil className="mr-2 h-4 w-4" />}
{t('correct_menu')}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canInlineRattelse && (
<DropdownMenuItem onClick={() => setShowStrikeLines(true)}>
<Scissors className="mr-2 h-4 w-4" />
{t('strike_lines')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setShowCorrectMetadata(true)}>
<PenLine className="mr-2 h-4 w-4" />
{t('correct_metadata')}
</DropdownMenuItem>
{canInlineRattelse && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={() => setShowCorrection(true)}>
<Pencil className="mr-2 h-4 w-4" />
{t('correct_lines')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowRecordate(true)}>
<CalendarClock className="mr-2 h-4 w-4" />
{t('correct_date')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowReverseConfirm(true)}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('reverse_action')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{canCorrect && isOpeningBalance && (
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => setShowCorrectIB(true)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Pencil className="mr-2 h-4 w-4" />}
{t('correct_opening_balances')}
</Button>
)}
{/* Copy is not status-gated: it only prefills a fresh manual draft
(no voucher number, date or attachments carried over), so it is
offered on drafts too, matching the list surfaces. */}
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
{t('copy_entry')}
</Button>
</div>
)}
</div>
{/* Info cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{t('details_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('field_date')}</span>
<span>{formatDate(entry.entry_date)}</span>
</div>
{entry.committed_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('field_posted_at')}</span>
<span>{formatDate(entry.committed_at)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">{t('field_type')}</span>
<span>{sourceTypeLabels[entry.source_type] || entry.source_type}</span>
</div>
{entry.source_voucher_series && entry.source_voucher_number != null && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('field_source_voucher')}</span>
<span className="font-mono tabular-nums">
{formatVoucher({ voucher_series: entry.source_voucher_series, voucher_number: entry.source_voucher_number })}
</span>
</div>
)}
{/* Notes: always editable (internal metadata, not BFL verifikation content) */}
<div className="border-t pt-2 mt-2">
<div className="flex items-center justify-between mb-1">
<span className="text-muted-foreground flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{t('field_note')}
</span>
{!editingNotes && canWrite && (
<Button
variant="ghost"
size="icon"
onClick={() => { setNotesValue(entry.notes || ''); setEditingNotes(true) }}
aria-label={t('edit_note_aria')}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
</div>
{editingNotes ? (
<div className="space-y-2">
<Textarea
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
placeholder={t('note_placeholder')}
className="resize-none text-sm"
rows={3}
maxLength={2000}
autoFocus
/>
<div className="flex gap-1 justify-end">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => setEditingNotes(false)}
disabled={savingNotes}
>
<X className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
className="h-7 px-2"
onClick={() => saveNotes(notesValue)}
disabled={savingNotes}
>
{savingNotes ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground italic">
{entry.notes || t('no_note')}
</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{t('summary_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('summary_debit')}</span>
<span className="tabular-nums font-medium">
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('summary_credit')}</span>
<span className="tabular-nums font-medium">
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('summary_lines')}</span>
<span>{lines.length}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{t('attachments_title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm">
{attachmentCount === 0 && references.length === 0 ? (
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warning-foreground" />
<span className="text-muted-foreground">{t('no_attachments')}</span>
</div>
) : (
<div className="space-y-1">
{attachmentCount > 0 && (
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span>{t('attachments_count', { count: attachmentCount })}</span>
</div>
)}
{references.length > 0 && (
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-muted-foreground" />
<span>{t('references_count', { count: references.length })}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Foreign-currency conversion audit chip */}
{hasForeignCurrency && foreignCurrency && (
<Card>
<CardContent className="p-4">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
{t('currency_title')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div className="flex justify-between sm:block">
<span className="text-muted-foreground">{t('currency_rate')}</span>
<span className="tabular-nums sm:block">
{foreignExchangeRate
? `1 ${foreignCurrency} = ${foreignExchangeRate.toLocaleString('sv-SE', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} SEK`
: '-'}
</span>
</div>
<div className="flex justify-between sm:block">
<span className="text-muted-foreground">{t('currency_original_amount')}</span>
<span className="tabular-nums sm:block">
{foreignTotal.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {foreignCurrency}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Lines table */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{t('lines_title')}</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop table */}
<div className="hidden sm:block">
<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 text-left">
<th className="py-2 w-48">{t('col_account')}</th>
<th className="py-2">{t('col_description')}</th>
<th className="py-2 w-28 text-right">{t('col_debit')}</th>
<th className="py-2 w-28 text-right">{t('col_credit')}</th>
</tr>
</thead>
<tbody>
{displayRows.map((row) => {
if (row.kind === 'struck') {
const s = row.line
return (
<tr key={`struck-${s.id}`} className="border-b last:border-0 text-muted-foreground">
<td className="py-2 line-through decoration-muted-foreground/70">
<AccountNumber number={s.account_number} showName />
</td>
<td className="py-2">
<span className="line-through decoration-muted-foreground/70">
{s.line_description || ''}
</span>
<span className="ml-2 no-underline text-xs">
{t('struck_marker', { date: formatDate(s.struck_at) })}
</span>
</td>
<td className="py-2 text-right tabular-nums line-through decoration-muted-foreground/70">
{Number(s.debit_amount) > 0 &&
Number(s.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td className="py-2 text-right tabular-nums line-through decoration-muted-foreground/70">
{Number(s.credit_amount) > 0 &&
Number(s.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
</tr>
)
}
const line = row.line
const hasForeignCurrency = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
return (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2"><AccountNumber number={line.account_number} showName /></td>
<td className="py-2 text-muted-foreground">
<span className="inline-flex items-center gap-1">
{line.line_description || ''}
{dimensionsEnabled && canWrite && entry.status === 'posted' && (
<button
type="button"
onClick={() => setRetagLine(line as unknown as RetagLine)}
className="p-1 rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary/60 transition-colors"
aria-label="Ändra dimensioner"
title="Ändra dimensioner (påverkar endast internredovisningen)"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</span>
{renderDimensionBadges(line)}
</td>
<td className="py-2 text-right tabular-nums">
{Number(line.debit_amount) > 0 && (
<>
{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{hasForeignCurrency && Number(line.debit_amount) > 0 && (
<span className="block text-xs text-muted-foreground">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</>
)}
</td>
<td className="py-2 text-right tabular-nums">
{Number(line.credit_amount) > 0 && (
<>
{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
{hasForeignCurrency && Number(line.credit_amount) > 0 && (
<span className="block text-xs text-muted-foreground">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</>
)}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">{t('sum')}</td>
<td className="py-2 text-right tabular-nums">
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td className="py-2 text-right tabular-nums">
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
</tr>
</tfoot>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{displayRows.map((row) => {
if (row.kind === 'struck') {
const s = row.line
return (
<div key={`struck-${s.id}`} className="flex items-center justify-between py-2 border-b last:border-0 gap-2 text-muted-foreground">
<div className="min-w-0 flex-1">
<div className="text-sm line-through decoration-muted-foreground/70">
<AccountNumber number={s.account_number} showName />
</div>
{s.line_description && (
<p className="text-xs truncate line-through decoration-muted-foreground/70">{s.line_description}</p>
)}
<p className="text-xs">{t('struck_marker', { date: formatDate(s.struck_at) })}</p>
</div>
<div className="text-right shrink-0 text-sm tabular-nums line-through decoration-muted-foreground/70">
{Number(s.debit_amount) > 0 && (
<p>{Number(s.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D</p>
)}
{Number(s.credit_amount) > 0 && (
<p>{Number(s.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K</p>
)}
</div>
</div>
)
}
const line = row.line
const hasForeignCurrency = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null
return (
<div key={line.id} className="flex items-center justify-between py-2 border-b last:border-0 gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm flex items-center gap-1">
<AccountNumber number={line.account_number} showName />
{dimensionsEnabled && canWrite && entry.status === 'posted' && (
<button
type="button"
onClick={() => setRetagLine(line as unknown as RetagLine)}
className="p-1 rounded text-muted-foreground/50 hover:text-foreground transition-colors"
aria-label="Ändra dimensioner"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{line.line_description && (
<p className="text-xs text-muted-foreground truncate">{line.line_description}</p>
)}
{renderDimensionBadges(line)}
</div>
<div className="text-right shrink-0 text-sm tabular-nums">
{Number(line.debit_amount) > 0 && (
<p>
{Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D
{hasForeignCurrency && (
<span className="block text-xs text-muted-foreground">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</p>
)}
{Number(line.credit_amount) > 0 && (
<p>
{Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K
{hasForeignCurrency && (
<span className="block text-xs text-muted-foreground">
{Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {line.currency}
</span>
)}
</p>
)}
</div>
</div>
)
})}
<div className="flex justify-between font-semibold text-sm pt-1">
<span>{t('sum')}</span>
<div className="flex gap-3 tabular-nums">
<span>D: {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
<span>K: {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Attachments */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{t('attachments_title')}</CardTitle>
</CardHeader>
<CardContent>
{references.length > 0 && (
<div className="mb-4 space-y-2">
<div>
<h4 className="text-sm font-medium">{t('references_title')}</h4>
<p className="text-xs text-muted-foreground">{t('references_subtitle')}</p>
</div>
<ul className="space-y-1">
{references.map((ref) => (
<li key={`${ref.type}-${ref.id}`}>
<Link
href={ref.type === 'invoice' ? `/invoices/${ref.id}` : `/supplier-invoices/${ref.id}`}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50 hover:bg-secondary/60 transition-colors"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">
{ref.type === 'invoice'
? t('reference_invoice', { number: ref.number })
: t('reference_supplier_invoice', { number: ref.number })}
</span>
</Link>
</li>
))}
</ul>
</div>
)}
<JournalEntryAttachments
journalEntryId={entry.id}
onCountChange={setAttachmentCount}
/>
</CardContent>
</Card>
{/* Correction chain */}
{chain.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{t('history_title')}</CardTitle>
</CardHeader>
<CardContent>
<CorrectionChain currentEntryId={id} chain={fullChain} />
</CardContent>
</Card>
)}
{/* Rättelsehistorik (BFL 5 kap 5 § / 9 §): the immutable who/when trail
behind inline rättelser. Stays Swedish (voucher detail surface). */}
{rattelseLog.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{t('rattelse_history_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{rattelseLog.map((row) => (
<div key={row.id} className="text-sm border-b last:border-0 pb-3 last:pb-0">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground tabular-nums">{formatDate(row.created_at)}</span>
<span className="text-xs text-muted-foreground">
{row.rattelse_type === 'metadata' ? t('rattelse_kind_metadata') : t('rattelse_kind_lines')}
</span>
</div>
{row.rattelse_type === 'metadata' ? (
<div className="space-y-0.5">
{row.old_description !== row.new_description && (
<p>
<span className="text-muted-foreground line-through">{row.old_description}</span>
{' → '}
<span>{row.new_description}</span>
</p>
)}
{row.old_entry_date !== row.new_entry_date && (
<p className="tabular-nums">
<span className="text-muted-foreground line-through">{formatDate(row.old_entry_date || '')}</span>
{' → '}
<span>{formatDate(row.new_entry_date || '')}</span>
</p>
)}
</div>
) : (
<div className="space-y-0.5">
{(row.struck_lines ?? []).map((s) => (
<p key={s.id} className="tabular-nums text-muted-foreground">
<span className="line-through decoration-muted-foreground/70">
{s.account_number}
{' '}
{Number(s.debit_amount) > 0
? `${Number(s.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D`
: `${Number(s.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K`}
</span>
</p>
))}
{(row.added_lines ?? []).map((a) => (
<p key={a.id} className="tabular-nums">
{a.account_number}
{' '}
{Number(a.debit_amount) > 0
? `${Number(a.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D`
: `${Number(a.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K`}
</p>
))}
</div>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* Dimension retag history (dimensions plan PR6): the immutable
before/after trail. Stays Swedish (voucher detail surface). */}
{dimensionsEnabled && retagLog.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Ändringshistorik för dimensioner</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{retagLog.map((row) => {
const lineForRow = lines.find((l) => l.id === row.line_id)
const fmt = (dims: Record<string, string>) => {
const entries = Object.entries(dims ?? {}).sort(([a], [b]) => Number(a) - Number(b))
return entries.length > 0 ? entries.map(([no, code]) => `${no}: ${code}`).join(', ') : '-'
}
return (
<div key={row.id} className="text-sm border-b last:border-0 pb-3 last:pb-0">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground tabular-nums">{formatDate(row.created_at)}</span>
{lineForRow && <AccountNumber number={lineForRow.account_number} />}
</div>
<p className="tabular-nums">
<span className="text-muted-foreground line-through">{fmt(row.old_dimensions)}</span>
{' → '}
<span>{fmt(row.new_dimensions)}</span>
</p>
<p className="text-xs text-muted-foreground">{row.reason}</p>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Retag dialog (Tier-2 retro-tagging) */}
<RetagLineDialog
open={retagLine !== null}
onOpenChange={(open) => {
if (!open) setRetagLine(null)
}}
line={retagLine}
onRetagged={fetchData}
/>
{/* Inline rättelse dialogs (strike lines / metadata) */}
{showStrikeLines && entry && (
<StrikeLinesDialog
entry={entry}
open={showStrikeLines}
onOpenChange={setShowStrikeLines}
onCorrected={() => {
setShowStrikeLines(false)
fetchData()
}}
/>
)}
{showCorrectMetadata && entry && (
<CorrectMetadataDialog
entry={entry}
open={showCorrectMetadata}
onOpenChange={setShowCorrectMetadata}
onCorrected={() => {
setShowCorrectMetadata(false)
fetchData()
}}
/>
)}
{/* Correction dialog */}
{showCorrection && entry && (
<CorrectionEntryDialog
entry={entry}
open={showCorrection}
onOpenChange={setShowCorrection}
onCorrected={() => {
setShowCorrection(false)
fetchData()
}}
/>
)}
{/* Opening-balance correction dialog: IB-aware (storno + rebook + relink) */}
{showCorrectIB && entry && (
<CorrectOpeningBalanceDialog
entry={entry}
open={showCorrectIB}
onOpenChange={setShowCorrectIB}
onCorrected={() => {
setShowCorrectIB(false)
fetchData()
}}
/>
)}
{/* Recordate (move to correct date) dialog */}
{showRecordate && entry && (
<RecordateEntryDialog
entry={entry}
open={showRecordate}
onOpenChange={setShowRecordate}
onMoved={() => {
setShowRecordate(false)
fetchData()
}}
/>
)}
{/* Edit draft dialog: drafts only; PATCHes the entry in place */}
{showEdit && entry && entry.status === 'draft' && (
<EditDraftEntryDialog
entry={entry}
open={showEdit}
onOpenChange={setShowEdit}
onUpdated={() => {
setShowEdit(false)
fetchData()
}}
/>
)}
{/* Confirm-before-posting for drafts (convention 10): describes the
outcome ("Bokförs som A-218 ...") before the commit runs, matching
the same action on the list. */}
<ConfirmDialog
open={showCommitConfirm}
onOpenChange={setShowCommitConfirm}
title={t('confirm_post_title')}
description={
commitVoucherPreview
? t('confirm_post_description', {
voucher: commitVoucherPreview,
description: entry?.description || '',
amount: formatCurrency(totalDebit),
})
: t('confirm_post_description_generic', { description: entry?.description || '' })
}
confirmLabel={t('post')}
onConfirm={handleCommit}
/>
{/* Delete confirmation dialog */}
<ConfirmationDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
onConfirm={handleDelete}
isSubmitting={isDeleting}
title={entry?.status === 'draft' ? t('delete_draft') : t('delete_entry')}
warningText={
entry?.status === 'draft'
? t('delete_warning_draft')
: t('delete_warning_entry', { voucher: entry ? formatVoucher(entry) : '' })
}
confirmLabel={t('delete_confirm_label')}
>
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<AlertTriangle className="h-5 w-5 text-destructive mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium mb-1">{t('delete_dialog_heading')}</p>
<p className="text-muted-foreground">
{entry?.status === 'draft' ? t('delete_dialog_draft_body') : t('delete_dialog_entry_body')}
</p>
</div>
</div>
</ConfirmationDialog>
{/* Reverse (storno) confirmation dialog */}
<ConfirmationDialog
open={showReverseConfirm}
onOpenChange={setShowReverseConfirm}
onConfirm={handleReverse}
isSubmitting={isReversing}
title={t('reverse_confirm_title')}
warningText={t('reverse_warning')}
confirmLabel={t('reverse_confirm_label')}
>
<div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
<RotateCcw className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium mb-1">{t('reverse_dialog_heading', { voucher: formatVoucher(entry) })}</p>
<p className="text-muted-foreground">{t('reverse_dialog_body')}</p>
</div>
</div>
</ConfirmationDialog>
</div>
)
}