feat(dimensions): PR3 tagging — voucher-form pickers, MCP dimension tools with resolve-don't-select, engine soft validation (#859)

Phase 3 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false see zero change; existing free-text API writers keep
working (validation is toggle-governed).

Engine (soft validation):
- validateEntryDimensions() in dimension-resolver: zero queries for untagged
  entries; toggle off → passthrough; toggle on → one settings fetch + two
  registry queries, rejects unknown dims/codes and archived values with
  Swedish per-code messages (DimensionValidationError, 400, details.issues).
  Wired into createDraftEntry + updateDraftEntry before any insert; reversal/
  storno paths untouched (verbatim copies). Fails open on transient registry
  errors — soft validation must never block bookkeeping.

MCP (agent write path):
- New tools: gnubok_list_dimensions, gnubok_list_dimension_values (fuse.js
  fuzzy), gnubok_create_dimension_value (STAGED via pending_operations —
  agents never silently mint reporting values; new op type + CHECK migration
  + executor with duplicate-idempotency).
- create_voucher/correct_entry: per-line dimensions bag + default_dimensions,
  resolve-don't-select server-side (code OR natural-language name; exact →
  fuzzy ≤0.30 with ≥0.15 runner-up margin; non-exact resolutions echoed with
  confidence; ambiguous → ranked candidates, no auto-create).
- gnubok_get_agent_briefing gains a dimensions block (enabled, dims, top
  values) — omitted when registry empty.
- TOOL_SCOPE_MAP entries; risk tier low for staged value creation.

UI:
- JournalEntryForm (manual voucher + TransactionBookingDialog embed): header
  "+ Kostnadsställe/Projekt" progressive disclosure (gäller alla rader with
  documented inheritance rule) + per-row tag popover + compact KS·PR badges;
  gated on dimensions_enabled.
- Voucher detail: display-only dimension badges with registry-name resolution.
- EditDraftEntryDialog carries line dimensions so editing a draft no longer
  strips tags.

categorize/bulk_book dims deferred to PR7 (needs the bulk_book RPC migration).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 13:36:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6be6510d73
commit 11126d6d56
28 changed files with 3152 additions and 43 deletions
@@ -35,6 +35,10 @@ export default function EditDraftEntryDialog({ entry, open, onOpenChange, onUpda
debit_amount: Number(l.debit_amount) > 0 ? String(l.debit_amount) : '',
credit_amount: Number(l.credit_amount) > 0 ? String(l.credit_amount) : '',
line_description: l.line_description || '',
// Carry the line's dimensions into the form — the PATCH replaces all
// lines, so omitting this would silently strip existing tags.
dimensions:
l.dimensions && Object.keys(l.dimensions).length > 0 ? { ...l.dimensions } : undefined,
}))
return (
+214 -23
View File
@@ -10,12 +10,14 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser } from 'lucide-react'
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser, Tags } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useCanWrite } from '@/lib/hooks/use-can-write'
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 LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
@@ -53,6 +55,8 @@ export interface FormLine {
currency?: string
amount_in_currency?: number
exchange_rate?: number
/** SIE dimension map {sie_dim_no: object_code}, e.g. {"1":"KS01","6":"P001"}. */
dimensions?: Record<string, string>
}
interface Props {
@@ -106,6 +110,20 @@ export default function JournalEntryForm({
const [description, setDescription] = useState(initialDescription ?? '')
const [notes, setNotes] = useState(initialNotes ?? '')
const [showNotes, setShowNotes] = useState(false)
// Dimension tagging (kostnadsställe/projekt). The affordances render only
// when company_settings.dimensions_enabled — a UI-visibility gate; lines
// that already carry dimensions (e.g. a draft being edited) still round-trip
// untouched when the toggle is off.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [showDims, setShowDims] = useState(false)
// Header-level default dims ("gäller alla rader"). The per-row maps on
// `lines` are the ONE source of truth — this state only drives the header
// comboboxes; setHeaderDimension writes the default through to the rows.
const [headerDims, setHeaderDims] = useState<Record<string, string>>({})
// Which row's dimension popover is open (desktop table), and its container
// for the outside-click close.
const [dimPopoverRow, setDimPopoverRow] = useState<number | null>(null)
const dimPopoverRef = useRef<HTMLDivElement | null>(null)
const [lines, setLines] = useState<FormLine[]>(
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
)
@@ -193,14 +211,16 @@ export default function JournalEntryForm({
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'.
// In edit mode the draft's own series is pre-filled — never override it
// from the company defaults.
if (!embedded && !editEntryId) {
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
if (!data) return
// Company settings power two things here: dimensions_enabled gates the
// tagging affordances (all modes, incl. the TransactionBookingDialog
// embed), and the default voucher series seeds the standalone form —
// prefer the per-source-type mapping when present; fall back to the legacy
// default_voucher_series, then to 'A'. In edit mode the draft's own series
// is pre-filled — never override it from the company defaults.
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
if (!data) return
setDimensionsEnabled(data.dimensions_enabled === true)
if (!embedded && !editEntryId) {
const effectiveSourceType = sourceType ?? 'manual'
const perSource = resolveDefaultSeriesForSource(
data as { default_voucher_series_per_source_type?: Record<string, string> | null } | null,
@@ -208,8 +228,8 @@ export default function JournalEntryForm({
)
const fallback = data.default_voucher_series || 'A'
setVoucherSeries(perSource !== 'A' ? perSource : fallback)
}).catch(() => {/* keep 'A' */})
}
}
}).catch(() => {/* keep 'A' + hidden dimension affordances */})
}, [embedded, sourceType, editEntryId])
// Auto-select period when entry date changes
@@ -346,15 +366,95 @@ export default function JournalEntryForm({
}
}, [accountsKey, entryDate])
// New rows inherit the current header default (a row without a per-row
// override follows the header — see setHeaderDimension).
const makeBlankLine = useCallback(
(): FormLine =>
Object.keys(headerDims).length > 0
? { ...BLANK_LINE, dimensions: { ...headerDims } }
: { ...BLANK_LINE },
[headerDims]
)
const addLine = () => {
setLines([...lines, { ...BLANK_LINE }])
setLines([...lines, makeBlankLine()])
}
const removeLine = (index: number) => {
if (lines.length <= 2) return
setLines(lines.filter((_, i) => i !== index))
// Keep the open dimension popover attached to the same row after the splice.
setDimPopoverRow((r) => (r === null ? r : r === index ? null : r > index ? r - 1 : r))
}
/**
* Header default write-through. Inheritance rule: a row inherits dimension
* `dimNo` iff its current value equals the previous header default (unset
* counts as equal to an unset default). Inheriting rows follow the change
* (including clearing); rows whose value differs are per-row overrides and
* are left untouched. A row explicitly set to the same code as the header is
* indistinguishable from an inherited one and follows later header changes
* by design — the per-row maps stay the single source of truth.
*/
const setHeaderDimension = (dimNo: string, code: string | null) => {
const prev = headerDims[dimNo]
const next = code?.trim() || undefined
setHeaderDims((h) => {
const out = { ...h }
if (next) out[dimNo] = next
else delete out[dimNo]
return out
})
setLines((ls) =>
ls.map((l) => {
if (l.dimensions?.[dimNo] !== prev) return l // per-row override — keep
const dims = { ...(l.dimensions ?? {}) }
if (next) dims[dimNo] = next
else delete dims[dimNo]
return { ...l, dimensions: Object.keys(dims).length > 0 ? dims : undefined }
})
)
}
const updateLineDimension = (index: number, dimNo: string, code: string | null) => {
setLines((ls) =>
ls.map((l, i) => {
if (i !== index) return l
const dims = { ...(l.dimensions ?? {}) }
const trimmed = code?.trim()
if (trimmed) dims[dimNo] = trimmed
else delete dims[dimNo]
return { ...l, dimensions: Object.keys(dims).length > 0 ? dims : undefined }
})
)
}
// Compact per-row display, e.g. "KS01 · P001" (dim number order).
const compactDims = (dims: Record<string, string>) =>
Object.entries(dims)
.filter(([, v]) => v)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v)
.join(' · ')
// Close the row dimension popover on outside click (same pattern as the
// comboboxes' own dropdowns; their option clicks preventDefault so a
// selection never counts as outside).
useEffect(() => {
if (dimPopoverRow === null) return
function handlePointerDown(e: MouseEvent | TouchEvent) {
if (dimPopoverRef.current && !dimPopoverRef.current.contains(e.target as Node)) {
setDimPopoverRow(null)
}
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('touchstart', handlePointerDown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('touchstart', handlePointerDown)
}
}, [dimPopoverRow])
const updateLine = (index: number, field: keyof FormLine, value: string) => {
const updated = [...lines]
updated[index] = { ...updated[index], [field]: value }
@@ -437,9 +537,9 @@ export default function JournalEntryForm({
if (!last) return prev
const trailingBlank =
last.account_number === '' && last.debit_amount === '' && last.credit_amount === ''
return trailingBlank ? prev : [...prev, { ...BLANK_LINE }]
return trailingBlank ? prev : [...prev, makeBlankLine()]
})
}, [lines])
}, [lines, makeBlankLine])
// Inline (bare) review: move focus to the confirm button when it opens so
// Enter posts — parity with the ConfirmationDialog's autoFocusConfirm.
@@ -518,6 +618,7 @@ export default function JournalEntryForm({
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setHeaderDims({})
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
@@ -589,6 +690,13 @@ export default function JournalEntryForm({
line_description: l.line_description || undefined,
}
if (l.dimensions) {
const dims = Object.fromEntries(
Object.entries(l.dimensions).filter(([, v]) => typeof v === 'string' && v.trim() !== '')
)
if (Object.keys(dims).length > 0) base.dimensions = dims
}
if (l.currency) {
base.currency = l.currency
if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency
@@ -675,6 +783,7 @@ export default function JournalEntryForm({
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setHeaderDims({})
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
@@ -762,6 +871,7 @@ export default function JournalEntryForm({
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setHeaderDims({})
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
@@ -995,6 +1105,15 @@ export default function JournalEntryForm({
+ {t('internal_note')}
</button>
)}
{dimensionsEnabled && !showDims && (
<button
type="button"
onClick={() => setShowDims(true)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
+ {t('add_dimensions')}
</button>
)}
</div>
{isForeign && (
@@ -1057,6 +1176,19 @@ export default function JournalEntryForm({
</div>
)}
{/* Header default dims — writes through to all rows without a per-row
override (see setHeaderDimension for the inheritance rule). */}
{dimensionsEnabled && showDims && (
<div className="max-w-md space-y-1">
<LineDimensionFields
dimensions={headerDims}
onChange={setHeaderDimension}
inputClassName="h-8"
/>
<p className="text-xs text-muted-foreground">{t('dimensions_apply_all_hint')}</p>
</div>
)}
{periodMismatch === 'no_period' && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
@@ -1141,6 +1273,12 @@ export default function JournalEntryForm({
/>
</div>
</div>
{dimensionsEnabled && (
<LineDimensionFields
dimensions={line.dimensions}
onChange={(dimNo, code) => updateLineDimension(index, dimNo, code)}
/>
)}
{/^\d{4}$/.test(line.account_number) && (
<div className="flex justify-end text-xs text-muted-foreground tabular-nums pt-0.5">
{accountBalances[line.account_number] === null || accountBalances[line.account_number] === undefined ? (
@@ -1220,6 +1358,13 @@ export default function JournalEntryForm({
placeholder={t('line_description_placeholder')}
className="h-8"
/>
{line.dimensions &&
Object.keys(line.dimensions).length > 0 &&
(line.account_number || line.debit_amount || line.credit_amount) && (
<Badge variant="outline" className="mt-1 font-mono text-[11px] font-normal">
{compactDims(line.dimensions)}
</Badge>
)}
</td>
<td className="py-1.5 px-1">
<Input
@@ -1261,15 +1406,61 @@ export default function JournalEntryForm({
})()}
</td>
<td className="py-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px]"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center justify-end">
{dimensionsEnabled && (
<div
className="relative"
ref={dimPopoverRow === index ? dimPopoverRef : undefined}
>
<Button
variant="ghost"
size="sm"
onClick={() => setDimPopoverRow(dimPopoverRow === index ? null : index)}
className={`h-8 w-8 p-0 min-h-[44px] min-w-[44px] ${
line.dimensions && Object.keys(line.dimensions).length > 0
? 'text-foreground'
: 'text-muted-foreground'
}`}
aria-label={t('row_dimensions_aria')}
aria-expanded={dimPopoverRow === index}
title={t('row_dimensions_aria')}
>
<Tags className="h-3.5 w-3.5" />
</Button>
{dimPopoverRow === index && (
<div
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-md border bg-card p-3 shadow-md"
onKeyDown={(e) => {
// The comboboxes preventDefault their own Escape
// (closing their dropdown) — only an unhandled
// Escape closes the popover.
if (e.key === 'Escape' && !e.defaultPrevented) {
e.preventDefault()
e.stopPropagation()
setDimPopoverRow(null)
}
}}
>
<LineDimensionFields
stacked
dimensions={line.dimensions}
onChange={(dimNo, code) => updateLineDimension(index, dimNo, code)}
inputClassName="h-8"
/>
</div>
)}
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px]"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
@@ -0,0 +1,60 @@
'use client'
import { Label } from '@/components/ui/label'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
interface LineDimensionFieldsProps {
/** Current dimensions map ({sie_dim_no: object_code}) — a line's map or the header default. */
dimensions: Record<string, string> | undefined
/** Fired per dimension; `code === null` clears the value. */
onChange: (sieDimNo: string, code: string | null) => void
disabled?: boolean
/** Vertical layout for narrow containers (row popover); default is a 2-col grid. */
stacked?: boolean
/** Extra classes merged into the combobox inputs (pass 'h-8' for dense contexts). */
inputClassName?: string
}
/**
* The Kostnadsställe + Projekt combobox pair (SIE dims 1/6) used by the
* voucher form's header default, the per-row tag popover, and the mobile line
* cards. Labels are the seeded system-dimension names and hardcoded Swedish —
* the component mounts on the voucher editor, a stays-Swedish surface per
* .claude/rules/i18n.md (same convention as DimensionCombobox).
*/
export default function LineDimensionFields({
dimensions,
onChange,
disabled,
stacked,
inputClassName,
}: LineDimensionFieldsProps) {
return (
<div className={stacked ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
<div>
<Label className="text-xs text-muted-foreground">Kostnadsställe</Label>
<div className="mt-1">
<DimensionCombobox
sieDimNo="1"
value={dimensions?.['1'] ?? null}
onChange={(code) => onChange('1', code)}
disabled={disabled}
className={inputClassName}
/>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Projekt</Label>
<div className="mt-1">
<DimensionCombobox
sieDimNo="6"
value={dimensions?.['6'] ?? null}
onChange={(code) => onChange('6', code)}
disabled={disabled}
className={inputClassName}
/>
</div>
</div>
</div>
)
}