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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user