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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
2c3ffaf51c
commit
a48508e5b0
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user