feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219) (#2255)

* feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219)

Two things from the same Discord report, both in bookkeeping from the
transaction view:

1. After picking a kostnadsställe the field showed only the code ("1").
   DimensionCombobox now writes the value's full name under the field once
   a code is committed, exactly as AccountCombobox does for the account
   name (looked up in the full registry so an archived code stays
   readable). The input text itself stays the code: the blur/revert logic
   keys on it.

2. A self-created dimension could not be removed at all: the DB already
   allowed it (enforce_dimension_registry_guards lets a non-system
   dimension go when no posted/reversed line carries its number, and the
   value retention trigger fires on the cascade), but no route or UI
   asked. New DELETE /api/dimensions/[id]: 400 DIMENSION_SYSTEM_DELETE for
   kostnadsställe/projekt, the guard's own Swedish P0001 verbatim as 409
   DIMENSION_REFERENCED, 404, and a happy path; the register gets a quiet
   "Ta bort dimension" link for the active custom dimension behind a
   DestructiveConfirmDialog. Keys added to sv and en.

Closes #2219

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

* test: satisfy the TypeScript ratchet for two test files main inherited from #2247 and #2242

accounts-route.test.ts built SyncResult literals without the
requestedFromDate / historyNarrowed fields #2247 added (vitest does not
typecheck, so it passed locally); fiscal-periods route.test.ts got two
more one-argument POST(req) calls from #2242 in a file already at its
ratchet baseline. Both files now typecheck; the ratchet runs clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 19:20:09 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 2c3ffaf51c
commit a48508e5b0
10 changed files with 269 additions and 9 deletions
+27 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { Loader2, Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -65,6 +65,13 @@ export default function DimensionCombobox({
() => dimension?.values.filter((v) => v.is_active) ?? [],
[dimension],
)
// The committed value's registry row, looked up in the FULL list: an
// archived code stays readable in history even though it is never offered.
const selected = useMemo(
() => (value ? dimension?.values.find((v) => v.code === value) ?? null : null),
[dimension, value],
)
const selectedNameId = useId()
const [isCreating, setIsCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
@@ -249,6 +256,14 @@ export default function DimensionCombobox({
}, 150)
}
// After a pick the field holds only the code ("1"), which told the user
// nothing (issue #2219). Write the value's full name under it, exactly as
// AccountCombobox does for the account name, whenever the field shows the
// committed code and the name adds something beyond the code itself.
const showSelectedName = Boolean(
selected && selected.name !== selected.code && value !== null && search === value,
)
return (
<div ref={containerRef} className="relative">
<Input
@@ -261,8 +276,19 @@ export default function DimensionCombobox({
disabled={disabled}
className={`font-mono ${className ?? ''}`.trim()}
autoComplete="off"
aria-describedby={showSelectedName ? selectedNameId : undefined}
/>
{showSelectedName && selected ? (
<p
id={selectedNameId}
data-ph-mask=""
className="mt-1 break-words px-1 text-xs leading-snug text-muted-foreground"
>
{selected.name}
</p>
) : null}
{/* Dropdown */}
{isOpen && !disabled && (
<div
@@ -12,6 +12,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import {
Dialog,
@@ -98,6 +99,8 @@ export default function DimensionsManager() {
const [dialog, setDialog] = useState<DialogState>(null)
const [isSaving, setIsSaving] = useState(false)
const [newDimDialogOpen, setNewDimDialogOpen] = useState(false)
const [confirmDeleteDimOpen, setConfirmDeleteDimOpen] = useState(false)
const [isDeletingDim, setIsDeletingDim] = useState(false)
const [isCreatingDimension, setIsCreatingDimension] = useState(false)
useEffect(() => {
@@ -270,6 +273,34 @@ export default function DimensionsManager() {
}
}
// Remove a custom dimension nobody has booked on (issue #2219). The DB
// registry guard refuses the delete with a Swedish P0001 when any posted
// line carries the number; the route passes that message through, so the
// toast says exactly which dimension and why.
async function handleDeleteDimension() {
if (!activeDim || activeDim.is_system) return
setIsDeletingDim(true)
try {
const res = await fetch(`/api/dimensions/${activeDim.id}`, { method: 'DELETE' })
if (!res.ok) {
const json = await res.json().catch(() => null)
toast({
title: t('delete_failed_title'),
description: getErrorMessage(json, { locale: errorLocale }),
variant: 'destructive',
})
return
}
toast({ title: t('dimension_deleted_title') })
setConfirmDeleteDimOpen(false)
setActiveDimId(null)
setSearchTerm('')
await loadDimensions()
} finally {
setIsDeletingDim(false)
}
}
async function handleDeleteValue() {
if (!activeDim || dialog?.mode !== 'edit') return
setIsSaving(true)
@@ -398,6 +429,17 @@ export default function DimensionsManager() {
>
{t('new_dimension')}
</button>
{activeDim && !activeDim.is_system && (
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'disabled:pointer-events-none disabled:opacity-50')}
disabled={!canWrite || isDeletingDim}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
onClick={() => setConfirmDeleteDimOpen(true)}
>
{t('delete_dimension')}
</button>
)}
<Button
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
@@ -515,6 +557,18 @@ export default function DimensionsManager() {
{/* New dimension dialog — the content (and thus the form state)
unmounts on close, so each open starts from a blank form. */}
{activeDim && !activeDim.is_system && (
<DestructiveConfirmDialog
open={confirmDeleteDimOpen}
onOpenChange={setConfirmDeleteDimOpen}
title={t('delete_dimension_confirm_title')}
description={t('delete_dimension_confirm_description', { name: activeDim.name })}
confirmLabel={t('delete_confirm_label')}
cancelLabel={t('delete_cancel_label')}
onConfirm={handleDeleteDimension}
/>
)}
<Dialog
open={newDimDialogOpen}
onOpenChange={(open) => !open && setNewDimDialogOpen(false)}