2026-04-08 15:53:33 -04:00
'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 { Separator } from '@/components/ui/separator' ;
import { apiFetch } from '@/lib/api/client' ;
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 ;
2026-04-14 13:16:20 -04:00
type : 'boolean' | 'number' | 'json' | 'string' ;
2026-04-08 15:53:33 -04:00
defaultValue : unknown ;
} > = [
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
{
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 ,
} ,
2026-04-08 15:53:33 -04:00
{
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 : {
open : 0.05 ,
details_sent : 0.1 ,
in_communication : 0.2 ,
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
eoi_sent : 0.4 ,
eoi_signed : 0.6 ,
deposit_10pct : 0.75 ,
contract_sent : 0.85 ,
contract_signed : 0.95 ,
2026-04-08 15:53:33 -04:00
completed : 1.0 ,
} ,
} ,
{
key : 'berth_rules' ,
label : 'Berth Status Rules' ,
description : 'Auto/suggest/off rules for berth status transitions (JSON)' ,
type : 'json' ,
defaultValue : [ ] ,
} ,
2026-04-14 13:16:20 -04:00
{
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 : 'sales@portnimara.com' ,
} ,
{
key : 'inquiry_notification_recipients' ,
label : 'External Notification Recipients' ,
description :
'Additional email addresses that receive sales notifications for new interests (JSON array)' ,
type : 'json' ,
defaultValue : [ ] ,
} ,
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
{
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 : [ ] ,
} ,
2026-05-02 00:19:55 +02:00
{
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 : 'David Mizrahi' , email : 'dm@portnimara.com' } ,
approver : { name : 'Abbie May' , email : 'sales@portnimara.com' } ,
} ,
} ,
2026-04-08 15:53:33 -04:00
] ;
export function SettingsManager() {
const [ portSettings , setPortSettings ] = useState < Setting [ ] > ( [ ] ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ saving , setSaving ] = useState < string | null > ( null ) ;
const [ values , setValues ] = useState < Record < string , unknown > > ( { } ) ;
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 < string , unknown > = { } ;
for ( const s of res . data . portSettings ) {
vals [ s . key ] = s . value ;
}
setValues ( vals ) ;
} finally {
setLoading ( false ) ;
}
} , [ ] ) ;
useEffect ( ( ) = > {
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 (
< div >
< PageHeader title = "System Settings" description = "Configure system behavior for this port" / >
< div className = "flex items-center justify-center py-12 text-muted-foreground" >
Loading . . .
< / div >
< / div >
) ;
}
// 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 (
< div >
< PageHeader title = "System Settings" description = "Configure system behavior for this port" / >
< div className = "space-y-6 mt-6" >
{ /* Feature Flags */ }
< Card >
< CardHeader >
< CardTitle > Feature Flags < / CardTitle >
< CardDescription > Enable or disable optional features < / CardDescription >
< / CardHeader >
< CardContent className = "space-y-4" >
{ KNOWN_SETTINGS . filter ( ( s ) = > s . type === 'boolean' ) . map ( ( setting ) = > (
< div key = { setting . key } className = "flex items-center justify-between" >
< div >
< Label > { setting . label } < / Label >
< p className = "text-xs text-muted-foreground" > { setting . description } < / p >
< / div >
< Switch
checked = { getEffectiveValue ( setting . key , setting . defaultValue ) === true }
disabled = { saving === setting . key }
onCheckedChange = { ( checked ) = > saveSetting ( setting . key , checked ) }
/ >
< / div >
) ) }
< / CardContent >
< / Card >
2026-04-14 13:16:20 -04:00
{ /* String Settings */ }
{ KNOWN_SETTINGS . some ( ( s ) = > s . type === 'string' ) && (
< Card >
< CardHeader >
< CardTitle > Inquiry Settings < / CardTitle >
< CardDescription > Configure inquiry notification behavior < / CardDescription >
< / CardHeader >
< CardContent className = "space-y-4" >
{ KNOWN_SETTINGS . filter ( ( s ) = > s . type === 'string' ) . map ( ( setting ) = > (
< div key = { setting . key } className = "flex items-center justify-between gap-4" >
< div className = "flex-1" >
< Label > { setting . label } < / Label >
< p className = "text-xs text-muted-foreground" > { setting . description } < / p >
< / div >
< div className = "flex items-center gap-2" >
< Input
type = "text"
className = "w-64"
value = { String ( getEffectiveValue ( setting . key , setting . defaultValue ) ? ? '' ) }
onChange = { ( e ) = >
setValues ( ( prev ) = > ( {
. . . prev ,
[ setting . key ] : e . target . value ,
} ) )
}
/ >
< Button
size = "sm"
variant = "outline"
disabled = { saving === setting . key }
onClick = { ( ) = >
saveSetting ( setting . key , values [ setting . key ] ? ? setting . defaultValue )
}
>
< Save className = "h-3.5 w-3.5" / >
< / Button >
< / div >
< / div >
) ) }
< / CardContent >
< / Card >
) }
2026-04-08 15:53:33 -04:00
{ /* Numeric Settings */ }
< Card >
< CardHeader >
< CardTitle > Business Rules < / CardTitle >
< CardDescription > Configure financial and operational parameters < / CardDescription >
< / CardHeader >
< CardContent className = "space-y-4" >
{ KNOWN_SETTINGS . filter ( ( s ) = > s . type === 'number' ) . map ( ( setting ) = > (
< div key = { setting . key } className = "flex items-center justify-between gap-4" >
< div className = "flex-1" >
< Label > { setting . label } < / Label >
< p className = "text-xs text-muted-foreground" > { setting . description } < / p >
< / div >
< div className = "flex items-center gap-2" >
< Input
type = "number"
className = "w-24"
value = { String ( getEffectiveValue ( setting . key , setting . defaultValue ) ? ? '' ) }
onChange = { ( e ) = >
setValues ( ( prev ) = > ( {
. . . prev ,
[ setting . key ] : parseFloat ( e . target . value ) || 0 ,
} ) )
}
/ >
< Button
size = "sm"
variant = "outline"
disabled = { saving === setting . key }
onClick = { ( ) = >
saveSetting ( setting . key , values [ setting . key ] ? ? setting . defaultValue )
}
>
< Save className = "h-3.5 w-3.5" / >
< / Button >
< / div >
< / div >
) ) }
< / CardContent >
< / Card >
{ /* JSON Settings */ }
< Card >
< CardHeader >
< CardTitle > Advanced Configuration < / CardTitle >
< CardDescription >
JSON - based settings for pipeline weights and berth rules
< / CardDescription >
< / CardHeader >
< CardContent className = "space-y-6" >
{ 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 (
< div key = { setting . key } className = "space-y-2" >
< Label > { setting . label } < / Label >
< p className = "text-xs text-muted-foreground" > { setting . description } < / p >
< Textarea
className = "font-mono text-xs"
rows = { 6 }
value = { jsonStr }
onChange = { ( e ) = >
setValues ( ( prev ) = > ( { . . . prev , [ ` ${ setting . key } _edit ` ] : e . target . value } ) )
}
/ >
< Button
size = "sm"
disabled = { saving === setting . key }
onClick = { ( ) = > {
try {
const parsed = JSON . parse (
String ( values [ ` ${ setting . key } _edit ` ] ? ? JSON . stringify ( currentValue ) ) ,
) ;
void saveSetting ( setting . key , parsed ) ;
} catch {
// invalid JSON — do nothing
}
} }
>
{ saving === setting . key ? 'Saving...' : 'Save' }
< / Button >
< / div >
) ;
} ) }
< / CardContent >
< / Card >
{ /* Custom Settings */ }
< Card >
< CardHeader >
< CardTitle > Custom Settings < / CardTitle >
< CardDescription > Additional key - value settings for this port < / CardDescription >
< / CardHeader >
< CardContent className = "space-y-4" >
{ customSettings . map ( ( setting ) = > (
< div key = { setting . key } className = "flex items-center justify-between gap-2" >
< div >
< code className = "text-sm font-mono" > { setting . key } < / code >
< p className = "text-xs text-muted-foreground" >
{ typeof setting . value === 'object'
? JSON . stringify ( setting . value )
: String ( setting . value ) }
< / p >
< / div >
< ConfirmationDialog
trigger = {
< Button
variant = "ghost"
size = "sm"
className = "text-destructive hover:text-destructive"
>
< Trash2 className = "h-4 w-4" / >
< / Button >
}
title = "Delete Setting"
description = { ` Delete " ${ setting . key } "? This may affect system behavior. ` }
confirmLabel = "Delete"
onConfirm = { ( ) = > handleDeleteSetting ( setting . key ) }
/ >
< / div >
) ) }
< Separator / >
< div className = "flex gap-2" >
< Input
placeholder = "Key"
value = { customKey }
onChange = { ( e ) = > setCustomKey ( e . target . value ) }
className = "w-40"
/ >
< Input
placeholder = "Value (JSON or string)"
value = { customValue }
onChange = { ( e ) = > setCustomValue ( e . target . value ) }
className = "flex-1"
/ >
< Button variant = "outline" onClick = { handleAddCustom } disabled = { ! customKey . trim ( ) } >
< Plus className = "mr-1 h-4 w-4" / >
Add
< / Button >
< / div >
< / CardContent >
< / Card >
< / div >
< / div >
) ;
}