'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { useDimensions } from '@/lib/reference-data/hooks' import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' 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 { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' 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 { cn, formatDate } from '@/lib/utils' import { Plus, Search, Lock, Tags, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Loader2, } from 'lucide-react' import DimensionValueForm, { type DimensionValueFormInput, } from '@/components/dimensions/DimensionValueForm' import { 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() // The register renders the same session-cached registry the pickers use // (lib/reference-data); every write below invalidates it. const { dimensions: registry, isLoading, error: loadError, refresh: refreshDimensions } = useDimensions() const dimensions = useMemo( () => [...registry].sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no), [registry], ) const loadFailed = !!loadError // The requested tab, validated against the current registry: a dimension // that disappears falls back to the first one. const [requestedDimId, setActiveDimId] = useState(null) const activeDimId = requestedDimId && dimensions.some((d) => d.id === requestedDimId) ? requestedDimId : (dimensions[0]?.id ?? null) const [searchTerm, setSearchTerm] = useState('') const [sortColumn, setSortColumn] = useState('code') const [sortDir, setSortDir] = useState('asc') const [dialog, setDialog] = useState(null) const [isSaving, setIsSaving] = useState(false) const [newDimDialogOpen, setNewDimDialogOpen] = useState(false) const [isCreatingDimension, setIsCreatingDimension] = useState(false) useEffect(() => { if (!loadError) return toast({ title: t('load_failed_title'), description: getErrorMessage(loadError, { locale: errorLocale }), variant: 'destructive', }) }, [loadError, toast, t, errorLocale]) /** After a write: refresh the shared registry (this list and every picker). */ const loadDimensions = useCallback(() => invalidateReferenceData('ref:dimensions'), []) const activeDim = useMemo( () => dimensions.find((d) => d.id === activeDimId) ?? null, [dimensions, activeDimId], ) 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() 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 = { 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() } catch (err) { toast({ title: t('save_failed_title'), description: getErrorMessage(err, { locale: errorLocale }), variant: 'destructive', }) } finally { setIsSaving(false) } } 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 = { 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() 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) 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() } 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 ( ) } function renderStatus(value: DimensionValueDto) { return value.is_active ? ( {t('status_active')} ) : ( {t('status_archived')} ) } if (isLoading) { return (
{[1, 2, 3, 4, 5].map((i) => ( ))}
) } if (loadFailed && dimensions.length === 0) { return ( void refreshDimensions()} /> ) } return (
{/* Toolbar (concept scene 31): seg per registry dimension with a muted value count, search, and the actions far right. */}
{ setActiveDimId(id) setSearchTerm('') }} options={dimensions.map((dim) => ({ value: dim.id, label: ( // data-ph-mask: dimension names are user data; the wrapper // keeps the segment button's inline gap between name and count. {dim.name} {dim.values.length > 0 && ( {dim.values.length} )} ), }))} /> setSearchTerm(e.target.value)} containerClassName="min-w-[180px] max-w-xs flex-1" />
{parentDimName && (

{t('subdimension_of', { parent: parentDimName })}

)}
{/* Value list (concept dry-table: Kod, Namn, Start, Slut, Status) */} {sortedValues.length === 0 ? ( searchTerm ? ( {t('no_search_results_description', { term: searchTerm })}} /> ) : ( {t('empty_description', { dimension: activeDim?.name ?? '' })}} actionLabel={canWrite ? t('new_value') : undefined} onAction={canWrite ? () => setDialog({ mode: 'create' }) : undefined} /> ) ) : (
{isProjectTab && ( <> )} {sortedValues.map((value) => ( setDialog({ mode: 'edit', value })} > {isProjectTab && ( <> )} ))}
{value.code} {value.name} {value.start_date ? formatDate(value.start_date) : ''} {value.end_date ? formatDate(value.end_date) : ''} {renderStatus(value)}
)} {/* Create/edit dialog */} !open && setDialog(null)}> {/* data-ph-mask: the dimension name is user data */} {dialog?.mode === 'edit' ? t('edit_value_title') : t('new_value_title', { dimension: activeDim?.name ?? '' })} {activeDim && dialog && ( )} {/* New dimension dialog — the content (and thus the form state) unmounts on close, so each open starts from a blank form. */} !open && setNewDimDialogOpen(false)} > {t('new_dimension_title')}
) } 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 }) { 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(null) const [numberError, setNumberError] = useState(null) const parentOptions = dimensions.filter((d) => d.is_active) function handleSubmit(e: React.FormEvent) { 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 (
setName(e.target.value)} autoComplete="off" autoFocus /> {nameError &&

{nameError}

}
setNumberStr(e.target.value)} className="tabular-nums" />

{t('dim_form_number_help')}

{numberError &&

{numberError}

}

{t('dim_form_resets_help')}

{showAdvanced && (

{t('dim_form_parent_help')}

)}
) }