feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry (#858)
* feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry
Phase 2 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false (default) see zero change.
API:
- Dashboard CRUD: GET /api/dimensions (lazy-seeds system dims 1/6 via the
ensure_company_dimensions RPC), PATCH /api/dimensions/[id] (is_system
rename blocked), POST/PATCH/DELETE values (code immutable after creation;
strict Fortnox code format ^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$ at the API layer;
retention-trigger deletes surface the Swedish "arkivera istället" message
as 409 DIMENSION_VALUE_REFERENCED).
- POST /api/dimensions/import-existing — scans journal_entry_lines.dimensions
for unregistered codes and mints inactive placeholder registry rows.
- v1 public API: GET dimensions + POST values (Idempotency-Key, dry-run),
registered in the OpenAPI spec (102→104 endpoints).
- dimensions_enabled boolean on company_settings (new migration,
UI-visibility only, never correctness-bearing) exposed through the
existing settings read/update path.
SIE export (lib/reports/sie-export.ts):
- Reads the new dimensions/dimension_values registry; legacy
cost_centers/projects tables now have zero readers (drop migration next).
- Fixes the latent Visma-rejection bug: #OBJEKT now declared for INACTIVE
values referenced by lines.
- Generic-N: #DIM/#UNDERDIM loop sorted by sie_dim_no; #TRANS object lists
serialize from the line JSONB map (sorted, '01'→'1' collapse); orphan
codes/dims synthesize declarations from the SIE reserved-number seed —
every referenced (dim, code) pair is guaranteed declared.
UI:
- /dimensions register (Register-recipe): tabs per dimension, search,
sortable table, value dialog (code immutable on edit, projekt dates on
dim 6), archive-not-delete affordances.
- DimensionCombobox shipped (mounts in the tagging PR).
- Settings toggle "Aktivera kostnadsställen & projekt" — toggle-on runs the
import-existing scan and links to the register.
- Nav row in redovisning, rendered only when dimensions_enabled (same
mechanism as pays_salaries).
- dimensions.* i18n namespace (51 keys, sv/en parity).
- Sandbox seed: demo dims + values, revenue line tagged {"1":"BUTIK","6":"P001"}.
Verified: 6328/6328 unit tests, guard + coverage gate green, tsc parity with
main (210=210), production build passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dimensions): PR2 review round — atomic archived-create, UNDERDIM ordering, import robustness, date semantics
- POST values accepts is_active so "create as archived" is atomic; the UI's
fragile create-then-PATCH fallback is deleted (PR Agent finding 1).
- DimensionCombobox blur revert reads the committed value/values through refs
so a selection landing inside the 150ms window always wins (finding 2).
- import-existing sanitizes candidate codes like the PR1 backfill and upserts
with ignoreDuplicates — one bad/duplicate code can no longer abort the
batch; created counted from returned rows (finding 3).
- SIE export emits all root #DIM before any #UNDERDIM so a parent always
precedes a lower-numbered child (SIE4 declaration order — Swedish review);
synthesized placeholder declarations now log one structured warning
(BFNAR 2013:2 behandlingshistorik) + defence-in-depth comment.
- Value dates rejected (400 DIMENSION_VALUE_DATES_NOT_ALLOWED) when the
parent dimension is flow-period (resets_annually=true); explicit null
still clears (Swedish review).
- Sandbox seed logs seeded dimension codes; GET /api/dimensions documents
the deliberate absence of dimensions_enabled gating (UI-visibility flag,
not a security boundary — compliance-swarm V8.2.1 rejected by design).
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
8cc2efb083
commit
8bb49c07a2
@@ -30,6 +30,7 @@ import {
|
||||
HandCoins,
|
||||
Package,
|
||||
Tag,
|
||||
Tags,
|
||||
ChevronsUpDown,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
@@ -68,6 +69,10 @@ interface DashboardNavProps {
|
||||
// pays_salaries). Drives visibility of the payroll (Personal) section for
|
||||
// non-aktiebolag — notably an enskild firma that hires staff. See #782.
|
||||
paysSalaries?: boolean
|
||||
// Whether the dimensions register (company_settings.dimensions_enabled) is
|
||||
// switched on. Drives visibility of the Kostnadsställen & projekt row —
|
||||
// same mechanism as paysSalaries: fetched by the dashboard layout.
|
||||
dimensionsEnabled?: boolean
|
||||
uncategorizedTransactionCount?: number
|
||||
pendingOperationsCount?: number
|
||||
isSandbox?: boolean
|
||||
@@ -94,6 +99,7 @@ type NavLabelKey =
|
||||
| 'transactions'
|
||||
| 'bookkeeping'
|
||||
| 'chart_of_accounts'
|
||||
| 'dimensions'
|
||||
| 'assets'
|
||||
| 'reports'
|
||||
| 'import'
|
||||
@@ -123,6 +129,10 @@ interface NavItem {
|
||||
// behaviour) plus any company that has registered as an employer via
|
||||
// company_settings.pays_salaries (e.g. an enskild firma with staff). #782
|
||||
employerOnly?: boolean
|
||||
// Dimension surfaces — visible only when the company has opted in via
|
||||
// company_settings.dimensions_enabled (UI-visibility gate only; the pages
|
||||
// and APIs work regardless — dimensions plan §2).
|
||||
requiresDimensions?: boolean
|
||||
hidden?: boolean
|
||||
comingSoon?: boolean
|
||||
devBadge?: boolean
|
||||
@@ -147,6 +157,7 @@ const navItems: NavItem[] = [
|
||||
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'redovisning' },
|
||||
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' },
|
||||
{ href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'redovisning' },
|
||||
{ href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'redovisning', requiresDimensions: true },
|
||||
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' },
|
||||
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
|
||||
{ href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
|
||||
@@ -185,7 +196,7 @@ function accountInitial(name: string | null, email: string | null): string {
|
||||
return '?'
|
||||
}
|
||||
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const supabase = useRealtimeSupabase()
|
||||
@@ -351,6 +362,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
// Payroll (employerOnly) is hidden until the company is an employer — an
|
||||
// aktiebolag, or any entity that has flagged pays_salaries. #782
|
||||
if (item.employerOnly && !isEmployer) return false
|
||||
// Dimension surfaces are hidden until the company opts in via the
|
||||
// bookkeeping settings toggle (company_settings.dimensions_enabled).
|
||||
if (item.requiresDimensions && !dimensionsEnabled) return false
|
||||
// Hide the Assistent (/chat) tab until the agent is built — mirrors the
|
||||
// floating AgentTrigger and avoids a nav entry that only bounces to the
|
||||
// home checklist (chat/layout redirects unverified users to /).
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, 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'
|
||||
import {
|
||||
DIMENSION_CODE_PATTERN,
|
||||
fetchDimensions,
|
||||
type DimensionValueDto,
|
||||
} from '@/components/dimensions/types'
|
||||
|
||||
interface DimensionComboboxProps {
|
||||
/** SIE dimension number as a string ('1' = kostnadsställe, '6' = projekt). */
|
||||
sieDimNo: string
|
||||
/** Selected object code, or null when the line carries no value for this dim. */
|
||||
value: string | null
|
||||
onChange: (code: string | null) => void
|
||||
disabled?: boolean
|
||||
/** Extra classes merged into the trigger Input (callers pass `h-8` for dense rows). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable picker for dimension values — sibling of
|
||||
* components/bookkeeping/AccountCombobox.tsx and deliberately mirrors its
|
||||
* interaction model: plain Input trigger, keyboard-first dropdown
|
||||
* (arrows/Enter/Escape), close-on-outside-click, and an inline "Skapa ny…"
|
||||
* affordance that creates the value via POST /api/dimensions/[id]/values and
|
||||
* selects it.
|
||||
*
|
||||
* Fetches the registry lazily on first open and filters client-side (value
|
||||
* lists are small). Only active values are offered — archived codes stay
|
||||
* pickable in history but are never suggested.
|
||||
*
|
||||
* Strings are hardcoded Swedish per the AccountCombobox convention: the
|
||||
* component mounts on the voucher editor (PR3), a stays-Swedish surface per
|
||||
* .claude/rules/i18n.md.
|
||||
*/
|
||||
export default function DimensionCombobox({
|
||||
sieDimNo,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: DimensionComboboxProps) {
|
||||
const [search, setSearch] = useState(value ?? '')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
const [loadState, setLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle')
|
||||
const [dimensionId, setDimensionId] = useState<string | null>(null)
|
||||
const [values, setValues] = useState<DimensionValueDto[]>([])
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Refs mirroring the committed `value` prop and the fetched values. The
|
||||
// blur timeout below runs 150ms after render, so reading `value`/`values`
|
||||
// directly would act on a stale snapshot — a selection landing during that
|
||||
// window (updating the prop via onChange) must win over the revert.
|
||||
const committedRef = useRef(value)
|
||||
const valuesRef = useRef(values)
|
||||
|
||||
// Sync external value changes into the search field
|
||||
useEffect(() => {
|
||||
committedRef.current = value
|
||||
setSearch(value ?? '')
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
valuesRef.current = values
|
||||
}, [values])
|
||||
|
||||
const loadValues = useCallback(async () => {
|
||||
setLoadState('loading')
|
||||
try {
|
||||
const dims = await fetchDimensions()
|
||||
const dim = dims.find((d) => String(d.sie_dim_no) === sieDimNo)
|
||||
setDimensionId(dim?.id ?? null)
|
||||
setValues(dim?.values.filter((v) => v.is_active) ?? [])
|
||||
setLoadState('loaded')
|
||||
} catch {
|
||||
setLoadState('error')
|
||||
}
|
||||
}, [sieDimNo])
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
setCreateError(null)
|
||||
if (loadState === 'idle') void loadValues()
|
||||
}, [loadState, loadValues])
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
const term = search.trim().toLowerCase()
|
||||
if (!term) return values
|
||||
return values.filter(
|
||||
(v) =>
|
||||
v.code.toLowerCase().includes(term) || v.name.toLowerCase().includes(term),
|
||||
)
|
||||
}, [values, search])
|
||||
|
||||
// Inline create is offered when the typed text is a valid new code.
|
||||
const createCandidate = useMemo(() => {
|
||||
const term = search.trim()
|
||||
if (!term || !dimensionId) return null
|
||||
if (!DIMENSION_CODE_PATTERN.test(term)) return null
|
||||
if (values.some((v) => v.code.toLowerCase() === term.toLowerCase())) return null
|
||||
return term
|
||||
}, [search, values, dimensionId])
|
||||
|
||||
// Keyboard list: matching values first, the create affordance last.
|
||||
const optionCount = filteredValues.length + (createCandidate ? 1 : 0)
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(0)
|
||||
}, [filteredValues, createCandidate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !listRef.current) return
|
||||
const highlighted = listRef.current.querySelector('[data-highlighted="true"]')
|
||||
if (highlighted) highlighted.scrollIntoView({ block: 'nearest' })
|
||||
}, [highlightedIndex, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent | TouchEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('touchstart', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('touchstart', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectValue = useCallback(
|
||||
(code: string) => {
|
||||
onChange(code)
|
||||
setSearch(code)
|
||||
setIsOpen(false)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const createValue = useCallback(
|
||||
async (code: string) => {
|
||||
if (!dimensionId || isCreating) return
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/dimensions/${dimensionId}/values`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, name: code }),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
setCreateError(getErrorMessage(json, { locale: 'sv' }))
|
||||
return
|
||||
}
|
||||
const created: DimensionValueDto = json?.data ?? {
|
||||
id: code,
|
||||
code,
|
||||
name: code,
|
||||
is_active: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
}
|
||||
setValues((prev) => [...prev, created].sort((a, b) => a.code.localeCompare(b.code, 'sv')))
|
||||
selectValue(created.code)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
},
|
||||
[dimensionId, isCreating, selectValue],
|
||||
)
|
||||
|
||||
const activateOption = useCallback(
|
||||
(index: number) => {
|
||||
if (index < filteredValues.length) {
|
||||
selectValue(filteredValues[index].code)
|
||||
} else if (createCandidate) {
|
||||
void createValue(createCandidate)
|
||||
}
|
||||
},
|
||||
[filteredValues, createCandidate, selectValue, createValue],
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
openDropdown()
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => Math.min(prev + 1, optionCount - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (optionCount > 0) activateOption(highlightedIndex)
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value)
|
||||
setCreateError(null)
|
||||
if (!isOpen) openDropdown()
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsOpen(false)
|
||||
// Small delay so a dropdown mousedown fires first (same trick as
|
||||
// AccountCombobox — option clicks preventDefault, so they never blur).
|
||||
// An emptied field clears the dimension; anything that isn't a known
|
||||
// code reverts to the committed value. Committed value and values are
|
||||
// read through refs so a selection that lands during the 150ms window
|
||||
// wins over the revert (the closure's render snapshot would be stale).
|
||||
const snapshot = search
|
||||
setTimeout(() => {
|
||||
const committed = committedRef.current
|
||||
const trimmed = snapshot.trim()
|
||||
if (!trimmed) {
|
||||
setSearch('')
|
||||
if (committed !== null) onChange(null)
|
||||
return
|
||||
}
|
||||
if (trimmed !== committed && !valuesRef.current.some((v) => v.code === trimmed)) {
|
||||
setSearch(committed ?? '')
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={handleInputChange}
|
||||
onFocus={openDropdown}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Sök värde…"
|
||||
disabled={disabled}
|
||||
className={`font-mono ${className ?? ''}`.trim()}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && !disabled && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 top-full left-0 mt-1 min-w-[16rem] w-[max(100%,20rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
|
||||
>
|
||||
{loadState === 'loading' && (
|
||||
<div className="flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Laddar…
|
||||
</div>
|
||||
)}
|
||||
{loadState === 'error' && (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">
|
||||
Kunde inte hämta värden.
|
||||
</p>
|
||||
)}
|
||||
{loadState === 'loaded' && optionCount === 0 && (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">
|
||||
Hittade inget värde som matchar.
|
||||
</p>
|
||||
)}
|
||||
{loadState === 'loaded' &&
|
||||
filteredValues.map((item, index) => {
|
||||
const isHighlighted = index === highlightedIndex
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-highlighted={isHighlighted}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
|
||||
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
selectValue(item.code)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<span className="font-mono shrink-0">{item.code}</span>
|
||||
<span className="flex-1 min-w-0 break-words text-muted-foreground">
|
||||
{item.name !== item.code ? item.name : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{loadState === 'loaded' && createCandidate && (
|
||||
<button
|
||||
type="button"
|
||||
data-highlighted={highlightedIndex === filteredValues.length}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-center gap-2 border-t border-input ${
|
||||
highlightedIndex === filteredValues.length
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
void createValue(createCandidate)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(filteredValues.length)}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">Skapa ny "{createCandidate}"</span>
|
||||
</button>
|
||||
)}
|
||||
{createError && (
|
||||
<p className="px-2 py-1.5 text-xs text-destructive border-t border-input">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { Loader2, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
DIMENSION_CODE_PATTERN,
|
||||
PROJECT_DIM_NO,
|
||||
type DimensionDto,
|
||||
type DimensionValueDto,
|
||||
} from '@/components/dimensions/types'
|
||||
|
||||
export interface DimensionValueFormInput {
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
interface DimensionValueFormProps {
|
||||
/** The dimension the value belongs to (drives the Projekt date fields). */
|
||||
dimension: DimensionDto
|
||||
/** When set, the form edits this value (code becomes immutable). */
|
||||
value?: DimensionValueDto | null
|
||||
isSaving?: boolean
|
||||
onSubmit: (input: DimensionValueFormInput) => void | Promise<void>
|
||||
/** Rendered only when editing. The caller performs the DELETE and surfaces
|
||||
* the retention-trigger error ("…arkivera det istället") as a toast. */
|
||||
onDelete?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/edit form for a dimension value (#OBJEKT), hosted in the
|
||||
* DimensionsManager dialog. Code is immutable in v1 — the field is disabled
|
||||
* when editing. Start/end dates appear only for Projekt (dim 6), matching the
|
||||
* SIE model where projects span a date range while cost centres do not.
|
||||
*/
|
||||
export default function DimensionValueForm({
|
||||
dimension,
|
||||
value,
|
||||
isSaving = false,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}: DimensionValueFormProps) {
|
||||
const t = useTranslations('dimensions')
|
||||
const isEditing = Boolean(value)
|
||||
const isProject = dimension.sie_dim_no === PROJECT_DIM_NO
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
// Legacy/backfilled codes can be looser than the strict format, so
|
||||
// the pattern is only enforced when the code is user-typed (create).
|
||||
code: isEditing
|
||||
? z.string()
|
||||
: z.string().regex(DIMENSION_CODE_PATTERN, t('form_code_invalid')),
|
||||
name: z.string().min(1, t('form_name_invalid')).max(120, t('form_name_invalid')),
|
||||
is_active: z.boolean(),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
!data.start_date || !data.end_date || data.end_date >= data.start_date,
|
||||
{ message: t('form_date_order_invalid'), path: ['end_date'] },
|
||||
),
|
||||
[isEditing, t],
|
||||
)
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
code: value?.code ?? '',
|
||||
name: value?.name ?? '',
|
||||
is_active: value?.is_active ?? true,
|
||||
start_date: value?.start_date ?? '',
|
||||
end_date: value?.end_date ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
function submit(data: FormData) {
|
||||
return onSubmit({
|
||||
code: data.code.trim(),
|
||||
name: data.name.trim(),
|
||||
is_active: data.is_active,
|
||||
start_date: isProject && data.start_date ? data.start_date : null,
|
||||
end_date: isProject && data.end_date ? data.end_date : null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submit)} className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension-value-code">{t('form_code_label')}</Label>
|
||||
<Input
|
||||
id="dimension-value-code"
|
||||
{...register('code')}
|
||||
disabled={isEditing}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
autoFocus={!isEditing}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEditing ? t('form_code_immutable_help') : t('form_code_help')}
|
||||
</p>
|
||||
{errors.code && (
|
||||
<p className="text-xs text-destructive">{errors.code.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension-value-name">{t('form_name_label')}</Label>
|
||||
<Input
|
||||
id="dimension-value-name"
|
||||
{...register('name')}
|
||||
autoComplete="off"
|
||||
autoFocus={isEditing}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProject && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension-value-start">{t('form_start_label')}</Label>
|
||||
<Input
|
||||
id="dimension-value-start"
|
||||
type="date"
|
||||
{...register('start_date')}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension-value-end">{t('form_end_label')}</Label>
|
||||
<Input
|
||||
id="dimension-value-end"
|
||||
type="date"
|
||||
{...register('end_date')}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
{errors.end_date && (
|
||||
<p className="text-xs text-destructive">{errors.end_date.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dimension-value-active">{t('form_active_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
{t('form_active_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
name="is_active"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="dimension-value-active"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
{isEditing && onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isSaving}
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('form_delete')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isEditing ? t('form_save') : t('form_create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{onDelete && (
|
||||
<DestructiveConfirmDialog
|
||||
open={confirmDeleteOpen}
|
||||
onOpenChange={setConfirmDeleteOpen}
|
||||
title={t('delete_confirm_title')}
|
||||
description={t('delete_confirm_description', { code: value?.code ?? '' })}
|
||||
confirmLabel={t('delete_confirm_label')}
|
||||
cancelLabel={t('delete_cancel_label')}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Lock,
|
||||
Tags,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
} from 'lucide-react'
|
||||
import DimensionValueForm, {
|
||||
type DimensionValueFormInput,
|
||||
} from '@/components/dimensions/DimensionValueForm'
|
||||
import {
|
||||
fetchDimensions,
|
||||
PROJECT_DIM_NO,
|
||||
type DimensionDto,
|
||||
type DimensionValueDto,
|
||||
} from '@/components/dimensions/types'
|
||||
|
||||
type SortColumn = 'code' | 'name' | 'status' | 'start_date' | 'end_date'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
type DialogState =
|
||||
| { mode: 'create' }
|
||||
| { mode: 'edit'; value: DimensionValueDto }
|
||||
| null
|
||||
|
||||
function compareStrings(a: string, b: string): number {
|
||||
return a.localeCompare(b, 'sv', { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register for dimension values (kostnadsställen & projekt) — the
|
||||
* customers-page register recipe hosted behind one segmented tab per registry
|
||||
* dimension. Archive rides the edit form's aktiv switch (PATCH is_active);
|
||||
* delete lives only in the edit dialog and surfaces the DB retention
|
||||
* trigger's Swedish "…arkivera det istället" message when the value is
|
||||
* referenced by posted lines.
|
||||
*/
|
||||
export default function DimensionsManager() {
|
||||
const t = useTranslations('dimensions')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
const [dimensions, setDimensions] = useState<DimensionDto[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [activeDimId, setActiveDimId] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('code')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
const [dialog, setDialog] = useState<DialogState>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const loadDimensions = useCallback(
|
||||
async (showSpinner: boolean) => {
|
||||
if (showSpinner) setIsLoading(true)
|
||||
try {
|
||||
const dims = await fetchDimensions()
|
||||
const sorted = [...dims].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no,
|
||||
)
|
||||
setDimensions(sorted)
|
||||
setLoadFailed(false)
|
||||
setActiveDimId((prev) =>
|
||||
prev && sorted.some((d) => d.id === prev) ? prev : (sorted[0]?.id ?? null),
|
||||
)
|
||||
} catch (err) {
|
||||
setLoadFailed(true)
|
||||
toast({
|
||||
title: t('load_failed_title'),
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[toast, t, errorLocale],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadDimensions(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const activeDim = useMemo(
|
||||
() => dimensions.find((d) => d.id === activeDimId) ?? null,
|
||||
[dimensions, activeDimId],
|
||||
)
|
||||
const isProjectTab = activeDim?.sie_dim_no === PROJECT_DIM_NO
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
if (!activeDim) return []
|
||||
const term = searchTerm.trim().toLowerCase()
|
||||
if (!term) return activeDim.values
|
||||
return activeDim.values.filter(
|
||||
(v) =>
|
||||
v.code.toLowerCase().includes(term) || v.name.toLowerCase().includes(term),
|
||||
)
|
||||
}, [activeDim, searchTerm])
|
||||
|
||||
const sortedValues = useMemo(() => {
|
||||
const arr = [...filteredValues]
|
||||
arr.sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortColumn) {
|
||||
case 'code':
|
||||
cmp = compareStrings(a.code, b.code)
|
||||
break
|
||||
case 'name':
|
||||
cmp = compareStrings(a.name, b.name)
|
||||
break
|
||||
case 'status':
|
||||
cmp = Number(b.is_active) - Number(a.is_active) || compareStrings(a.code, b.code)
|
||||
break
|
||||
case 'start_date':
|
||||
cmp = compareStrings(a.start_date ?? '', b.start_date ?? '')
|
||||
break
|
||||
case 'end_date':
|
||||
cmp = compareStrings(a.end_date ?? '', b.end_date ?? '')
|
||||
break
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return arr
|
||||
}, [filteredValues, sortColumn, sortDir])
|
||||
|
||||
const updateSort = useCallback(
|
||||
(column: SortColumn) => {
|
||||
if (column === sortColumn) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDir('asc')
|
||||
}
|
||||
},
|
||||
[sortColumn],
|
||||
)
|
||||
|
||||
async function handleSubmitValue(input: DimensionValueFormInput) {
|
||||
if (!activeDim || !dialog) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (dialog.mode === 'edit') {
|
||||
const res = await fetch(
|
||||
`/api/dimensions/${activeDim.id}/values/${dialog.value.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
is_active: input.is_active,
|
||||
start_date: input.start_date,
|
||||
end_date: input.end_date,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw json ?? new Error()
|
||||
toast({ title: t('updated_title') })
|
||||
} else {
|
||||
// "Create as archived" rides the create contract's is_active field —
|
||||
// one atomic POST, no follow-up PATCH.
|
||||
const body: Record<string, unknown> = {
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
is_active: input.is_active,
|
||||
}
|
||||
if (input.start_date) body.start_date = input.start_date
|
||||
if (input.end_date) body.end_date = input.end_date
|
||||
const res = await fetch(`/api/dimensions/${activeDim.id}/values`, {
|
||||
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()
|
||||
toast({
|
||||
title: t('created_title'),
|
||||
description: t('created_description', { code: input.code }),
|
||||
})
|
||||
}
|
||||
setDialog(null)
|
||||
await loadDimensions(false)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('save_failed_title'),
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteValue() {
|
||||
if (!activeDim || dialog?.mode !== 'edit') return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/dimensions/${activeDim.id}/values/${dialog.value.id}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null)
|
||||
// Values referenced by posted lines cannot be deleted — the DB
|
||||
// retention trigger's Swedish message ("…arkivera det istället")
|
||||
// rides the error envelope; surface it verbatim.
|
||||
toast({
|
||||
title: t('delete_failed_title'),
|
||||
description: getErrorMessage(json, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('deleted_title') })
|
||||
setDialog(null)
|
||||
await loadDimensions(false)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
column,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
column: SortColumn
|
||||
label: string
|
||||
className?: string
|
||||
}) {
|
||||
const isActive = sortColumn === column
|
||||
const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSort(column)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{label}
|
||||
<Icon className="h-3 w-3 opacity-70" aria-hidden="true" />
|
||||
</button>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStatusBadge(value: DimensionValueDto) {
|
||||
return value.is_active ? (
|
||||
<Badge variant="success">{t('status_active')}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{t('status_archived')}</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadFailed && dimensions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={t('load_failed_title')}
|
||||
description={t('load_failed_description')}
|
||||
actionLabel={t('retry')}
|
||||
onAction={() => void loadDimensions(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('search_placeholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value list */}
|
||||
{sortedValues.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{searchTerm ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title={t('no_search_results_title')}
|
||||
description={t('no_search_results_description', { term: searchTerm })}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description', { dimension: activeDim?.name ?? '' })}
|
||||
actionLabel={canWrite ? t('new_value') : undefined}
|
||||
onAction={canWrite ? () => setDialog({ mode: 'create' }) : undefined}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<Card className="hidden md:block">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHeader column="code" label={t('col_code')} />
|
||||
<SortableHeader column="name" label={t('col_name')} />
|
||||
<SortableHeader column="status" label={t('col_status')} />
|
||||
{isProjectTab && (
|
||||
<>
|
||||
<SortableHeader column="start_date" label={t('col_start')} />
|
||||
<SortableHeader column="end_date" label={t('col_end')} />
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedValues.map((value) => (
|
||||
<TableRow
|
||||
key={value.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDialog({ mode: 'edit', value })}
|
||||
>
|
||||
<TableCell className="font-mono">{value.code}</TableCell>
|
||||
<TableCell className="font-medium">{value.name}</TableCell>
|
||||
<TableCell>{renderStatusBadge(value)}</TableCell>
|
||||
{isProjectTab && (
|
||||
<>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{value.start_date ? formatDate(value.start_date) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{value.end_date ? formatDate(value.end_date) : '—'}
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="grid gap-4 md:hidden">
|
||||
{sortedValues.map((value) => (
|
||||
<Card
|
||||
key={value.id}
|
||||
className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60"
|
||||
onClick={() => setDialog({ mode: 'edit', value })}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-sm">{value.code}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{value.name}
|
||||
</p>
|
||||
</div>
|
||||
{renderStatusBadge(value)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isProjectTab && (value.start_date || value.end_date) && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{value.start_date ? formatDate(value.start_date) : '—'}
|
||||
{' – '}
|
||||
{value.end_date ? formatDate(value.end_date) : '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create/edit dialog */}
|
||||
<Dialog open={dialog !== null} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{dialog?.mode === 'edit'
|
||||
? t('edit_value_title')
|
||||
: t('new_value_title', { dimension: activeDim?.name ?? '' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{activeDim && dialog && (
|
||||
<DimensionValueForm
|
||||
key={dialog.mode === 'edit' ? dialog.value.id : 'create'}
|
||||
dimension={activeDim}
|
||||
value={dialog.mode === 'edit' ? dialog.value : null}
|
||||
isSaving={isSaving}
|
||||
onSubmit={handleSubmitValue}
|
||||
onDelete={dialog.mode === 'edit' ? handleDeleteValue : undefined}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Client-side contract types for the dimensions registry API (PR2 of
|
||||
* dev_docs/dimensions_implementation_plan.md).
|
||||
*
|
||||
* The routes live under /api/dimensions and are built against the same locked
|
||||
* contract — this module codes against the contract, not the route files, so
|
||||
* the register UI (DimensionsManager) and the shared picker (DimensionCombobox)
|
||||
* can ship independently of the API package.
|
||||
*/
|
||||
|
||||
export interface DimensionValueDto {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
export interface DimensionDto {
|
||||
id: string
|
||||
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt). */
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
resets_annually: boolean
|
||||
is_system: boolean
|
||||
is_active: boolean
|
||||
sort_order: number
|
||||
/** Sorted by code by the API. */
|
||||
values: DimensionValueDto[]
|
||||
}
|
||||
|
||||
/** SIE dimension number whose values carry start/end dates (Projekt). */
|
||||
export const PROJECT_DIM_NO = 6
|
||||
|
||||
/**
|
||||
* Strict Fortnox-compatible code format enforced by the API for user-created
|
||||
* codes (the DB CHECK is deliberately looser so legacy free-text survives the
|
||||
* backfill). Mirrored client-side for inline validation before POST.
|
||||
*/
|
||||
export const DIMENSION_CODE_PATTERN = /^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/
|
||||
|
||||
/**
|
||||
* Load the company's dimension registry. The handler lazily seeds system dims
|
||||
* 1/6 via ensure_company_dimensions, so the result always contains at least
|
||||
* Kostnadsställe + Projekt. Throws the parsed error envelope on failure so
|
||||
* callers can hand it straight to getErrorMessage().
|
||||
*/
|
||||
export async function fetchDimensions(): Promise<DimensionDto[]> {
|
||||
const res = await fetch('/api/dimensions')
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw json ?? new Error('Failed to load dimensions')
|
||||
}
|
||||
return (json?.dimensions ?? []) as DimensionDto[]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* Company-level toggle for the dimensions register (kostnadsställen &
|
||||
* projekt). Persists company_settings.dimensions_enabled through the standard
|
||||
* settings PUT — the flag gates UI visibility only (nav row + register),
|
||||
* never correctness (dimensions plan §2).
|
||||
*
|
||||
* Toggling ON runs the "Importera befintliga koder" scan
|
||||
* (POST /api/dimensions/import-existing): codes already present on
|
||||
* journal_entry_lines.dimensions but missing from the registry are created as
|
||||
* archived placeholder values, and the user is told how many were found.
|
||||
*/
|
||||
export function DimensionsToggle() {
|
||||
const t = useTranslations('dimensions')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { settings, updateSettings } = useSettings()
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const enabled = settings?.dimensions_enabled ?? false
|
||||
|
||||
async function handleChange(next: boolean) {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dimensions_enabled: next }),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: t('settings_save_failed_title'),
|
||||
description: getErrorMessage(json, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
updateSettings({ dimensions_enabled: next })
|
||||
|
||||
if (next) {
|
||||
// Import scan: registry rows for codes already used on lines. Failure
|
||||
// is non-fatal — the toggle stays on and the scan can be re-run by
|
||||
// toggling again.
|
||||
try {
|
||||
const importRes = await fetch('/api/dimensions/import-existing', {
|
||||
method: 'POST',
|
||||
})
|
||||
const importJson = await importRes.json().catch(() => null)
|
||||
if (importRes.ok) {
|
||||
const created: number =
|
||||
importJson?.created ?? importJson?.data?.created ?? 0
|
||||
if (created > 0) {
|
||||
toast({
|
||||
title: t('settings_imported_toast_title'),
|
||||
description: t('settings_imported_toast', { count: created }),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: t('settings_import_failed_title'),
|
||||
description: getErrorMessage(importJson, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: t('settings_import_failed_title'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('settings_heading')}
|
||||
</h2>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dimensions-enabled" className="text-sm">
|
||||
{t('settings_toggle_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
{t('settings_toggle_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="dimensions-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(next) => void handleChange(next)}
|
||||
disabled={isSaving || !canWrite}
|
||||
/>
|
||||
</div>
|
||||
{enabled && (
|
||||
<Link
|
||||
href="/dimensions"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('settings_open_register')}
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager
|
||||
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
|
||||
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
|
||||
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
|
||||
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -165,6 +166,11 @@ export function BookkeepingSettingsContent() {
|
||||
<PeriodiseringAutoDetectToggle />
|
||||
</div>
|
||||
|
||||
{/* Kostnadsställen & projekt (dimensions) toggle */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<DimensionsToggle />
|
||||
</div>
|
||||
|
||||
{/* Cross-links */}
|
||||
<div className="border-t border-border pt-8 space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user