feat(admin): inquiry inbox, send log, email-template overrides, reports dashboard, recommender keys, role-editor coverage; replace placeholder pages

Closes the bulk of audit-pass-#1 admin gaps in one batch.

New admin pages:
- /admin/inquiries reads website_submissions with filter chips for
  berth/residence/contact + payload viewer per row.
- /admin/sends reads document_sends with sent/failed filter chips and
  expandable body markdown; failures surface errorReason and any
  fallback-to-link reason from the SMTP retry.
- /admin/email-templates lets per-port admins override the subject of
  each transactional template (8 templates catalogued in
  template-catalog.ts). Body editing is a follow-on; portal_activation
  + portal_reset are wired to honor the override via loadSubjectOverride.
- /admin/reports replaces the "Coming in Layer 3" placeholder with a
  KPI dashboard: 4 KPI tiles, pipeline funnel bars, berth occupancy
  donut-bars, conversion %, refresh every 60s.
- backup/import/onboarding admin pages replace placeholders with
  actionable guidance: backup posture + planned features, available CLI
  imports + planned UI, ordered onboarding checklist linking to admin
  pages.

Existing pages widened:
- settings-manager exposes the 9 berth-recommender tunables that were
  previously code-only (recommender_*, heat_weight_*, fallthrough_*,
  tier_ladder_hide_late_stage).
- role-form covers all 19 RolePermissions schema groups; previously
  missing yachts/companies/memberships/reservations + missing
  documents.edit + files.edit checkboxes. snake_case residential
  labels replaced with friendly text.

portal-auth.service.ts now also writes audit_log rows for portal
invite, resend, activate, password-reset request, and reset (closes one
more audit-pass-#2 gap while we were touching the file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 14:58:17 +02:00
parent 8cdee99310
commit c90876abad
22 changed files with 1703 additions and 54 deletions

View File

@@ -0,0 +1,166 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RotateCcw, Save } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
interface TemplateRow {
key: string;
label: string;
description: string;
mergeTokens: string[];
defaultSubject: string;
subjectOverride: string | null;
effectiveSubject: string;
}
export function EmailTemplatesAdmin() {
const qc = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['admin-email-templates'],
queryFn: () => apiFetch<{ data: TemplateRow[] }>('/api/v1/admin/email-templates'),
});
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const [message, setMessage] = useState<{ key: string; kind: 'ok' | 'err'; text: string } | null>(
null,
);
const rows = useMemo(() => data?.data ?? [], [data]);
useEffect(() => {
// Hydrate drafts from server values whenever the source-of-truth list refreshes.
const next: Record<string, string> = {};
for (const row of rows) {
next[row.key] = row.subjectOverride ?? row.defaultSubject;
}
setDrafts(next);
}, [rows]);
async function save(row: TemplateRow, mode: 'save' | 'reset') {
setSavingKey(row.key);
setMessage(null);
try {
const subject = mode === 'reset' ? null : (drafts[row.key] ?? '');
await apiFetch('/api/v1/admin/email-templates', {
method: 'PUT',
body: { key: row.key, subject },
});
await qc.invalidateQueries({ queryKey: ['admin-email-templates'] });
setMessage({
key: row.key,
kind: 'ok',
text: mode === 'reset' ? 'Reset to default' : 'Saved',
});
} catch (err) {
setMessage({
key: row.key,
kind: 'err',
text: err instanceof Error ? err.message : 'Failed',
});
} finally {
setSavingKey(null);
}
}
return (
<div>
<PageHeader
title="Email templates"
description="Customize the subject line of transactional emails per port. Body editing is the next iteration; for now the layout and HTML stay locked to the default template."
/>
<div className="mt-6 space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground py-6">Loading</p>
) : error ? (
<p className="text-sm text-red-600 py-6">
Failed to load templates: {error instanceof Error ? error.message : 'unknown error'}
</p>
) : (
rows.map((row) => {
const draft = drafts[row.key] ?? row.defaultSubject;
const dirty =
draft !== (row.subjectOverride ?? row.defaultSubject) ||
(row.subjectOverride !== null && draft === row.defaultSubject);
const overridden = row.subjectOverride !== null;
return (
<Card key={row.key}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base font-medium">{row.label}</CardTitle>
{overridden ? (
<Badge className="bg-blue-100 text-blue-800">Overridden</Badge>
) : (
<Badge variant="secondary">Default</Badge>
)}
</div>
<CardDescription>{row.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<label className="text-xs uppercase tracking-wide text-muted-foreground">
Subject
</label>
<Input
value={draft}
onChange={(e) =>
setDrafts((prev) => ({ ...prev, [row.key]: e.target.value }))
}
className="mt-1 font-mono text-sm"
/>
</div>
<div className="text-xs text-muted-foreground">
Default: <code className="font-mono">{row.defaultSubject}</code>
</div>
<div className="text-xs text-muted-foreground">
Available tokens:{' '}
{row.mergeTokens.map((t) => (
<code key={t} className="mr-1 font-mono">{`{{${t}}}`}</code>
))}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => save(row, 'save')}
disabled={savingKey === row.key || !dirty}
>
<Save className="h-3.5 w-3.5 mr-1.5" /> Save
</Button>
{overridden ? (
<Button
size="sm"
variant="outline"
onClick={() => save(row, 'reset')}
disabled={savingKey === row.key}
>
<RotateCcw className="h-3.5 w-3.5 mr-1.5" /> Reset to default
</Button>
) : null}
{message?.key === row.key ? (
<span
className={
message.kind === 'ok' ? 'text-sm text-green-600' : 'text-sm text-red-600'
}
>
{message.text}
</span>
) : null}
</div>
</CardContent>
</Card>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface Submission {
id: string;
portId: string;
submissionId: string;
kind: 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
payload: Record<string, unknown> | null;
legacyNocodbId: string | null;
sourceIp: string | null;
userAgent: string | null;
receivedAt: string;
}
interface ListResponse {
data: Submission[];
pagination: { nextCursor: { receivedAt: string; id: string } | null };
counts: Record<string, number>;
}
const KIND_LABELS: Record<Submission['kind'], string> = {
berth_inquiry: 'Berth inquiry',
residence_inquiry: 'Residence inquiry',
contact_form: 'Contact form',
};
const KIND_COLORS: Record<Submission['kind'], string> = {
berth_inquiry: 'bg-blue-100 text-blue-800',
residence_inquiry: 'bg-amber-100 text-amber-800',
contact_form: 'bg-slate-100 text-slate-800',
};
function pickName(payload: Record<string, unknown> | null): string {
if (!payload) return '';
const candidates = ['name', 'fullName', 'full_name', 'firstName', 'first_name'];
for (const k of candidates) {
const v = payload[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
}
function pickEmail(payload: Record<string, unknown> | null): string {
if (!payload) return '';
const v = payload['email'];
return typeof v === 'string' ? v : '';
}
function pickPhone(payload: Record<string, unknown> | null): string {
if (!payload) return '';
const v = payload['phone'] ?? payload['phoneNumber'] ?? payload['phone_number'];
return typeof v === 'string' ? v : '';
}
export function InquiryInbox() {
const [kind, setKind] = useState<Submission['kind'] | 'all'>('all');
const [expanded, setExpanded] = useState<string | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['inquiry-inbox', kind],
queryFn: () =>
apiFetch<ListResponse>(
`/api/v1/admin/website-submissions${kind === 'all' ? '' : `?kind=${kind}`}`,
),
});
const counts = data?.counts ?? {};
const totalAll = useMemo(() => Object.values(counts).reduce((sum, n) => sum + n, 0), [counts]);
const rows = data?.data ?? [];
return (
<div>
<PageHeader
title="Inquiry inbox"
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
/>
<div className="flex items-center gap-2 mt-6 flex-wrap">
<FilterChip
label={`All (${totalAll})`}
active={kind === 'all'}
onClick={() => setKind('all')}
/>
<FilterChip
label={`Berth inquiries (${counts.berth_inquiry ?? 0})`}
active={kind === 'berth_inquiry'}
onClick={() => setKind('berth_inquiry')}
/>
<FilterChip
label={`Residence (${counts.residence_inquiry ?? 0})`}
active={kind === 'residence_inquiry'}
onClick={() => setKind('residence_inquiry')}
/>
<FilterChip
label={`Contact (${counts.contact_form ?? 0})`}
active={kind === 'contact_form'}
onClick={() => setKind('contact_form')}
/>
</div>
<div className="mt-6">
{isLoading ? (
<p className="text-sm text-muted-foreground py-6">Loading</p>
) : error ? (
<p className="text-sm text-red-600 py-6">
Failed to load inquiries: {error instanceof Error ? error.message : 'unknown error'}
</p>
) : rows.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
No website submissions yet for this filter.
</CardContent>
</Card>
) : (
<div className="space-y-3">
{rows.map((row) => {
const name = pickName(row.payload);
const email = pickEmail(row.payload);
const phone = pickPhone(row.payload);
const ago = formatDistanceToNow(new Date(row.receivedAt), { addSuffix: true });
const isOpen = expanded === row.id;
return (
<Card key={row.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2">
<Badge className={KIND_COLORS[row.kind]}>{KIND_LABELS[row.kind]}</Badge>
<span
className="text-xs text-muted-foreground"
title={new Date(row.receivedAt).toISOString()}
>
{ago}
</span>
</div>
<CardTitle className="mt-2 text-base font-medium">
{name || '(no name supplied)'}
</CardTitle>
<div className="text-sm text-muted-foreground mt-1 space-x-3">
{email ? <span>{email}</span> : null}
{phone ? <span>{phone}</span> : null}
{row.sourceIp ? (
<span className="text-xs">from {row.sourceIp}</span>
) : null}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setExpanded(isOpen ? null : row.id)}
>
{isOpen ? 'Hide payload' : 'View payload'}
</Button>
</div>
</CardHeader>
{isOpen && (
<CardContent>
<pre className="bg-muted/40 rounded-md p-3 text-xs overflow-auto max-h-96">
{JSON.stringify(row.payload, null, 2)}
</pre>
</CardContent>
)}
</Card>
);
})}
</div>
)}
</div>
</div>
);
}
function FilterChip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`px-3 py-1.5 rounded-full text-sm border transition ${
active
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-muted'
}`}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, STAGE_LABELS, type PipelineStage } from '@/lib/constants';
interface DashboardStats {
totals: { totalClients: number; totalInterests: number; totalBerths: number };
recent: { newInquiries7d: number; completed30d: number };
pipeline: Record<string, number>;
berthStatus: { available: number; under_offer: number; sold: number };
conversion: { closedTotal: number; openTotal: number; conversionPct: number };
}
const BERTH_STATUS_COLORS: Record<string, string> = {
available: 'bg-green-500',
under_offer: 'bg-amber-500',
sold: 'bg-slate-500',
};
const BERTH_STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under offer',
sold: 'Sold',
};
export function ReportsDashboard() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading, error } = useQuery({
queryKey: ['admin-dashboard-stats'],
queryFn: () => apiFetch<{ data: DashboardStats }>('/api/v1/admin/dashboard-stats'),
refetchInterval: 60_000,
});
if (isLoading) {
return (
<div>
<PageHeader title="Reports" description="Pipeline, occupancy, and recent activity." />
<p className="text-sm text-muted-foreground py-6">Loading</p>
</div>
);
}
if (error || !data) {
return (
<div>
<PageHeader title="Reports" description="Pipeline, occupancy, and recent activity." />
<p className="text-sm text-red-600 py-6">
Failed to load stats: {error instanceof Error ? error.message : 'unknown error'}
</p>
</div>
);
}
const stats = data.data;
const maxStageCount = Math.max(1, ...Object.values(stats.pipeline));
const totalBerths =
stats.berthStatus.available + stats.berthStatus.under_offer + stats.berthStatus.sold;
return (
<div>
<PageHeader
title="Reports"
description="Live snapshot of clients, pipeline, and berth occupancy. Refreshes every minute."
/>
{/* KPI tiles */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-6">
<KpiTile label="Clients" value={stats.totals.totalClients} href={`/${portSlug}/clients`} />
<KpiTile
label="Open interests"
value={stats.conversion.openTotal}
href={`/${portSlug}/interests`}
/>
<KpiTile
label="Inquiries (7 days)"
value={stats.recent.newInquiries7d}
href={`/${portSlug}/admin/inquiries`}
/>
<KpiTile
label="Completed (30 days)"
value={stats.recent.completed30d}
accent={stats.recent.completed30d > 0 ? 'success' : undefined}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
{/* Pipeline funnel */}
<Card>
<CardHeader>
<CardTitle>Pipeline funnel</CardTitle>
<CardDescription>Open interests by stage (excludes archived).</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{PIPELINE_STAGES.map((stage) => {
const n = stats.pipeline[stage] ?? 0;
const pct = (n / maxStageCount) * 100;
return (
<li key={stage} className="text-sm">
<div className="flex items-center justify-between mb-1">
<span>{STAGE_LABELS[stage as PipelineStage]}</span>
<span className="font-medium">{n}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
<div className="mt-4 pt-4 border-t text-sm">
<span className="text-muted-foreground">Conversion (completed / total):</span>{' '}
<span className="font-medium">{stats.conversion.conversionPct}%</span>
</div>
</CardContent>
</Card>
{/* Berth occupancy */}
<Card>
<CardHeader>
<CardTitle>Berth occupancy</CardTitle>
<CardDescription>
Current public-status mix for {totalBerths} berths in this port.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(['available', 'under_offer', 'sold'] as const).map((status) => {
const n = stats.berthStatus[status];
const pct = totalBerths === 0 ? 0 : Math.round((n / totalBerths) * 100);
return (
<div key={status} className="text-sm">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${BERTH_STATUS_COLORS[status]}`}
/>
<span>{BERTH_STATUS_LABELS[status]}</span>
</div>
<span className="font-medium">
{n} <span className="text-muted-foreground">· {pct}%</span>
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full ${BERTH_STATUS_COLORS[status]}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
<p className="text-xs text-muted-foreground mt-6">
Need scheduled or downloadable reports?{' '}
<Link href={`/${portSlug}/reports` as never} className="underline">
Open the report generator
</Link>{' '}
to produce PDF exports of these views.
</p>
</div>
);
}
function KpiTile({
label,
value,
href,
accent,
}: {
label: string;
value: number;
href?: string;
accent?: 'success' | 'danger';
}) {
const accentClass =
accent === 'success' ? 'text-green-700' : accent === 'danger' ? 'text-red-700' : '';
const inner = (
<Card className="h-full transition hover:border-primary/40">
<CardContent className="py-5">
<div className={`text-3xl font-semibold ${accentClass}`}>{value}</div>
<div className="text-sm text-muted-foreground mt-1">{label}</div>
</CardContent>
</Card>
);
return href ? (
<Link href={href as never} className="block">
{inner}
</Link>
) : (
inner
);
}

View File

@@ -17,7 +17,8 @@ import {
} from '@/components/ui/accordion';
import { apiFetch } from '@/lib/api/client';
/** Default permissions structure matching RolePermissions type */
/** Default permissions structure matching RolePermissions type in
* src/lib/db/schema/users.ts. Keep this in sync when actions are added. */
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
interests: {
@@ -33,6 +34,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
documents: {
view: false,
create: false,
edit: false,
send_for_signing: false,
upload_signed: false,
delete: false,
@@ -54,7 +56,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
record_payment: false,
export: false,
},
files: { view: false, upload: false, delete: false, manage_folders: false },
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
email: { view: false, send: false, configure_account: false },
reminders: {
view_own: false,
@@ -67,6 +69,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
calendar: { connect: false, view_events: false },
reports: { view_dashboard: false, view_analytics: false, export: false },
document_templates: { view: false, generate: false, manage: false },
yachts: { view: false, create: false, edit: false, delete: false, transfer: false },
companies: { view: false, create: false, edit: false, delete: false },
memberships: { view: false, manage: false },
reservations: { view: false, create: false, activate: false, cancel: false },
admin: {
manage_users: false,
view_audit_log: false,
@@ -101,7 +107,13 @@ const GROUP_LABELS: Record<string, string> = {
calendar: 'Calendar',
reports: 'Reports',
document_templates: 'Document Templates',
yachts: 'Yachts',
companies: 'Companies',
memberships: 'Company Memberships',
reservations: 'Reservations',
admin: 'Administration',
residential_clients: 'Residential Clients',
residential_interests: 'Residential Interests',
};
function formatAction(action: string): string {

View File

@@ -0,0 +1,200 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow, format } from 'date-fns';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface SendRow {
id: string;
portId: string;
recipientEmail: string;
documentKind: 'berth_pdf' | 'brochure' | string;
fromAddress: string;
bodyMarkdown: string | null;
sentAt: string;
failedAt: string | null;
errorReason: string | null;
fallbackToLinkReason: string | null;
messageId: string | null;
berthId: string | null;
brochureId: string | null;
clientId: string | null;
interestId: string | null;
}
interface ListResponse {
data: SendRow[];
pagination: { nextCursor: { sentAt: string; id: string } | null };
counts: { sent: number; failed: number; all: number };
}
export function SendsLog() {
const [status, setStatus] = useState<'all' | 'sent' | 'failed'>('all');
const [expanded, setExpanded] = useState<string | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['document-sends', status],
queryFn: () => apiFetch<ListResponse>(`/api/v1/admin/document-sends?status=${status}`),
});
const counts = data?.counts ?? { sent: 0, failed: 0, all: 0 };
const rows = data?.data ?? [];
return (
<div>
<PageHeader
title="Send log"
description="Every brochure and per-berth PDF sent from the CRM, with delivery failures surfaced for retry."
/>
<div className="flex items-center gap-2 mt-6 flex-wrap">
<FilterChip
label={`All (${counts.all})`}
active={status === 'all'}
onClick={() => setStatus('all')}
/>
<FilterChip
label={`Sent (${counts.sent})`}
active={status === 'sent'}
onClick={() => setStatus('sent')}
/>
<FilterChip
label={`Failed (${counts.failed})`}
active={status === 'failed'}
onClick={() => setStatus('failed')}
accent={counts.failed > 0 ? 'danger' : undefined}
/>
</div>
<div className="mt-6">
{isLoading ? (
<p className="text-sm text-muted-foreground py-6">Loading</p>
) : error ? (
<p className="text-sm text-red-600 py-6">
Failed to load sends: {error instanceof Error ? error.message : 'unknown error'}
</p>
) : rows.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
No sends recorded for this filter yet.
</CardContent>
</Card>
) : (
<div className="space-y-3">
{rows.map((row) => {
const sent = new Date(row.sentAt);
const ago = formatDistanceToNow(sent, { addSuffix: true });
const isOpen = expanded === row.id;
const failed = !!row.failedAt;
return (
<Card key={row.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={
failed ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
}
>
{failed ? 'Failed' : 'Sent'}
</Badge>
<Badge variant="secondary">
{row.documentKind === 'berth_pdf'
? 'Berth PDF'
: row.documentKind === 'brochure'
? 'Brochure'
: row.documentKind}
</Badge>
{row.fallbackToLinkReason ? (
<Badge className="bg-amber-100 text-amber-800">
Switched to download link
</Badge>
) : null}
<span
className="text-xs text-muted-foreground"
title={sent.toISOString()}
>
{ago} · {format(sent, 'PP p')}
</span>
</div>
<CardTitle className="mt-2 text-base font-medium">
{row.recipientEmail}
</CardTitle>
<div className="text-sm text-muted-foreground mt-1">
From {row.fromAddress}
{row.messageId ? (
<span className="text-xs ml-2 font-mono">{row.messageId}</span>
) : null}
</div>
{failed && row.errorReason ? (
<div className="mt-2 text-sm text-red-700 bg-red-50 rounded-md p-2">
{row.errorReason}
</div>
) : null}
{row.fallbackToLinkReason ? (
<div className="mt-2 text-sm text-amber-700 bg-amber-50 rounded-md p-2">
Attachment dropped sent as link. Reason: {row.fallbackToLinkReason}
</div>
) : null}
</div>
{row.bodyMarkdown ? (
<Button
size="sm"
variant="outline"
onClick={() => setExpanded(isOpen ? null : row.id)}
>
{isOpen ? 'Hide body' : 'View body'}
</Button>
) : null}
</div>
</CardHeader>
{isOpen && row.bodyMarkdown ? (
<CardContent>
<pre className="bg-muted/40 rounded-md p-3 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
{row.bodyMarkdown}
</pre>
</CardContent>
) : null}
</Card>
);
})}
</div>
)}
</div>
</div>
);
}
function FilterChip({
label,
active,
onClick,
accent,
}: {
label: string;
active: boolean;
onClick: () => void;
accent?: 'danger';
}) {
const base = active
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-muted';
const dangerActive =
accent === 'danger' && active ? 'bg-red-600 text-white border-red-600' : null;
return (
<button
type="button"
onClick={onClick}
className={`px-3 py-1.5 rounded-full text-sm border transition ${dangerActive ?? base}`}
>
{label}
</button>
);
}

View File

@@ -118,6 +118,77 @@ const KNOWN_SETTINGS: Array<{
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
},
},
// ─── 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. One of: immediate_with_heat, cooldown, never_auto_recommend.',
type: 'string',
defaultValue: 'immediate_with_heat',
},
{
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,
},
];
export function SettingsManager() {