'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([]) 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 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 = { 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 ( ) } 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) */}
{ setActiveDimId(id) setSearchTerm('') }} > {dimensions.map((dim) => ( {dim.name} ))}
{/* 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) : '—'} {' – '} {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 && ( )}
) }