Files
pn-new-crm/src/lib/vocabularies.ts
Matt da7ce16344 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

163 lines
5.8 KiB
TypeScript

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;
}