feat(admin): vocabularies page for per-port pick lists
New /admin/vocabularies route + VocabulariesManager component. Catalog
at src/lib/vocabularies.ts defines 11 vocabularies grouped into
Interests / Berths / Expenses / Documents domains, each shipping with
the canonical defaults from src/lib/constants.ts (interest temps,
status-change reasons, tenure types, expense categories, document
types, plus the 5 berth-spec dropdowns).
Editor supports add / remove / reorder / inline-rename / reset-to-
defaults; only dirty cards save. Uses the existing
/api/v1/admin/settings PUT endpoint (already gated on
admin.manage_settings) so storage piggybacks on system_settings
(port_id, key) per the established pattern.
Reps need read access without holding manage_settings — added a
public-read /api/v1/vocabularies endpoint plus useVocabulary() hook
(5-minute staleTime). The admin manager invalidates the vocabularies
query on save so consumers (status-change dialog, expense form, etc.)
pick up new lists immediately.
Adds a Vocabularies card to the admin landing page.
Follow-up sweep owed: actual consumers (interest-card temperature pill,
berth-tabs select dropdowns, expense form category list, etc.) still
read from the hardcoded constants.ts arrays. Wire them through
useVocabulary in a separate pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:36:53 +02:00
'use client' ;
import { useCallback , useEffect , useMemo , useState } from 'react' ;
import { useQueryClient } from '@tanstack/react-query' ;
import { Plus , Save , Trash2 , GripVertical , RotateCcw , Loader2 } from 'lucide-react' ;
import { Button } from '@/components/ui/button' ;
import { Card , CardContent , CardDescription , CardHeader , CardTitle } from '@/components/ui/card' ;
import { Input } from '@/components/ui/input' ;
import { PageHeader } from '@/components/shared/page-header' ;
import { apiFetch } from '@/lib/api/client' ;
import { VOCABULARIES , type VocabularyDef , type VocabularyKey } from '@/lib/vocabularies' ;
interface SettingRow {
key : string ;
value : unknown ;
portId : string | null ;
updatedAt : string ;
}
interface VocabState {
/** Working copy of the entries the admin is editing. */
entries : string [ ] ;
/** True when entries differ from the loaded server value. */
dirty : boolean ;
/** Server value as last loaded (so Reset / dirty-check have something to compare against). */
loaded : string [ ] ;
/** The last in-progress add value (so the typing buffer survives re-renders). */
newEntry : string ;
}
const DOMAINS : Array < VocabularyDef [ 'domain' ] > = [ 'Interests' , 'Berths' , 'Expenses' , 'Documents' ] ;
function arraysEqual ( a : readonly string [ ] , b : readonly string [ ] ) : boolean {
if ( a . length !== b . length ) return false ;
return a . every ( ( v , i ) = > v === b [ i ] ) ;
}
export function VocabulariesManager() {
const queryClient = useQueryClient ( ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ saving , setSaving ] = useState < VocabularyKey | null > ( null ) ;
const [ state , setState ] = useState < Record < VocabularyKey , VocabState > > ( ( ) = > {
const initial : Partial < Record < VocabularyKey , VocabState > > = { } ;
for ( const def of VOCABULARIES ) {
const defaults = [ . . . def . defaults ] ;
initial [ def . key ] = { entries : defaults , loaded : defaults , dirty : false , newEntry : '' } ;
}
return initial as Record < VocabularyKey , VocabState > ;
} ) ;
const fetchAll = useCallback ( async ( ) = > {
setLoading ( true ) ;
try {
const res = await apiFetch < { data : { portSettings : SettingRow [ ] } } > (
'/api/v1/admin/settings' ,
) ;
const byKey = new Map < string , unknown > ( ) ;
for ( const row of res . data . portSettings ) byKey . set ( row . key , row . value ) ;
setState ( ( prev ) = > {
const next = { . . . prev } ;
for ( const def of VOCABULARIES ) {
const remote = byKey . get ( def . key ) ;
// Only adopt the remote value when it parses as a string array;
// anything else (legacy malformed rows, accidental object) falls
// back to the shipped defaults so the editor never starts in a
// broken state.
const remoteList = Array . isArray ( remote )
? remote . filter ( ( v ) : v is string = > typeof v === 'string' )
: null ;
const entries = remoteList && remoteList . length > 0 ? remoteList : [ . . . def . defaults ] ;
next [ def . key ] = {
entries ,
loaded : entries ,
dirty : false ,
newEntry : '' ,
} ;
}
return next ;
} ) ;
} finally {
setLoading ( false ) ;
}
} , [ ] ) ;
useEffect ( ( ) = > {
2026-05-13 11:50:07 +02:00
// Initial vocabularies load on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
feat(admin): vocabularies page for per-port pick lists
New /admin/vocabularies route + VocabulariesManager component. Catalog
at src/lib/vocabularies.ts defines 11 vocabularies grouped into
Interests / Berths / Expenses / Documents domains, each shipping with
the canonical defaults from src/lib/constants.ts (interest temps,
status-change reasons, tenure types, expense categories, document
types, plus the 5 berth-spec dropdowns).
Editor supports add / remove / reorder / inline-rename / reset-to-
defaults; only dirty cards save. Uses the existing
/api/v1/admin/settings PUT endpoint (already gated on
admin.manage_settings) so storage piggybacks on system_settings
(port_id, key) per the established pattern.
Reps need read access without holding manage_settings — added a
public-read /api/v1/vocabularies endpoint plus useVocabulary() hook
(5-minute staleTime). The admin manager invalidates the vocabularies
query on save so consumers (status-change dialog, expense form, etc.)
pick up new lists immediately.
Adds a Vocabularies card to the admin landing page.
Follow-up sweep owed: actual consumers (interest-card temperature pill,
berth-tabs select dropdowns, expense form category list, etc.) still
read from the hardcoded constants.ts arrays. Wire them through
useVocabulary in a separate pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:36:53 +02:00
void fetchAll ( ) ;
} , [ fetchAll ] ) ;
const grouped = useMemo ( ( ) = > {
const map = new Map < VocabularyDef [ 'domain' ] , VocabularyDef [ ] > ( ) ;
for ( const d of DOMAINS ) map . set ( d , [ ] ) ;
for ( const def of VOCABULARIES ) map . get ( def . domain ) ? . push ( def ) ;
return map ;
} , [ ] ) ;
function patch ( key : VocabularyKey , partial : Partial < VocabState > ) {
setState ( ( prev ) = > {
const current = prev [ key ] ;
const next = { . . . current , . . . partial } ;
next . dirty = ! arraysEqual ( next . entries , current . loaded ) ;
return { . . . prev , [ key ] : next } ;
} ) ;
}
function addEntry ( key : VocabularyKey ) {
const current = state [ key ] ;
const value = current . newEntry . trim ( ) ;
if ( ! value ) return ;
if ( current . entries . some ( ( e ) = > e . toLowerCase ( ) === value . toLowerCase ( ) ) ) {
// Don't allow exact-case-insensitive duplicates.
patch ( key , { newEntry : '' } ) ;
return ;
}
patch ( key , { entries : [ . . . current . entries , value ] , newEntry : '' } ) ;
}
function removeEntry ( key : VocabularyKey , index : number ) {
const current = state [ key ] ;
patch ( key , { entries : current.entries.filter ( ( _ , i ) = > i !== index ) } ) ;
}
function moveEntry ( key : VocabularyKey , from : number , direction : - 1 | 1 ) {
const current = state [ key ] ;
const to = from + direction ;
if ( to < 0 || to >= current . entries . length ) return ;
const entries = [ . . . current . entries ] ;
const [ moved ] = entries . splice ( from , 1 ) ;
entries . splice ( to , 0 , moved ! ) ;
patch ( key , { entries } ) ;
}
function updateEntry ( key : VocabularyKey , index : number , value : string ) {
const current = state [ key ] ;
const entries = current . entries . map ( ( e , i ) = > ( i === index ? value : e ) ) ;
patch ( key , { entries } ) ;
}
async function save ( key : VocabularyKey ) {
setSaving ( key ) ;
try {
const value = state [ key ] . entries . map ( ( e ) = > e . trim ( ) ) . filter ( ( e ) = > e . length > 0 ) ;
await apiFetch ( '/api/v1/admin/settings' , {
method : 'PUT' ,
body : { key , value } ,
} ) ;
patch ( key , { entries : value , loaded : value , newEntry : '' } ) ;
// Drop the in-app vocabularies cache so consumers (status-change
// dialog, expense form, etc.) pick up the new list immediately.
void queryClient . invalidateQueries ( { queryKey : [ 'vocabularies' ] } ) ;
} finally {
setSaving ( null ) ;
}
}
function resetToDefaults ( key : VocabularyKey ) {
const def = VOCABULARIES . find ( ( v ) = > v . key === key ) ;
if ( ! def ) return ;
patch ( key , { entries : [ . . . def . defaults ] , newEntry : '' } ) ;
}
if ( loading ) {
return (
< div >
< PageHeader title = "Vocabularies" description = "Per-port pick lists used across the CRM." / >
< div className = "flex items-center justify-center py-12 text-muted-foreground" >
< Loader2 className = "mr-2 h-4 w-4 animate-spin" / > Loading …
< / div >
< / div >
) ;
}
return (
< div >
< PageHeader
title = "Vocabularies"
description = "Per-port pick lists used across the CRM. Override the shipped defaults so reps see the wording your team actually uses. Changes apply immediately for this port; Reset restores the defaults the CRM ships with."
/ >
< div className = "mt-6 space-y-8" >
{ DOMAINS . map ( ( domain ) = > {
const defs = grouped . get ( domain ) ? ? [ ] ;
if ( defs . length === 0 ) return null ;
return (
< section key = { domain } className = "space-y-4" >
< h2 className = "text-lg font-semibold" > { domain } < / h2 >
< div className = "space-y-4" >
{ defs . map ( ( def ) = > {
const v = state [ def . key ] ;
return (
< Card key = { def . key } >
< CardHeader >
< div className = "flex items-start justify-between gap-3" >
< div >
< CardTitle className = "text-base" > { def . label } < / CardTitle >
< CardDescription > { def . description } < / CardDescription >
< p className = "mt-1 text-xs text-muted-foreground font-mono" >
{ def . key }
< / p >
< / div >
< div className = "flex shrink-0 items-center gap-2" >
< Button
size = "sm"
variant = "ghost"
onClick = { ( ) = > resetToDefaults ( def . key ) }
disabled = { saving === def . key }
title = "Restore the shipped defaults"
>
< RotateCcw className = "mr-1 h-3.5 w-3.5" / >
Reset
< / Button >
< Button
size = "sm"
onClick = { ( ) = > save ( def . key ) }
disabled = { ! v . dirty || saving === def . key }
>
{ saving === def . key ? (
< Loader2 className = "mr-1 h-3.5 w-3.5 animate-spin" / >
) : (
< Save className = "mr-1 h-3.5 w-3.5" / >
) }
Save
< / Button >
< / div >
< / div >
< / CardHeader >
< CardContent className = "space-y-3" >
{ v . entries . length === 0 ? (
< p className = "text-sm text-muted-foreground" >
No entries yet . Add at least one before saving , or hit Reset to restore
defaults .
< / p >
) : (
< ul className = "space-y-1.5" >
{ v . entries . map ( ( entry , index ) = > (
< li key = { ` ${ def . key } - ${ index } ` } className = "flex items-center gap-2" >
< div className = "flex flex-col gap-0.5" >
< button
type = "button"
aria - label = "Move up"
className = "text-muted-foreground hover:text-foreground disabled:opacity-30"
onClick = { ( ) = > moveEntry ( def . key , index , - 1 ) }
disabled = { index === 0 }
>
< GripVertical className = "h-3.5 w-3.5 rotate-90" / >
< / button >
< / div >
< Input
value = { entry }
onChange = { ( e ) = > updateEntry ( def . key , index , e . target . value ) }
className = "flex-1"
/ >
< Button
variant = "ghost"
size = "icon"
className = "h-8 w-8 text-destructive hover:text-destructive"
onClick = { ( ) = > removeEntry ( def . key , index ) }
aria - label = { ` Remove ${ entry } ` }
>
< Trash2 className = "h-3.5 w-3.5" / >
< / Button >
< / li >
) ) }
< / ul >
) }
< div className = "flex items-center gap-2 pt-2" >
< Input
value = { v . newEntry }
onChange = { ( e ) = > patch ( def . key , { newEntry : e.target.value } ) }
onKeyDown = { ( e ) = > {
if ( e . key === 'Enter' ) {
e . preventDefault ( ) ;
addEntry ( def . key ) ;
}
} }
placeholder = "Add an entry…"
className = "flex-1"
/ >
< Button
type = "button"
variant = "outline"
size = "sm"
onClick = { ( ) = > addEntry ( def . key ) }
disabled = { ! v . newEntry . trim ( ) }
>
< Plus className = "mr-1 h-3.5 w-3.5" / > Add
< / Button >
< / div >
< / CardContent >
< / Card >
) ;
} ) }
< / div >
< / section >
) ;
} ) }
< / div >
< / div >
) ;
}