diff --git a/src/app/(dashboard)/[portSlug]/admin/page.tsx b/src/app/(dashboard)/[portSlug]/admin/page.tsx index f6242432..a8a17f88 100644 --- a/src/app/(dashboard)/[portSlug]/admin/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/page.tsx @@ -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', diff --git a/src/app/(dashboard)/[portSlug]/admin/vocabularies/page.tsx b/src/app/(dashboard)/[portSlug]/admin/vocabularies/page.tsx new file mode 100644 index 00000000..3da233a5 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/vocabularies/page.tsx @@ -0,0 +1,5 @@ +import { VocabulariesManager } from '@/components/admin/vocabularies/vocabularies-manager'; + +export default function VocabulariesPage() { + return ; +} diff --git a/src/app/api/v1/vocabularies/route.ts b/src/app/api/v1/vocabularies/route.ts new file mode 100644 index 00000000..e51a3088 --- /dev/null +++ b/src/app/api/v1/vocabularies/route.ts @@ -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(); + for (const row of portSettings) overridesByKey.set(row.key, row.value); + + const data: Record = {}; + 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); + } +}); diff --git a/src/components/admin/vocabularies/vocabularies-manager.tsx b/src/components/admin/vocabularies/vocabularies-manager.tsx new file mode 100644 index 00000000..08435d71 --- /dev/null +++ b/src/components/admin/vocabularies/vocabularies-manager.tsx @@ -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 = ['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" + /> + +
+
+
+ ); + })} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/hooks/use-vocabulary.ts b/src/hooks/use-vocabulary.ts new file mode 100644 index 00000000..0182d310 --- /dev/null +++ b/src/hooks/use-vocabulary.ts @@ -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; +} + +/** + * 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({ + queryKey: ['vocabularies'], + queryFn: () => apiFetch('/api/v1/vocabularies'), + staleTime: 5 * 60 * 1000, + }); + return data?.data[key] ?? def.defaults; +} diff --git a/src/lib/vocabularies.ts b/src/lib/vocabularies.ts new file mode 100644 index 00000000..8760a82d --- /dev/null +++ b/src/lib/vocabularies.ts @@ -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(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; +}