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
@@ -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