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:
co-authored by
Claude Fable 5
parent
70e893b8d4
commit
764348e99c
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user