'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 = ['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(null); const [state, setState] = useState>(() => { const initial: Partial> = {}; for (const def of VOCABULARIES) { const defaults = [...def.defaults]; initial[def.key] = { entries: defaults, loaded: defaults, dirty: false, newEntry: '' }; } return initial as Record; }); const fetchAll = useCallback(async () => { setLoading(true); try { const res = await apiFetch<{ data: { portSettings: SettingRow[] } }>( '/api/v1/admin/settings', ); const byKey = new Map(); 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(() => { void fetchAll(); }, [fetchAll]); const grouped = useMemo(() => { const map = new Map(); 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) { 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 (
Loading…
); } return (
{DOMAINS.map((domain) => { const defs = grouped.get(domain) ?? []; if (defs.length === 0) return null; return (

{domain}

{defs.map((def) => { const v = state[def.key]; return (
{def.label} {def.description}

{def.key}

{v.entries.length === 0 ? (

No entries yet. Add at least one before saving, or hit Reset to restore defaults.

) : (
    {v.entries.map((entry, index) => (
  • updateEntry(def.key, index, e.target.value)} className="flex-1" />
  • ))}
)}
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" />
); })}
); })}
); }