feat(dimensions): PR6 retro-tagging — audited retag carve-out, BulkTagWorkbench, staged MCP tool (#867)

* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool

Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.

Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).

retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).

Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.

UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).

MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence

- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
  P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
  guard) → 403, anything else → logged 500 with a generic message. No more
  substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
  naming the unselected counter-vouchers before apply (Srf U 14 gross
  reporting — one-legged retags silently skew project P&L; the banner alone
  was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
  the direct dialog/workbench path allows {} (human untags phantom codes,
  logged with reason), the MCP staged path rejects it (agents never
  bulk-clear history).

Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 17:02:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fb3fe82a56
commit 816b1769c8
25 changed files with 3493 additions and 4 deletions
+656
View File
@@ -0,0 +1,656 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { AlertTriangle, Loader2, Search, Tags, Undo2, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
DataList,
DataListEmpty,
DataListHeader,
DataListLoading,
DataListMeta,
DataListMetaSeparator,
DataListPrimary,
DataListRow,
} from '@/components/ui/data-list'
import { useToast } from '@/components/ui/use-toast'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency, formatDate } from '@/lib/utils'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
/** Flattened line DTO from GET /api/dimensions/tagging/lines. */
interface TaggingLine {
id: string
account_number: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string>
journal_entry_id: string
entry_date: string
voucher_number: number | null
voucher_series: string | null
description: string
reversed_by_id: string | null
reverses_id: string | null
fiscal_period_id: string
}
interface ApplyResult {
retagged: number
unchanged: number
failed: { line_id: string; error: string }[]
}
const ACCOUNT_RE = /^\d{4}$/
function dimensionLabel(sieDimNo: string): string {
if (sieDimNo === '1') return 'KS'
if (sieDimNo === '6') return 'Proj'
return `Dim ${sieDimNo}`
}
/** Stable grouping key for a dimensions map (sorted entries). */
function mapKey(dims: Record<string, string>): string {
return JSON.stringify(
Object.keys(dims)
.sort()
.map((k) => [k, dims[k]]),
)
}
/**
* Bulk retro-tagging workbench (dimensions plan PR6 §3, Retrofit UX): browse
* posted lines, select (shift-click ranges supported), pick KS/Projekt values
* and apply them through the audited retag RPC. Merge mode (default) layers
* the picked values onto each line's existing map; "Ersätt tagg" replaces the
* whole map — used to consolidate typo/phantom codes.
*
* Strings are hardcoded Swedish per the dimensions-surface convention
* (DimensionCombobox/LineDimensionFields): this operates directly on
* verifikat, a stays-Swedish surface per .claude/rules/i18n.md.
*/
export default function BulkTagWorkbench() {
const { toast } = useToast()
const { canWrite } = useCanWrite()
// Filter bar
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [accountFrom, setAccountFrom] = useState('')
const [accountTo, setAccountTo] = useState('')
const [text, setText] = useState('')
const [onlyUntagged, setOnlyUntagged] = useState(false)
// Result set (null = never fetched)
const [lines, setLines] = useState<TaggingLine[] | null>(null)
const [totalCapped, setTotalCapped] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Selection + apply panel
const [selected, setSelected] = useState<Set<string>>(new Set())
const anchorIndexRef = useRef<number | null>(null)
const [picked, setPicked] = useState<Record<string, string>>({})
const [replaceMode, setReplaceMode] = useState(false)
const [reason, setReason] = useState('')
const [isApplying, setIsApplying] = useState(false)
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
const loadLines = useCallback(async () => {
for (const [label, value] of [
['Konto från', accountFrom],
['Konto till', accountTo],
] as const) {
if (value && !ACCOUNT_RE.test(value)) {
toast({
title: 'Ogiltigt kontonummer',
description: `${label} måste vara exakt 4 siffror.`,
variant: 'destructive',
})
return
}
}
setIsLoading(true)
try {
const params = new URLSearchParams()
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (accountFrom) params.set('account_from', accountFrom)
if (accountTo) params.set('account_to', accountTo)
if (text.trim()) params.set('text', text.trim())
if (onlyUntagged) params.set('only_untagged', '1')
const res = await fetch(`/api/dimensions/tagging/lines?${params.toString()}`)
const json = await res.json().catch(() => null)
if (!res.ok) throw json ?? new Error()
setLines((json?.data?.lines ?? []) as TaggingLine[])
setTotalCapped(Boolean(json?.data?.total_capped))
setSelected(new Set())
setRowErrors({})
anchorIndexRef.current = null
} catch (err) {
toast({
title: 'Kunde inte hämta rader',
description: getErrorMessage(err, { locale: 'sv' }),
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [accountFrom, accountTo, dateFrom, dateTo, text, onlyUntagged, toast])
const toggleRow = useCallback(
(index: number, shiftKey: boolean) => {
if (!lines) return
setSelected((prev) => {
const next = new Set(prev)
const anchor = anchorIndexRef.current
if (shiftKey && anchor !== null && anchor !== index) {
// Range selection: the whole range takes the clicked row's NEW state.
const target = !prev.has(lines[index].id)
const [lo, hi] = anchor < index ? [anchor, index] : [index, anchor]
for (let i = lo; i <= hi; i++) {
if (target) next.add(lines[i].id)
else next.delete(lines[i].id)
}
} else if (next.has(lines[index].id)) {
next.delete(lines[index].id)
} else {
next.add(lines[index].id)
}
return next
})
anchorIndexRef.current = index
},
[lines],
)
const allSelected =
lines !== null && lines.length > 0 && lines.every((l) => selected.has(l.id))
const someSelected = lines !== null && lines.some((l) => selected.has(l.id))
const toggleAll = useCallback(() => {
if (!lines) return
setSelected(allSelected ? new Set() : new Set(lines.map((l) => l.id)))
anchorIndexRef.current = null
}, [lines, allSelected])
// Reversal-pair warning: a selected line whose entry is half of a storno
// pair, where the paired entry's lines are loaded but not (all) selected.
const missingPairLineIds = useMemo(() => {
if (!lines || selected.size === 0) return [] as string[]
const pairEntryIds = new Set<string>()
for (const line of lines) {
if (!selected.has(line.id)) continue
if (line.reversed_by_id) pairEntryIds.add(line.reversed_by_id)
if (line.reverses_id) pairEntryIds.add(line.reverses_id)
}
if (pairEntryIds.size === 0) return [] as string[]
return lines
.filter((l) => pairEntryIds.has(l.journal_entry_id) && !selected.has(l.id))
.map((l) => l.id)
}, [lines, selected])
// Voucher labels of the unselected counter-vouchers — the blocking
// confirmation names them so the skew risk is concrete (#867 review:
// Srf U 14 gross reporting; an asymmetric storno pair silently skews
// project P&L, so the advisory alone is not enough).
const missingPairVouchers = useMemo(() => {
if (!lines || missingPairLineIds.length === 0) return [] as string[]
const ids = new Set(missingPairLineIds)
const labels = new Set<string>()
for (const line of lines) {
if (ids.has(line.id)) {
labels.add(`${line.voucher_series ?? ''}${line.voucher_number ?? ''}`)
}
}
return [...labels]
}, [lines, missingPairLineIds])
const includeCounterVouchers = useCallback(() => {
setSelected((prev) => {
const next = new Set(prev)
for (const id of missingPairLineIds) next.add(id)
return next
})
}, [missingPairLineIds])
const handlePick = useCallback((sieDimNo: string, code: string | null) => {
setPicked((prev) => {
const next = { ...prev }
if (code) next[sieDimNo] = code
else delete next[sieDimNo]
return next
})
}, [])
const pickedCount = Object.keys(picked).length
const reasonValid = reason.trim().length >= 3
const canApply =
canWrite &&
!isApplying &&
selected.size > 0 &&
reasonValid &&
(replaceMode || pickedCount > 0)
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
const handleApply = useCallback(async () => {
if (!lines || !canApply) return
// Storno-pair guard: tagging one leg of a reversal pair without the
// other skews project P&L. Blocking confirmation, not just the banner.
if (missingPairLineIds.length > 0) {
const ok = await confirm({
title: 'Motverifikat är inte valda',
description: `Du taggar verifikat utan deras motverifikat (${missingPairVouchers.join(', ')}). Projektresultatet blir skevt tills båda sidorna bär samma dimensioner. Vill du tagga ändå?`,
confirmLabel: 'Tagga ändå',
})
if (!ok) return
}
const selectedLines = lines.filter((l) => selected.has(l.id))
// Per-line resulting map, grouped so each distinct map is one POST
// (the API takes ONE dimensions object per call). Usually 1 group; more
// when merge mode meets heterogeneous existing tags.
const groups = new Map<string, { dimensions: Record<string, string>; ids: string[] }>()
for (const line of selectedLines) {
const dims = replaceMode ? { ...picked } : { ...line.dimensions, ...picked }
const key = mapKey(dims)
const group = groups.get(key) ?? { dimensions: dims, ids: [] }
group.ids.push(line.id)
groups.set(key, group)
}
setIsApplying(true)
let retagged = 0
let unchanged = 0
const failed: { line_id: string; error: string }[] = []
const newDimsByLine = new Map<string, Record<string, string>>()
try {
for (const group of groups.values()) {
const res = await fetch('/api/dimensions/tagging/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
line_ids: group.ids,
dimensions: group.dimensions,
reason: reason.trim(),
}),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
const message = getErrorMessage(json, { locale: 'sv' })
for (const id of group.ids) failed.push({ line_id: id, error: message })
continue
}
const result = (json?.data ?? {}) as Partial<ApplyResult>
retagged += result.retagged ?? 0
unchanged += result.unchanged ?? 0
const failedIds = new Set<string>()
for (const f of result.failed ?? []) {
failed.push(f)
failedIds.add(f.line_id)
}
for (const id of group.ids) {
if (!failedIds.has(id)) newDimsByLine.set(id, group.dimensions)
}
}
} finally {
setIsApplying(false)
}
// Succeeded rows get their new map locally (no refetch); failed rows stay
// selected with their Swedish RPC error shown inline.
setLines((prev) =>
prev
? prev.map((l) =>
newDimsByLine.has(l.id)
? { ...l, dimensions: newDimsByLine.get(l.id) as Record<string, string> }
: l,
)
: prev,
)
setSelected(new Set(failed.map((f) => f.line_id)))
setRowErrors(Object.fromEntries(failed.map((f) => [f.line_id, f.error])))
toast({
title: failed.length > 0 ? 'Omtaggningen slutfördes delvis' : 'Rader omtaggade',
description: `${retagged} ändrade, ${unchanged} oförändrade${
failed.length > 0 ? `, ${failed.length} misslyckades` : ''
}.`,
variant: failed.length > 0 ? 'destructive' : undefined,
})
if (failed.length === 0) {
setPicked({})
setReason('')
}
}, [lines, canApply, selected, replaceMode, picked, reason, toast, missingPairLineIds, missingPairVouchers, confirm])
const headerChecked: boolean | 'indeterminate' = allSelected
? true
: someSelected
? 'indeterminate'
: false
return (
<div className="space-y-8">
{/* Filter bar */}
<Card>
<CardContent className="p-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<Label htmlFor="tag-date-from" className="text-xs text-muted-foreground">
Från datum
</Label>
<Input
id="tag-date-from"
type="date"
className="mt-1"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div>
<Label htmlFor="tag-date-to" className="text-xs text-muted-foreground">
Till datum
</Label>
<Input
id="tag-date-to"
type="date"
className="mt-1"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
<div>
<Label htmlFor="tag-account-from" className="text-xs text-muted-foreground">
Konto från
</Label>
<Input
id="tag-account-from"
inputMode="numeric"
maxLength={4}
placeholder="3000"
className="mt-1 font-mono"
value={accountFrom}
onChange={(e) => setAccountFrom(e.target.value.replace(/\D/g, ''))}
/>
</div>
<div>
<Label htmlFor="tag-account-to" className="text-xs text-muted-foreground">
Konto till
</Label>
<Input
id="tag-account-to"
inputMode="numeric"
maxLength={4}
placeholder="7999"
className="mt-1 font-mono"
value={accountTo}
onChange={(e) => setAccountTo(e.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Sök i beskrivning…"
className="pl-10"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void loadLines()
}}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="tag-only-untagged"
checked={onlyUntagged}
onCheckedChange={(checked) => setOnlyUntagged(checked === true)}
/>
<Label htmlFor="tag-only-untagged" className="text-sm font-normal">
Endast otaggade rader
</Label>
</div>
<Button onClick={() => void loadLines()} disabled={isLoading}>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Hämta rader
</Button>
</div>
</CardContent>
</Card>
{/* Result list */}
{lines === null && !isLoading ? (
<Card>
<CardContent className="p-0">
<DataListEmpty
icon={<Tags className="h-6 w-6" />}
title="Hämta rader att tagga"
description="Välj filter ovan och klicka på Hämta rader för att bläddra bland bokförda verifikatrader."
/>
</CardContent>
</Card>
) : (
<DataList>
<DataListHeader>
<Checkbox
checked={headerChecked}
onClick={(e) => {
e.preventDefault()
toggleAll()
}}
aria-label="Markera alla rader"
disabled={!lines || lines.length === 0}
/>
<span className="text-xs text-muted-foreground">
{lines ? `${lines.length} rader` : ''}
</span>
{totalCapped && (
<span className="ml-auto text-xs text-muted-foreground">
Visar de första {lines?.length ?? 0} raderna — förfina filtren för att se
fler.
</span>
)}
</DataListHeader>
{isLoading ? (
<DataListLoading />
) : lines && lines.length === 0 ? (
<DataListEmpty
icon={<Search className="h-6 w-6" />}
title="Inga rader matchade filtren"
description="Justera datum, kontointervall eller söktext och försök igen."
/>
) : (
(lines ?? []).map((line, index) => {
const isSelected = selected.has(line.id)
const inStornoPair = Boolean(line.reversed_by_id || line.reverses_id)
const dimEntries = Object.entries(line.dimensions)
const isDebit = line.debit_amount > 0
return (
<DataListRow
key={line.id}
selected={isSelected}
className="select-none"
onClick={(e) => toggleRow(index, e.shiftKey)}
leading={
<Checkbox
checked={isSelected}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleRow(index, e.shiftKey)
}}
aria-label={`Markera rad ${line.voucher_series ?? ''}${line.voucher_number ?? ''} ${line.account_number}`}
/>
}
trailing={
<div className="text-right">
<p className="text-sm tabular-nums">
{formatCurrency(isDebit ? line.debit_amount : line.credit_amount)}
</p>
<p className="text-xs text-muted-foreground">
{isDebit ? 'Debet' : 'Kredit'}
</p>
</div>
}
>
<DataListPrimary className="flex items-center gap-2">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{line.voucher_series ?? ''}
{line.voucher_number ?? ''}
</span>
<span className="truncate">{line.description}</span>
{inStornoPair && (
<span
title="Verifikatet ingår i ett storno-par"
className="inline-flex shrink-0"
>
<Undo2
className="h-3.5 w-3.5 text-muted-foreground"
aria-hidden="true"
/>
<span className="sr-only">Ingår i ett storno-par</span>
</span>
)}
</DataListPrimary>
<DataListMeta>
<span className="tabular-nums">{formatDate(line.entry_date)}</span>
<DataListMetaSeparator />
<span className="font-mono">{line.account_number}</span>
{dimEntries.length > 0 && <DataListMetaSeparator />}
{dimEntries.map(([dimNo, code]) => (
<Badge
key={dimNo}
variant="outline"
className="px-1.5 py-0 text-[10px] font-normal"
>
{dimensionLabel(dimNo)}{' '}
<span className="ml-1 font-mono">{code}</span>
</Badge>
))}
</DataListMeta>
{rowErrors[line.id] && (
<p className="mt-1 text-xs text-destructive">{rowErrors[line.id]}</p>
)}
</DataListRow>
)
})
)}
</DataList>
)}
{/* Spacer so the fixed apply panel never covers the last rows */}
{selected.size > 0 && <div aria-hidden="true" className="h-64 sm:h-48" />}
{/* Apply panel — fixed footer bar while a selection is active */}
{selected.size > 0 && (
<div className="fixed bottom-20 left-1/2 z-40 w-[calc(100vw-2rem)] max-w-2xl -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-lg md:bottom-6">
{missingPairLineIds.length > 0 && (
<div className="mb-4 flex flex-col gap-3 rounded-lg border border-border bg-secondary/40 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<p className="text-sm">
Du taggar ett verifikat men inte dess motverifikat —
projektresultatet kan bli skevt.
</p>
</div>
<Button variant="outline" size="sm" onClick={includeCounterVouchers}>
Inkludera motverifikat
</Button>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">{selected.size} rader valda</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelected(new Set())
anchorIndexRef.current = null
}}
>
<X className="mr-1 h-3 w-3" />
Avmarkera
</Button>
<div className="ml-auto flex items-center gap-2">
<Switch
id="tag-replace-mode"
checked={replaceMode}
onCheckedChange={setReplaceMode}
disabled={isApplying}
/>
<Label htmlFor="tag-replace-mode" className="text-sm font-normal">
Ersätt tagg
</Label>
</div>
</div>
<div className="mt-4 space-y-4">
<LineDimensionFields
dimensions={picked}
onChange={handlePick}
disabled={isApplying}
inputClassName="h-8"
/>
<div>
<Label htmlFor="tag-reason" className="text-xs text-muted-foreground">
Anledning
</Label>
<div className="mt-1 flex flex-col gap-3 sm:flex-row">
<Input
id="tag-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="T.ex. rättelse av projektkod (minst 3 tecken)"
disabled={isApplying}
className="flex-1"
/>
<Button onClick={() => void handleApply()} disabled={!canApply}>{/* storno-pair confirm inside */}
{isApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Tags className="mr-2 h-4 w-4" />
)}
Tagga {selected.size} rader
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{replaceMode
? 'Ersätter hela taggningen på raderna med exakt de valda värdena. '
: ''}
Påverkar endast internredovisningen, inte verifikatet.
</p>
</div>
</div>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
'use client'
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { AccountNumber } from '@/components/ui/account-number'
export interface RetagLine {
id: string
account_number: string
line_description: string | null
debit_amount: number
credit_amount: number
dimensions?: Record<string, string> | null
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
line: RetagLine | null
/** Fired after a successful retag so the host refetches the entry. */
onRetagged: () => void
}
/**
* Tier-2 retro-tagging on a posted voucher line (dimensions plan PR6).
* Edits ONLY the dimension tags via the audited retag RPC; the verifikat
* itself is untouchable. Dims other than 1/6 pass through unedited (same
* merge semantics as the voucher editor). Hardcoded Swedish — voucher
* detail is a stays-Swedish surface.
*/
export default function RetagLineDialog({ open, onOpenChange, line, onRetagged }: Props) {
const { toast } = useToast()
const [dims, setDims] = useState<Record<string, string>>({})
const [reason, setReason] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open && line) {
setDims({ ...(line.dimensions ?? {}) })
setReason('')
setError(null)
}
}, [open, line])
if (!line) return null
const amount = Number(line.debit_amount) > 0 ? Number(line.debit_amount) : Number(line.credit_amount)
const side = Number(line.debit_amount) > 0 ? 'Debet' : 'Kredit'
const handleChange = (dimNo: string, code: string | null) => {
setDims((prev) => {
const next = { ...prev }
if (code) next[dimNo] = code
else delete next[dimNo]
return next
})
}
const handleSave = async () => {
setIsSaving(true)
setError(null)
try {
const res = await fetch(`/api/bookkeeping/journal-entry-lines/${line.id}/retag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dimensions: dims, reason }),
})
const payload = await res.json()
if (!res.ok) {
setError(typeof payload.error === 'string' ? payload.error : 'Kunde inte ändra dimensioner')
return
}
if (payload.data?.changed === false) {
toast({ title: 'Inga ändringar', description: 'Dimensionerna var redan de valda.' })
} else {
toast({ title: 'Dimensioner ändrade', description: 'Ändringen är loggad i ändringshistoriken.' })
}
onOpenChange(false)
onRetagged()
} catch {
setError('Kunde inte ändra dimensioner')
} finally {
setIsSaving(false)
}
}
const reasonValid = reason.trim().length >= 3
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ändra dimensioner</DialogTitle>
<DialogDescription>
Påverkar endast internredovisningen, inte verifikatet. Ändringen
loggas med före/efter och anledning.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border p-3 text-sm">
<AccountNumber number={line.account_number} showName />
<div className="mt-1 flex items-center justify-between text-muted-foreground">
<span className="truncate">{line.line_description || '—'}</span>
<span className="tabular-nums shrink-0 ml-3">
{amount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr {side}
</span>
</div>
</div>
<LineDimensionFields dimensions={dims} onChange={handleChange} />
<div className="space-y-2">
<Label htmlFor="retag-reason">Anledning</Label>
<Input
id="retag-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="t.ex. Raden hörde till projekt P002"
maxLength={500}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={isSaving || !reasonValid}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara ändring
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}