feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement (#886)

* feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement

The final rung of the dimensions ladder
(dev_docs/dimensions_implementation_plan.md §7 row 10):

- custom dimensions: POST /api/dimensions creates registry dims (next free
  SIE number >= 20 when omitted; explicit numbers allowed — SIE import
  already mints reserved ones); register gets a 'Ny dimension' dialog with
  a quiet Avancerat disclosure for the #UNDERDIM parent; GET now carries
  parent_sie_dim_no (the column + SIE round-trip existed since PR1/PR5 —
  this exposes it)
- account_dimension_rules (migration 20260703120000): one rule per
  (account, dimension) — required / default / fixed, per-rule is_active,
  company-scoped RLS, composite FK to the registry, value-presence CHECK
- enforcement, opt-in BY CONSTRUCTION (zero rules = engine byte-identical;
  deliberately NO settings toggle — a rule that exists but is ignored is
  worse than either extreme): default/fixed apply onto line bags at draft
  creation (fixed overwrites, default fills); required asserts at
  commitEntry with a Swedish MANDATORY_DIMENSION_MISSING naming every
  account + dimension; the bulk-book route runs the same policy before its
  RPC; storno/correction paths never pass through commitEntry so history
  always reverses regardless of policy; rule fetches fail open incl.
  thrown exceptions
- chart of accounts: per-account Dimensionsregler section in
  EditAccountDialog (Krävs/Förval/Låst, value picker, pause switch),
  gated on the existing dimensions toggle, quiet when empty
- pickers: LineDimensionFields is registry-driven (one combobox per active
  dimension, cached fetch, hardcoded 1/6 fallback) — every existing mount
  lights up custom dims with zero changes
- agent briefing: per-dimension required_on_accounts/default_on_accounts
  so agents self-correct instead of bouncing off the policy error
- rules CRUD API with existence/active/company validation and qualified
  DTO ids; firm_id FK deferred until the firms table lands (per plan)

39 new tests (pure-fn rules, engine enforcement, both new API surfaces,
pg-real RLS/CHECK/cascade suite); full suite 6,791 green; migration
replayed on a fresh container.

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

* fix: renumber migration to 20260703200000 — version collision with prod

The concurrent session shipped pending_operations_add_link_document_to_voucher
as 20260703120000 today; the Supabase preview branch (cloned from prod)
rejected the duplicate version key.

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

* fix: review round — auto-pick retry on collision, fail-open warnings, query schema

- POST /api/dimensions retries once past a concurrent number claim when the
  number was auto-picked (explicit choices still 409)
- every fail-open skip of the dimension-rules policy now logs a structured
  warning (engine draft/commit paths + bulk-book) — deliberate fail-open,
  but observable
- GET /api/dimensions/rules validates its query through
  ListDimensionRulesQuerySchema instead of an inline regex

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-03 16:50:28 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 70e893b8d4
commit 764348e99c
26 changed files with 2800 additions and 72 deletions
+380 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -8,12 +8,29 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Loader2, Plus, X } from 'lucide-react'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import {
fetchDimensions,
type AccountDimensionRuleDto,
type DimensionDto,
type DimensionRuleType,
} from '@/components/dimensions/types'
import type { BASAccount } from '@/types'
interface EditAccountDialogProps {
@@ -23,7 +40,23 @@ interface EditAccountDialogProps {
onSaved: () => void
}
// Hardcoded Swedish per the file's convention (chart-of-accounts editing is a
// bookkeeping surface). Labels mirror the rule semantics enforced by the
// engine at commit time.
const RULE_TYPE_LABELS: Record<DimensionRuleType, string> = {
required: 'Krävs',
default: 'Förval',
fixed: 'Låst',
}
const RULE_TYPE_HELP: Record<DimensionRuleType, string> = {
required: 'Krävs — verifikat på kontot kan inte bokföras utan värde',
default: 'Förval — värdet föreslås men kan ändras',
fixed: 'Låst — värdet sätts alltid automatiskt',
}
export function EditAccountDialog({ open, onOpenChange, account, onSaved }: EditAccountDialogProps) {
const { toast } = useToast()
const [accountName, setAccountName] = useState(account.account_name)
const [description, setDescription] = useState(account.description || '')
const [defaultVatCode, setDefaultVatCode] = useState(account.default_vat_code || '')
@@ -31,6 +64,183 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
const [isActive, setIsActive] = useState(account.is_active)
const [isSaving, setIsSaving] = useState(false)
// Dimension rules ("Dimensionsregler") — visible only when the company has
// dimensions enabled (same /api/settings gate as JournalEntryForm). Rule
// mutations apply immediately via their own fetches + toasts; they are
// deliberately independent of the account PUT below.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [dims, setDims] = useState<DimensionDto[]>([])
const [rules, setRules] = useState<AccountDimensionRuleDto[]>([])
const [rulesLoading, setRulesLoading] = useState(false)
const [addRuleOpen, setAddRuleOpen] = useState(false)
const [newRuleDimensionId, setNewRuleDimensionId] = useState('')
const [newRuleType, setNewRuleType] = useState<DimensionRuleType>('required')
const [newRuleValueCode, setNewRuleValueCode] = useState<string | null>(null)
const [isAddingRule, setIsAddingRule] = useState(false)
useEffect(() => {
let cancelled = false
fetch('/api/settings')
.then((r) => r.json())
.then(({ data }) => {
if (!cancelled && data?.dimensions_enabled === true) setDimensionsEnabled(true)
})
.catch(() => {
/* keep the section hidden */
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!dimensionsEnabled) return
let cancelled = false
setRulesLoading(true)
Promise.all([
fetchDimensions().catch(() => [] as DimensionDto[]),
fetch(`/api/dimensions/rules?account_number=${account.account_number}`)
.then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) }))
.catch(() => ({ ok: false, json: null })),
]).then(([fetchedDims, rulesRes]) => {
if (cancelled) return
setDims(fetchedDims)
if (rulesRes.ok) {
setRules((rulesRes.json?.data?.rules ?? []) as AccountDimensionRuleDto[])
}
setRulesLoading(false)
})
return () => {
cancelled = true
}
}, [dimensionsEnabled, account.account_number])
const activeDims = dims.filter((d) => d.is_active)
const newRuleDim = activeDims.find((d) => d.id === newRuleDimensionId) ?? null
const newRuleNeedsValue = newRuleType === 'default' || newRuleType === 'fixed'
function resetAddRuleForm() {
setAddRuleOpen(false)
setNewRuleDimensionId('')
setNewRuleType('required')
setNewRuleValueCode(null)
}
async function handleAddRule() {
if (!newRuleDim) return
setIsAddingRule(true)
try {
// Resolve the picked code to a value id. The combobox can create values
// inline, so a code missing from the mount-time registry snapshot means
// we refetch once before giving up.
let valueId: string | null = null
if (newRuleNeedsValue) {
const code = newRuleValueCode
if (!code) return
const findValueId = (list: DimensionDto[]) =>
list
.find((d) => d.id === newRuleDim.id)
?.values.find((v) => v.code === code)?.id ?? null
valueId = findValueId(dims)
if (!valueId) {
const refreshed = await fetchDimensions().catch(() => null)
if (refreshed) {
setDims(refreshed)
valueId = findValueId(refreshed)
}
}
if (!valueId) {
toast({
title: 'Kunde inte lägga till regeln',
description: `Värdet ${code} hittades inte i registret.`,
variant: 'destructive',
})
return
}
}
const body: Record<string, unknown> = {
account_number: account.account_number,
dimension_id: newRuleDim.id,
rule_type: newRuleType,
}
if (valueId) body.value_id = valueId
const res = await fetch('/api/dimensions/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
toast({
title: 'Kunde inte lägga till regeln',
description: getErrorMessage(json, { locale: 'sv' }),
variant: 'destructive',
})
return
}
const created = json?.data?.rule as AccountDimensionRuleDto | undefined
if (created) setRules((prev) => [...prev, created])
toast({ title: 'Regel tillagd' })
resetAddRuleForm()
} finally {
setIsAddingRule(false)
}
}
async function handleToggleRule(rule: AccountDimensionRuleDto, checked: boolean) {
const ruleId = rule.account_dimension_rule_id
// Optimistic — the switch flips immediately and reverts on failure.
setRules((prev) =>
prev.map((r) =>
r.account_dimension_rule_id === ruleId ? { ...r, is_active: checked } : r,
),
)
const res = await fetch(`/api/dimensions/rules/${ruleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: checked }),
}).catch(() => null)
const json = await res?.json().catch(() => null)
if (!res?.ok) {
setRules((prev) =>
prev.map((r) =>
r.account_dimension_rule_id === ruleId ? { ...r, is_active: rule.is_active } : r,
),
)
toast({
title: 'Kunde inte uppdatera regeln',
description: getErrorMessage(json, { locale: 'sv' }),
variant: 'destructive',
})
return
}
const updated = json?.data?.rule as AccountDimensionRuleDto | undefined
if (updated) {
setRules((prev) =>
prev.map((r) => (r.account_dimension_rule_id === ruleId ? updated : r)),
)
}
}
async function handleDeleteRule(rule: AccountDimensionRuleDto) {
const ruleId = rule.account_dimension_rule_id
const res = await fetch(`/api/dimensions/rules/${ruleId}`, {
method: 'DELETE',
}).catch(() => null)
if (!res?.ok) {
const json = await res?.json().catch(() => null)
toast({
title: 'Kunde inte ta bort regeln',
description: getErrorMessage(json, { locale: 'sv' }),
variant: 'destructive',
})
return
}
setRules((prev) => prev.filter((r) => r.account_dimension_rule_id !== ruleId))
toast({ title: 'Regel borttagen' })
}
async function handleSave() {
setIsSaving(true)
try {
@@ -62,7 +272,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Redigera konto {account.account_number}
@@ -107,6 +317,173 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
</div>
</div>
{dimensionsEnabled && (
<div className="space-y-3">
<p className="text-sm font-medium">Dimensionsregler</p>
{rulesLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<>
{rules.length === 0 && !addRuleOpen && (
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Inga dimensionsregler för det här kontot.
</p>
<Button
variant="ghost"
size="sm"
onClick={() => setAddRuleOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Lägg till regel
</Button>
</div>
)}
{rules.map((rule) => (
<div
key={rule.account_dimension_rule_id}
className="flex items-center gap-3 rounded-lg border p-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{rule.dimension_name}
</p>
<p className="text-xs text-muted-foreground truncate">
{RULE_TYPE_LABELS[rule.rule_type]}
{rule.value_code && (
<>
{' · '}
<span className="font-mono">{rule.value_code}</span>
{rule.value_name && rule.value_name !== rule.value_code
? ` ${rule.value_name}`
: ''}
</>
)}
</p>
</div>
<Switch
checked={rule.is_active}
onCheckedChange={(checked) => handleToggleRule(rule, checked)}
aria-label={`Regel för ${rule.dimension_name} aktiv`}
/>
<Button
variant="ghost"
size="icon"
aria-label={`Ta bort regel för ${rule.dimension_name}`}
onClick={() => handleDeleteRule(rule)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
{addRuleOpen ? (
<div className="space-y-3 rounded-lg border border-dashed p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Dimension
</Label>
<Select
value={newRuleDimensionId || undefined}
onValueChange={(id) => {
setNewRuleDimensionId(id)
setNewRuleValueCode(null)
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj dimension" />
</SelectTrigger>
<SelectContent>
{activeDims.map((dim) => (
<SelectItem key={dim.id} value={dim.id}>
{dim.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Typ</Label>
<Select
value={newRuleType}
onValueChange={(v) => {
setNewRuleType(v as DimensionRuleType)
if (v === 'required') setNewRuleValueCode(null)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(RULE_TYPE_LABELS) as DimensionRuleType[]).map(
(type) => (
<SelectItem key={type} value={type}>
{RULE_TYPE_LABELS[type]}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
{RULE_TYPE_HELP[newRuleType]}
</p>
{newRuleNeedsValue && newRuleDim && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Värde</Label>
<DimensionCombobox
sieDimNo={String(newRuleDim.sie_dim_no)}
value={newRuleValueCode}
onChange={setNewRuleValueCode}
/>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
disabled={isAddingRule}
onClick={resetAddRuleForm}
>
Avbryt
</Button>
<Button
size="sm"
disabled={
isAddingRule ||
!newRuleDim ||
(newRuleNeedsValue && !newRuleValueCode)
}
onClick={handleAddRule}
>
{isAddingRule && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Lägg till
</Button>
</div>
</div>
) : (
rules.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setAddRuleOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Lägg till regel
</Button>
)
)}
</>
)}
</div>
)}
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Aktivt konto</p>
+279 -25
View File
@@ -6,7 +6,9 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
@@ -14,6 +16,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
@@ -34,7 +43,9 @@ import {
Tags,
ChevronUp,
ChevronDown,
ChevronRight,
ChevronsUpDown,
Loader2,
} from 'lucide-react'
import DimensionValueForm, {
type DimensionValueFormInput,
@@ -81,6 +92,8 @@ export default function DimensionsManager() {
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [dialog, setDialog] = useState<DialogState>(null)
const [isSaving, setIsSaving] = useState(false)
const [newDimDialogOpen, setNewDimDialogOpen] = useState(false)
const [isCreatingDimension, setIsCreatingDimension] = useState(false)
const loadDimensions = useCallback(
async (showSpinner: boolean) => {
@@ -120,6 +133,15 @@ export default function DimensionsManager() {
)
const isProjectTab = activeDim?.sie_dim_no === PROJECT_DIM_NO
// Parent (#UNDERDIM) of the active tab's dimension, when it has one.
const parentDimName = useMemo(() => {
if (!activeDim || activeDim.parent_sie_dim_no == null) return null
const parent = dimensions.find(
(d) => d.sie_dim_no === activeDim.parent_sie_dim_no,
)
return parent?.name ?? `#${activeDim.parent_sie_dim_no}`
}, [dimensions, activeDim])
const filteredValues = useMemo(() => {
if (!activeDim) return []
const term = searchTerm.trim().toLowerCase()
@@ -224,6 +246,45 @@ export default function DimensionsManager() {
}
}
async function handleCreateDimension(input: NewDimensionFormInput) {
setIsCreatingDimension(true)
try {
// Contract: sie_dim_no omitted → the server picks the next free ≥20;
// parent_sie_dim_no omitted → no #UNDERDIM hierarchy.
const body: Record<string, unknown> = {
name: input.name,
resets_annually: input.resets_annually,
}
if (input.sie_dim_no !== null) body.sie_dim_no = input.sie_dim_no
if (input.parent_sie_dim_no !== null) {
body.parent_sie_dim_no = input.parent_sie_dim_no
}
const res = await fetch('/api/dimensions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) throw json ?? new Error()
const createdId = (json?.data?.dimension as { id?: string } | undefined)?.id
toast({ title: t('dim_created_title') })
setNewDimDialogOpen(false)
await loadDimensions(false)
if (createdId) {
setActiveDimId(createdId)
setSearchTerm('')
}
} catch (err) {
toast({
title: t('save_failed_title'),
description: getErrorMessage(err, { locale: errorLocale }),
variant: 'destructive',
})
} finally {
setIsCreatingDimension(false)
}
}
async function handleDeleteValue() {
if (!activeDim || dialog?.mode !== 'edit') return
setIsSaving(true)
@@ -318,31 +379,49 @@ export default function DimensionsManager() {
return (
<div className="space-y-8">
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe, 6 Projekt) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Tabs
value={activeDimId ?? undefined}
onValueChange={(id) => {
setActiveDimId(id)
setSearchTerm('')
}}
>
<TabsList>
{dimensions.map((dim) => (
<TabsTrigger key={dim.id} value={dim.id}>
{dim.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Button
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
onClick={() => setDialog({ mode: 'create' })}
>
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('new_value')}
</Button>
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe,
6 Projekt, plus any custom 20+ dims) */}
<div className="space-y-2">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Tabs
value={activeDimId ?? undefined}
onValueChange={(id) => {
setActiveDimId(id)
setSearchTerm('')
}}
>
<TabsList>
{dimensions.map((dim) => (
<TabsTrigger key={dim.id} value={dim.id}>
{dim.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex items-center gap-3">
<Button
variant="outline"
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
onClick={() => setNewDimDialogOpen(true)}
>
{t('new_dimension')}
</Button>
<Button
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
onClick={() => setDialog({ mode: 'create' })}
>
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('new_value')}
</Button>
</div>
</div>
{parentDimName && (
<p className="text-xs text-muted-foreground">
{t('subdimension_of', { parent: parentDimName })}
</p>
)}
</div>
{/* Search */}
@@ -479,6 +558,181 @@ export default function DimensionsManager() {
)}
</DialogContent>
</Dialog>
{/* New dimension dialog — the content (and thus the form state)
unmounts on close, so each open starts from a blank form. */}
<Dialog
open={newDimDialogOpen}
onOpenChange={(open) => !open && setNewDimDialogOpen(false)}
>
<DialogContent className="sm:max-w-lg max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('new_dimension_title')}</DialogTitle>
</DialogHeader>
<NewDimensionForm
dimensions={dimensions}
isSaving={isCreatingDimension}
onSubmit={handleCreateDimension}
/>
</DialogContent>
</Dialog>
</div>
)
}
interface NewDimensionFormInput {
name: string
/** null → omit from the POST; the server picks the next free number ≥20. */
sie_dim_no: number | null
resets_annually: boolean
/** null → omit from the POST; no #UNDERDIM hierarchy. */
parent_sie_dim_no: number | null
}
/**
* Create form for a custom registry dimension (#DIM 20+). The parent
* (#UNDERDIM) select is an advanced, rarely-used SIE concept — kept behind a
* quiet disclosure so the common path stays name + number.
*/
function NewDimensionForm({
dimensions,
isSaving,
onSubmit,
}: {
/** Existing dims — active ones populate the parent select. */
dimensions: DimensionDto[]
isSaving: boolean
onSubmit: (input: NewDimensionFormInput) => void | Promise<void>
}) {
const t = useTranslations('dimensions')
const [name, setName] = useState('')
const [numberStr, setNumberStr] = useState('')
const [resetsAnnually, setResetsAnnually] = useState(true)
const [showAdvanced, setShowAdvanced] = useState(false)
const [parent, setParent] = useState('none')
const [nameError, setNameError] = useState<string | null>(null)
const [numberError, setNumberError] = useState<string | null>(null)
const parentOptions = dimensions.filter((d) => d.is_active)
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmedName = name.trim()
const trimmedNumber = numberStr.trim()
let valid = true
if (!trimmedName || trimmedName.length > 60) {
setNameError(t('dim_form_name_invalid'))
valid = false
} else {
setNameError(null)
}
let sieDimNo: number | null = null
if (trimmedNumber) {
const parsed = Number(trimmedNumber)
if (!Number.isInteger(parsed) || parsed < 20) {
setNumberError(t('dim_form_number_invalid'))
valid = false
} else {
setNumberError(null)
sieDimNo = parsed
}
} else {
setNumberError(null)
}
if (!valid) return
void onSubmit({
name: trimmedName,
sie_dim_no: sieDimNo,
resets_annually: resetsAnnually,
parent_sie_dim_no: parent === 'none' ? null : Number(parent),
})
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="new-dimension-name">{t('form_name_label')}</Label>
<Input
id="new-dimension-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
autoFocus
/>
{nameError && <p className="text-xs text-destructive">{nameError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="new-dimension-number">{t('dim_form_number_label')}</Label>
<Input
id="new-dimension-number"
type="number"
inputMode="numeric"
min={20}
step={1}
value={numberStr}
onChange={(e) => setNumberStr(e.target.value)}
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('dim_form_number_help')}</p>
{numberError && <p className="text-xs text-destructive">{numberError}</p>}
</div>
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="new-dimension-resets">{t('dim_form_resets_label')}</Label>
<p className="text-xs text-muted-foreground max-w-md">
{t('dim_form_resets_help')}
</p>
</div>
<Switch
id="new-dimension-resets"
checked={resetsAnnually}
onCheckedChange={setResetsAnnually}
/>
</div>
<div className="space-y-3">
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
aria-expanded={showAdvanced}
>
<ChevronRight
className={`h-3 w-3 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
aria-hidden="true"
/>
{t('dim_form_advanced')}
</button>
{showAdvanced && (
<div className="space-y-2">
<Label>{t('dim_form_parent_label')}</Label>
<Select value={parent} onValueChange={setParent}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('dim_form_parent_none')}</SelectItem>
{parentOptions.map((dim) => (
<SelectItem key={dim.id} value={String(dim.sie_dim_no)}>
{dim.name} ({dim.sie_dim_no})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('dim_form_parent_help')}</p>
</div>
)}
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('form_create')}
</Button>
</div>
</form>
)
}
+59 -28
View File
@@ -1,7 +1,12 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Label } from '@/components/ui/label'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import {
fetchDimensionsCached,
type DimensionDto,
} from '@/components/dimensions/types'
interface LineDimensionFieldsProps {
/** Current dimensions map ({sie_dim_no: object_code}) — a line's map or the header default. */
@@ -16,11 +21,23 @@ interface LineDimensionFieldsProps {
}
/**
* 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).
* While the registry loads (or if the fetch fails) we render the seeded
* system pair (SIE dims 1/6) so the tagging affordance never disappears —
* the registry always contains at least these two.
*/
const FALLBACK_FIELDS: { sieDimNo: string; label: string }[] = [
{ sieDimNo: '1', label: 'Kostnadsställe' },
{ sieDimNo: '6', label: 'Projekt' },
]
/**
* Registry-driven dimension comboboxes used by the voucher form's header
* default, the per-row tag popover, and the mobile line cards. One combobox
* per active registry dimension, ordered by sort_order then sie_dim_no (the
* seeded 1/6 pair sorts first). Labels are the registry dimension names and
* hardcoded-Swedish fallbacks — 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,
@@ -29,32 +46,46 @@ export default function LineDimensionFields({
stacked,
inputClassName,
}: LineDimensionFieldsProps) {
const [registry, setRegistry] = useState<DimensionDto[] | null>(null)
useEffect(() => {
let cancelled = false
fetchDimensionsCached()
.then((dims) => {
if (!cancelled) setRegistry(dims)
})
.catch(() => {
/* keep the hardcoded 1/6 fallback */
})
return () => {
cancelled = true
}
}, [])
const fields = useMemo(() => {
const active = registry?.filter((d) => d.is_active) ?? []
if (active.length === 0) return FALLBACK_FIELDS
return [...active]
.sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no)
.map((d) => ({ sieDimNo: String(d.sie_dim_no), label: d.name }))
}, [registry])
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}
/>
{fields.map((field) => (
<div key={field.sieDimNo}>
<Label className="text-xs text-muted-foreground">{field.label}</Label>
<div className="mt-1">
<DimensionCombobox
sieDimNo={field.sieDimNo}
value={dimensions?.[field.sieDimNo] ?? null}
onChange={(code) => onChange(field.sieDimNo, code)}
disabled={disabled}
className={inputClassName}
/>
</div>
</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>
)
}
+41 -1
View File
@@ -19,17 +19,38 @@ export interface DimensionValueDto {
export interface DimensionDto {
id: string
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt). */
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt, 20+ = custom). */
sie_dim_no: number
name: string
resets_annually: boolean
is_system: boolean
is_active: boolean
sort_order: number
/** SIE #UNDERDIM parent — the sie_dim_no of the parent dimension, or null. */
parent_sie_dim_no: number | null
/** Sorted by code by the API. */
values: DimensionValueDto[]
}
export type DimensionRuleType = 'required' | 'default' | 'fixed'
/**
* Per-account dimension rule as served by GET /api/dimensions/rules —
* a flattened join row (rule + dimension + optional pinned value).
*/
export interface AccountDimensionRuleDto {
account_dimension_rule_id: string
account_number: string
dimension_id: string
sie_dim_no: number
dimension_name: string
rule_type: DimensionRuleType
value_id: string | null
value_code: string | null
value_name: string | null
is_active: boolean
}
/** SIE dimension number whose values carry start/end dates (Projekt). */
export const PROJECT_DIM_NO = 6
@@ -54,3 +75,22 @@ export async function fetchDimensions(): Promise<DimensionDto[]> {
}
return (json?.dimensions ?? []) as DimensionDto[]
}
let cachedDimensionsPromise: Promise<DimensionDto[]> | null = null
/**
* Module-level cached variant of fetchDimensions for high-mount-count
* consumers (one registry fetch per page load instead of one per line
* picker). A failed fetch clears the cache so the next mount retries.
* Registry mutations are rare enough that staleness within a page visit
* is acceptable — the register UI uses the uncached fetch.
*/
export function fetchDimensionsCached(): Promise<DimensionDto[]> {
if (!cachedDimensionsPromise) {
cachedDimensionsPromise = fetchDimensions().catch((err) => {
cachedDimensionsPromise = null
throw err
})
}
return cachedDimensionsPromise
}