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>
This commit is contained in:
2026-05-09 18:36:53 +02:00
parent 07b5756014
commit da7ce16344
6 changed files with 534 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
'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(() => {
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>
);
}