'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 { 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, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' 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, ChevronRight, ChevronsUpDown, Loader2, } 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([]) const [isLoading, setIsLoading] = useState(true) const [loadFailed, setLoadFailed] = useState(false) const [activeDimId, setActiveDimId] = useState(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) 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 // 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(false) } 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(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) 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 ( ) } function renderStatusBadge(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 loadDimensions(true)} /> ) } return (
{/* Segmented tabs: one per registry dimension (1 Kostnadsställe, 6 Projekt, plus any custom 20+ dims) */}
{ setActiveDimId(id) setSearchTerm('') }} > {dimensions.map((dim) => ( {dim.name} ))}
{parentDimName && (

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

)}
{/* Search */}
setSearchTerm(e.target.value)} className="pl-10" />
{/* Value list */} {sortedValues.length === 0 ? ( {searchTerm ? ( ) : ( setDialog({ mode: 'create' }) : undefined} /> )} ) : ( <> {/* Desktop table */} {isProjectTab && ( <> )} {sortedValues.map((value) => ( setDialog({ mode: 'edit', value })} > {value.code} {value.name} {renderStatusBadge(value)} {isProjectTab && ( <> {value.start_date ? formatDate(value.start_date) : '-'} {value.end_date ? formatDate(value.end_date) : '-'} )} ))}
{/* Mobile card list */}
{sortedValues.map((value) => ( setDialog({ mode: 'edit', value })} >

{value.code}

{value.name}

{renderStatusBadge(value)}
{isProjectTab && (value.start_date || value.end_date) && (

{value.start_date ? formatDate(value.start_date) : '-'} {' till '} {value.end_date ? formatDate(value.end_date) : '-'}

)}
))}
)} {/* Create/edit dialog */} !open && setDialog(null)}> {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')}

)}
) }