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:
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Bell,
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
Database,
|
||||
FileText,
|
||||
@@ -133,6 +134,13 @@ const GROUPS: AdminGroup[] = [
|
||||
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
|
||||
icon: Tag,
|
||||
},
|
||||
{
|
||||
href: 'vocabularies',
|
||||
label: 'Vocabularies',
|
||||
description:
|
||||
'Per-port pick lists used across the CRM: interest temperatures, status reasons, tenure types, expense categories, document types.',
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
href: 'custom-fields',
|
||||
label: 'Custom Fields',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { VocabulariesManager } from '@/components/admin/vocabularies/vocabularies-manager';
|
||||
|
||||
export default function VocabulariesPage() {
|
||||
return <VocabulariesManager />;
|
||||
}
|
||||
33
src/app/api/v1/vocabularies/route.ts
Normal file
33
src/app/api/v1/vocabularies/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listSettings } from '@/lib/services/settings.service';
|
||||
import { resolveVocabulary, VOCABULARIES, type VocabularyKey } from '@/lib/vocabularies';
|
||||
|
||||
/**
|
||||
* GET /api/v1/vocabularies
|
||||
*
|
||||
* Returns the resolved per-port vocabulary lists (admin overrides
|
||||
* merged with shipped defaults). Any authenticated user in this port
|
||||
* can read — vocabularies drive in-app pickers (status reasons,
|
||||
* interest temperatures, expense categories, etc.) so reps need read
|
||||
* access without holding `admin.manage_settings`.
|
||||
*
|
||||
* Edits still go through `/api/v1/admin/settings` (admin-only).
|
||||
*/
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
const { portSettings } = await listSettings(ctx.portId);
|
||||
const overridesByKey = new Map<string, unknown>();
|
||||
for (const row of portSettings) overridesByKey.set(row.key, row.value);
|
||||
|
||||
const data: Record<string, readonly string[]> = {};
|
||||
for (const def of VOCABULARIES) {
|
||||
data[def.key] = resolveVocabulary(def.key as VocabularyKey, overridesByKey.get(def.key));
|
||||
}
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
300
src/components/admin/vocabularies/vocabularies-manager.tsx
Normal file
300
src/components/admin/vocabularies/vocabularies-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/hooks/use-vocabulary.ts
Normal file
26
src/hooks/use-vocabulary.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { getVocabularyDef, type VocabularyKey } from '@/lib/vocabularies';
|
||||
|
||||
interface VocabulariesResponse {
|
||||
data: Record<string, readonly string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the effective per-port vocabulary list for a key. Falls back
|
||||
* to the shipped defaults when the request hasn't resolved or the key
|
||||
* is missing from the response. Cached for 5 minutes — vocabularies
|
||||
* change rarely and the admin Vocabularies page invalidates by save.
|
||||
*/
|
||||
export function useVocabulary(key: VocabularyKey): readonly string[] {
|
||||
const def = getVocabularyDef(key);
|
||||
const { data } = useQuery<VocabulariesResponse>({
|
||||
queryKey: ['vocabularies'],
|
||||
queryFn: () => apiFetch<VocabulariesResponse>('/api/v1/vocabularies'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return data?.data[key] ?? def.defaults;
|
||||
}
|
||||
162
src/lib/vocabularies.ts
Normal file
162
src/lib/vocabularies.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
BERTH_ACCESS_OPTIONS,
|
||||
BERTH_BOLLARD_TYPES,
|
||||
BERTH_CLEAT_TYPES,
|
||||
BERTH_MOORING_TYPES,
|
||||
BERTH_SIDE_PONTOON_OPTIONS,
|
||||
DOCUMENT_TYPES,
|
||||
EXPENSE_CATEGORIES,
|
||||
} from '@/lib/constants';
|
||||
|
||||
/**
|
||||
* Per-port vocabularies — pick lists that the rep team needs to be
|
||||
* able to tweak without a code change. Each vocabulary is a JSON
|
||||
* array of strings stored in `system_settings` keyed by
|
||||
* `(port_id, key)`. The defaults below are the values the CRM
|
||||
* shipped with; the admin Vocabularies page lets a port admin
|
||||
* override any of them.
|
||||
*
|
||||
* Consumers should call {@link getVocabulary} to fetch the
|
||||
* effective list (port override first, falling back to default).
|
||||
* Never hardcode a vocabulary in component code — always read
|
||||
* through this module so the admin page is the single source of
|
||||
* truth.
|
||||
*/
|
||||
export type VocabularyKey =
|
||||
| 'interest_temperature_levels'
|
||||
| 'berth_status_change_reasons'
|
||||
| 'berth_tenure_types'
|
||||
| 'expense_categories'
|
||||
| 'document_types'
|
||||
| 'interest_outcome_statuses'
|
||||
| 'berth_side_pontoon_options'
|
||||
| 'berth_mooring_types'
|
||||
| 'berth_cleat_types'
|
||||
| 'berth_bollard_types'
|
||||
| 'berth_access_options';
|
||||
|
||||
export interface VocabularyDef {
|
||||
key: VocabularyKey;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Domain grouping for the admin page card layout. */
|
||||
domain: 'Interests' | 'Berths' | 'Expenses' | 'Documents';
|
||||
/** Default values shipped with the CRM. */
|
||||
defaults: readonly string[];
|
||||
}
|
||||
|
||||
export const VOCABULARIES: VocabularyDef[] = [
|
||||
// ─── Interests ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'interest_temperature_levels',
|
||||
label: 'Interest temperatures',
|
||||
description:
|
||||
'Heat-level pills shown on each interest card. The first entry is treated as the highest urgency in lists and reports.',
|
||||
domain: 'Interests',
|
||||
defaults: ['HOT', 'WARM', 'COLD'],
|
||||
},
|
||||
{
|
||||
key: 'interest_outcome_statuses',
|
||||
label: 'Interest outcomes',
|
||||
description:
|
||||
'Terminal outcome labels recorded when an interest is closed. Used in reports and the lost-deal heat scorer.',
|
||||
domain: 'Interests',
|
||||
defaults: ['won', 'lost_other_marina', 'lost_unqualified', 'lost_no_response', 'cancelled'],
|
||||
},
|
||||
|
||||
// ─── Berths ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'berth_status_change_reasons',
|
||||
label: 'Berth status-change reasons',
|
||||
description:
|
||||
'Quick-pick chips shown in the status-change dialog when a berth is moved to Sold / Under Offer.',
|
||||
domain: 'Berths',
|
||||
defaults: [
|
||||
'Sold to existing prospect',
|
||||
'Sold to walk-in',
|
||||
'Reserved pending deposit',
|
||||
'Returned to inventory',
|
||||
'Owner withdrew',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'berth_tenure_types',
|
||||
label: 'Berth tenure types',
|
||||
description: 'Ownership / lease structure displayed on berth detail.',
|
||||
domain: 'Berths',
|
||||
defaults: ['permanent', 'fixed_term'],
|
||||
},
|
||||
{
|
||||
key: 'berth_side_pontoon_options',
|
||||
label: 'Side-pontoon configurations',
|
||||
description: 'Options for the Side Pontoon dropdown on the berth form.',
|
||||
domain: 'Berths',
|
||||
defaults: [...BERTH_SIDE_PONTOON_OPTIONS],
|
||||
},
|
||||
{
|
||||
key: 'berth_mooring_types',
|
||||
label: 'Mooring types',
|
||||
description: 'Mooring-type options on the berth form.',
|
||||
domain: 'Berths',
|
||||
defaults: [...BERTH_MOORING_TYPES],
|
||||
},
|
||||
{
|
||||
key: 'berth_cleat_types',
|
||||
label: 'Cleat types',
|
||||
description: 'Hardware tiers used in the berth spec form.',
|
||||
domain: 'Berths',
|
||||
defaults: [...BERTH_CLEAT_TYPES],
|
||||
},
|
||||
{
|
||||
key: 'berth_bollard_types',
|
||||
label: 'Bollard types',
|
||||
description: 'Bollard hardware options on the berth spec form.',
|
||||
domain: 'Berths',
|
||||
defaults: [...BERTH_BOLLARD_TYPES],
|
||||
},
|
||||
{
|
||||
key: 'berth_access_options',
|
||||
label: 'Access modes',
|
||||
description: 'Vehicle access options shown on the berth spec form.',
|
||||
domain: 'Berths',
|
||||
defaults: [...BERTH_ACCESS_OPTIONS],
|
||||
},
|
||||
|
||||
// ─── Expenses ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'expense_categories',
|
||||
label: 'Expense categories',
|
||||
description: 'Category dropdown on the expense form. Drives the expense PDF group-by-category.',
|
||||
domain: 'Expenses',
|
||||
defaults: [...EXPENSE_CATEGORIES],
|
||||
},
|
||||
|
||||
// ─── Documents ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'document_types',
|
||||
label: 'Document types',
|
||||
description: 'Type tag applied to uploaded documents and template metadata.',
|
||||
domain: 'Documents',
|
||||
defaults: [...DOCUMENT_TYPES],
|
||||
},
|
||||
];
|
||||
|
||||
const VOCAB_BY_KEY = new Map<VocabularyKey, VocabularyDef>(VOCABULARIES.map((v) => [v.key, v]));
|
||||
|
||||
export function getVocabularyDef(key: VocabularyKey): VocabularyDef {
|
||||
const def = VOCAB_BY_KEY.get(key);
|
||||
if (!def) throw new Error(`Unknown vocabulary key: ${key}`);
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective vocabulary for a port — merges the port's
|
||||
* override (when present) with the shipped defaults. Falls back to
|
||||
* defaults when the system_settings value is missing or malformed.
|
||||
*/
|
||||
export function resolveVocabulary(key: VocabularyKey, override: unknown): readonly string[] {
|
||||
const def = getVocabularyDef(key);
|
||||
if (!Array.isArray(override)) return def.defaults;
|
||||
const cleaned = override.filter((v): v is string => typeof v === 'string' && v.trim().length > 0);
|
||||
return cleaned.length > 0 ? cleaned : def.defaults;
|
||||
}
|
||||
Reference in New Issue
Block a user