'use client'; import { useState, useEffect, useCallback } from 'react'; import { Trash2, Plus, Save } from 'lucide-react'; import { PageHeader } from '@/components/shared/page-header'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { apiFetch } from '@/lib/api/client'; import { SUPPORTED_CURRENCIES, currencyLabel } from '@/lib/utils/currency'; interface Setting { key: string; value: unknown; portId: string | null; updatedBy: string | null; updatedAt: string; } /** Well-known settings with their display metadata */ const KNOWN_SETTINGS: Array<{ key: string; label: string; description: string; type: 'boolean' | 'number' | 'json' | 'string' | 'select'; defaultValue: unknown; options?: Array<{ value: string; label: string }>; }> = [ { key: 'client_portal_enabled', label: 'Client Portal', description: 'Allow clients of this port to sign in and manage their account through the client portal.', type: 'boolean', defaultValue: true, }, { key: 'tenancies_module_enabled', label: 'Tenancies Module', description: 'Enable the per-berth tenancy tracker (lease windows, renewals, transfers). Off by default; auto-enables when the first tenancy row is created via webhook or manual add. Disabling here hides the sidebar entry and entity tabs, but never deletes underlying tenancy rows - re-enabling brings them back.', type: 'boolean', defaultValue: false, }, { key: 'expenses_module_enabled', label: 'Expenses Module', description: 'Enable the expenses + receipt-upload surface for this port. Disabling hides both sidebar entries (Expenses and How to upload receipts) and blocks the routes with a "module disabled" page, so bookmarks land somewhere meaningful instead of 404-ing. Previously-recorded expense rows are preserved if you re-enable.', type: 'boolean', defaultValue: true, }, { key: 'ai_interest_scoring', label: 'AI Interest Scoring', description: 'Enable AI-powered interest scoring based on engagement signals', type: 'boolean', defaultValue: false, }, { key: 'ai_email_drafts', label: 'AI Email Drafts', description: 'Enable AI-assisted email draft generation', type: 'boolean', defaultValue: false, }, { key: 'invoice_net10_discount', label: 'Net-10 Invoice Discount (%)', description: 'Discount percentage applied when payment terms are net-10', type: 'number', defaultValue: 2, }, { key: 'pipeline_weights', label: 'Pipeline Stage Weights', description: 'Probability weights for revenue forecast by pipeline stage (JSON)', type: 'json', defaultValue: { enquiry: 0.05, qualified: 0.15, nurturing: 0.15, eoi: 0.4, reservation: 0.7, deposit_paid: 0.85, contract: 0.95, }, }, { key: 'berth_rules', label: 'Berth Status Rules', description: 'Auto/suggest/off rules for berth status transitions (JSON)', type: 'json', defaultValue: [], }, { key: 'default_new_interest_owner', label: 'Default New-Interest Owner', description: 'User ID to auto-assign as the deal owner when a new interest is created. Stored as { "userId": "..." }. Leave blank to have new interests unassigned by default - the rep can pick an owner from the interest detail header.', type: 'json', defaultValue: { userId: null }, }, { key: 'inquiry_contact_email', label: 'Inquiry Contact Email', description: 'Reply-to email shown in client confirmation emails when a new interest is registered', type: 'string', defaultValue: '', }, { key: 'inquiry_notification_recipients', label: 'External Notification Recipients', description: 'Additional email addresses that receive sales notifications for new interests (JSON array)', type: 'json', defaultValue: [], }, { key: 'residential_notification_recipients', label: 'Residential Notification Recipients', description: 'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.', type: 'json', defaultValue: [], }, { key: 'eoi_signers', label: 'EOI Signers', description: 'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.', type: 'json', defaultValue: { developer: { name: '', email: '' }, approver: { name: '', email: '' }, }, }, // ─── Berth recommender (src/lib/services/berth-recommender.service.ts) ────── { key: 'recommender_max_oversize_pct', label: 'Recommender - max oversize %', description: 'Cap on how much larger a berth can be than the desired length/width/draft before it stops being suggested. Default 30.', type: 'number', defaultValue: 30, }, { key: 'recommender_top_n_default', label: 'Recommender - default result count', description: 'Default number of berth recommendations returned per request. Default 8.', type: 'number', defaultValue: 8, }, { key: 'fallthrough_policy', label: 'Recommender - fall-through policy', description: 'How berths re-enter the recommender after a lost deal.', type: 'select', defaultValue: 'immediate_with_heat', options: [ { value: 'immediate_with_heat', label: 'Immediate (with heat boost) - surface again right away', }, { value: 'cooldown', label: 'Cooldown - wait N days (see below)', }, { value: 'never_auto_recommend', label: 'Never - only re-surface via manual rep search', }, ], }, { key: 'fallthrough_cooldown_days', label: 'Recommender - fall-through cooldown (days)', description: 'Days a berth stays out of the recommender after a lost deal when the policy is `cooldown`. Default 30.', type: 'number', defaultValue: 30, }, { key: 'heat_weight_recency', label: 'Heat weight - recency', description: 'Weight given to how recently the prior interest fell through. Default 30.', type: 'number', defaultValue: 30, }, { key: 'heat_weight_furthest_stage', label: 'Heat weight - furthest stage', description: 'Weight given to how close the prior interest got to closing before falling through. Default 40.', type: 'number', defaultValue: 40, }, { key: 'heat_weight_interest_count', label: 'Heat weight - historical interest count', description: 'Weight given to how often this berth has attracted interest historically. Default 15.', type: 'number', defaultValue: 15, }, { key: 'heat_weight_eoi_count', label: 'Heat weight - historical EOI count', description: 'Weight given to how often interest in this berth has reached EOI signing. Default 15.', type: 'number', defaultValue: 15, }, { key: 'tier_ladder_hide_late_stage', label: 'Recommender - hide late-stage tier', description: 'Hide berths whose only active interests are late-stage (close to closing) from recommendations.', type: 'boolean', defaultValue: true, }, { key: 'documents_show_expired_tab', label: 'Documents - show Expired tab', description: 'When off, the Expired tab on the documents hub is hidden. Use this when expired EOIs are noise that distracts reps from active deals.', type: 'boolean', defaultValue: true, }, { key: 'berths_default_currency', label: 'Berths - default currency', description: 'Currency applied to newly-created berths when none is specified on the form. Existing berths keep their per-row currency. Defaults to USD.', type: 'select', defaultValue: 'USD', options: SUPPORTED_CURRENCIES.map((c) => ({ value: c.code, label: `${c.code} - ${currencyLabel(c.code)}`, })), }, ]; export function SettingsManager() { const [portSettings, setPortSettings] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); const [values, setValues] = useState>({}); const [customKey, setCustomKey] = useState(''); const [customValue, setCustomValue] = useState(''); const fetchSettings = useCallback(async () => { setLoading(true); try { const res = await apiFetch<{ data: { portSettings: Setting[]; globalSettings: Setting[] } }>( '/api/v1/admin/settings', ); setPortSettings(res.data.portSettings); // Build values map from existing settings const vals: Record = {}; for (const s of res.data.portSettings) { vals[s.key] = s.value; } setValues(vals); } finally { setLoading(false); } }, []); useEffect(() => { // Initial settings load on mount. // eslint-disable-next-line react-hooks/set-state-in-effect void fetchSettings(); }, [fetchSettings]); async function saveSetting(key: string, value: unknown) { setSaving(key); try { await apiFetch('/api/v1/admin/settings', { method: 'PUT', body: { key, value }, }); await fetchSettings(); } finally { setSaving(null); } } async function handleDeleteSetting(key: string) { await apiFetch('/api/v1/admin/settings', { method: 'DELETE', body: { key }, }); await fetchSettings(); } async function handleAddCustom() { if (!customKey.trim()) return; let parsed: unknown; try { parsed = JSON.parse(customValue); } catch { parsed = customValue; } await saveSetting(customKey, parsed); setCustomKey(''); setCustomValue(''); } function getEffectiveValue(key: string, defaultValue: unknown): unknown { return values[key] ?? defaultValue; } if (loading) { return (
Loading...
); } // Custom settings = port settings that aren't in KNOWN_SETTINGS const knownKeys = new Set(KNOWN_SETTINGS.map((s) => s.key)); const customSettings = portSettings.filter((s) => !knownKeys.has(s.key)); return (
{/* Feature Flags */} Feature Flags Enable or disable optional features {KNOWN_SETTINGS.filter((s) => s.type === 'boolean').map((setting) => (

{setting.description}

saveSetting(setting.key, checked)} />
))}
{/* String + Select Settings - both render in the same card. 'select' settings get a Select dropdown bound to setting.options; 'string' settings get a free-text Input. */} {KNOWN_SETTINGS.some((s) => s.type === 'string' || s.type === 'select') && ( Inquiry Settings Configure inquiry notification behavior {KNOWN_SETTINGS.filter((s) => s.type === 'string' || s.type === 'select').map( (setting) => (

{setting.description}

{setting.type === 'select' && setting.options ? ( ) : ( setValues((prev) => ({ ...prev, [setting.key]: e.target.value, })) } /> )}
), )}
)} {/* Numeric Settings */} Business Rules Configure financial and operational parameters {KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (

{setting.description}

setValues((prev) => ({ ...prev, [setting.key]: parseFloat(e.target.value) || 0, })) } />
))}
{/* JSON Settings */} Advanced Configuration JSON-based settings for pipeline weights and berth rules {KNOWN_SETTINGS.filter((s) => s.type === 'json').map((setting) => { const currentValue = getEffectiveValue(setting.key, setting.defaultValue); const jsonStr = values[`${setting.key}_edit`] !== undefined ? String(values[`${setting.key}_edit`]) : JSON.stringify(currentValue, null, 2); return (

{setting.description}