Files
pn-new-crm/src/components/admin/admin-sections-browser.tsx
Matt a8dec0bada fix(audit-wave-9): onboarding + first-run UX fixes (onboarding-auditor)
Address the CRITICAL and high-leverage HIGH items from the
onboarding-auditor report:

**C1 — checklist auto-checks were reading the wrong setting keys**
A port that had actually been configured still showed three steps as
incomplete, permanently capping the checklist at < 70 %.

- email step: `sales_email_smtp_host` → `smtp_host_override` (the key
  the email admin page actually persists).
- documenso step: `documenso_api_url` → compound gate
  `documenso_api_url_override` + `documenso_developer_email` +
  `documenso_approver_email` + `documenso_eoi_template_id`. All four
  are required for `buildDocumensoPayload` not to error out; checking
  only the URL falsely greenlit the step until a rep tried to send an
  EOI and Documenso 404'd.
- settings step: `recommender_top_n_default` → `heat_weight_recency`.
  The defaults are layered (port > global > built-in), so a port using
  the built-ins never writes the `top_n_default` row — old key was an
  unreachable green. heat_weight_recency genuinely means "admin tuned
  the recommender".

**C2 — forms step href was broken**
`STEPS[8].href = '../'` resolved through the Link template to the
dashboard, not `/admin/forms`. Fixed to `'forms'`.

**C3 — EOI signer-identity gate**
Folded into the new compound-gate logic on the documenso step
(see C1). Now matches what the EOI pipeline actually requires before
it can send.

**C4 — ensureSystemRoots failure mode poisoned port creation**
`ports.service.createPort` awaited `ensureSystemRoots` after the port
row had committed, so a throw bubbled out as a 500 even though the
inline comment said "non-fatal if this throws". Wrap in try/catch +
logger.warn — the row stays live, the next admin action self-heals
via `ensureEntityFolder`, and the operator doesn't retry into a 409.

**H5 — berth-list empty-state copy misleads fresh ports**
"Berths are imported from external sources. Adjust your filters..."
implied data existed but was hidden. Branch on whether any filter is
active: with none, suggest running `import-berths-from-nocodb.ts`;
with filters, the original "adjust filters" message.

**M4 — admin-sections-browser description was wrong**
"Setup checklist for fresh ports (read-only references)" implied the
page was read-only when it has working manual-completion checkboxes
and discouraged clicking in. Reworded.

Additionally, the OnboardingStep type gains an optional
`autoCheckSettingKeysAll` field for compound gates (used by the
documenso step), and the auto-detected hint shows all keys when the
gate is compound.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:15:46 +02:00

486 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
Bell,
BookOpen,
Briefcase,
Database,
FileText,
Globe,
HardDrive,
Inbox,
Key,
LayoutDashboard,
Mail,
Palette,
ScrollText,
Search,
Settings,
Shield,
Sliders,
Tag,
Upload,
Users,
UsersRound,
Webhook,
X,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface AdminSection {
href: string;
label: string;
description: string;
icon: LucideIcon;
/**
* Free-text aliases the search input also matches against. Use this to
* surface the inner setting keys exposed *inside* a section card so
* typing "client portal" finds the System Settings card that hosts
* `client_portal_enabled`, not just labels that literally contain the
* phrase.
*/
keywords?: string[];
}
interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
// Catalog lives inside the client component so React Server Components don't
// need to serialize the lucide icon factories (functions / $$typeof refs)
// across the RSC boundary — that crash was the error the rep hit when the
// catalog lived in the server-component `page.tsx`. Adding a new admin
// surface? Append it to the matching group below.
const GROUPS: AdminGroup[] = [
{
title: 'Access',
description: 'Who can sign in and what they can do once they do.',
sections: [
{
href: 'users',
label: 'Users',
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
icon: Users,
keywords: [
'accounts',
'staff',
'team',
'disable user',
'reset password',
'residential access',
],
},
{
href: 'invitations',
label: 'Invitations',
description: 'Send invitations, track pending invites, and resend or revoke them.',
icon: Mail,
},
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
},
],
},
{
title: 'Configuration',
description: 'Branding, integrations, and per-port settings.',
sections: [
{
href: 'email',
label: 'Email Settings',
description: 'From address, signatures, and per-port SMTP overrides.',
icon: Mail,
},
{
href: 'documenso',
label: 'EOI signing service',
description:
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileText,
},
{
href: 'reminders',
label: 'Reminders',
description: 'Default reminder behaviour and the daily-digest delivery window.',
icon: Bell,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Palette,
},
{
href: 'settings',
label: 'System Settings',
description: 'Generic key/value configuration store for advanced flags.',
icon: Settings,
// Mirrors KNOWN_SETTINGS in settings-manager.tsx so a search for any
// individual flag jumps straight to this card. Keep in sync when
// adding new keys there.
keywords: [
'client portal',
'client portal enabled',
'ai',
'ai interest scoring',
'ai email drafts',
'invoice net10 discount',
'net-10',
'pipeline weights',
'pipeline stage weights',
'forecast',
'berth rules',
'berth status rules',
'inquiry contact email',
'inquiry notification recipients',
'residential notification recipients',
'eoi signers',
'developer',
'approver',
'countersign',
'recommender max oversize',
'recommender top n',
'recommender default count',
'fallthrough policy',
'fallthrough cooldown',
'heat weight recency',
'heat weight furthest stage',
'heat weight interest count',
'heat weight eoi count',
'tier ladder',
'hide late stage',
'documents show expired tab',
'expired tab',
'berths default currency',
'default currency',
],
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
],
},
{
title: 'Content',
description: 'Forms, templates, and labels that users see.',
sections: [
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: Sliders,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'email-templates',
label: 'Email Templates',
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
icon: Mail,
},
{
href: 'tags',
label: 'Tags',
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',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
},
],
},
{
title: 'Data Quality',
description: 'Cleanup, imports, and the audit trail.',
sections: [
{
href: 'inquiries',
label: 'Inquiry Inbox',
description:
'Submissions captured from the public marketing site (berth, residence, contact).',
icon: Inbox,
},
{
href: 'sends',
label: 'Send Log',
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
icon: Mail,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: UsersRound,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
],
},
{
title: 'Operations',
description: 'Health checks and disaster recovery.',
sections: [
{
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: HardDrive,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: HardDrive,
},
],
},
{
title: 'Tenancy',
description: 'Multi-port and multi-install scaffolding.',
sections: [
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
},
{
href: 'onboarding',
label: 'Onboarding checklist',
description:
'Step-by-step setup checklist for fresh ports — auto-detects what youve configured and lets you mark manual steps complete.',
icon: LayoutDashboard,
},
],
},
{
title: 'Integrations',
description: 'Third-party providers wired into the app.',
sections: [
{
href: 'ai',
label: 'AI configuration',
description:
'Master switch + provider credentials shared by every AI surface (OCR, berth-PDF parser, future recommender embeddings).',
icon: ScrollText,
keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'],
},
{
href: 'ocr',
label: 'Receipt OCR (per-feature)',
description: 'Provider, model, and confidence thresholds for the receipt scanner.',
icon: ScrollText,
keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'],
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: Globe,
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
},
{
href: 'residential-stages',
label: 'Residential pipeline stages',
description:
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
icon: ScrollText,
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
},
],
},
];
interface AdminSectionsBrowserProps {
portSlug: string;
}
/**
* Searchable index of admin settings cards. The unfiltered view renders the
* grouped grid (Access / Configuration / Content / …); typing in the search
* input collapses every section into a flat result list of matching cards.
*
* Match is substring against label + description + group title so a search
* for "tax" finds Document Templates (description mentions tax-id mergefield)
* as well as the literal "Tax ID" field.
*/
export function AdminSectionsBrowser({ portSlug }: AdminSectionsBrowserProps) {
const [query, setQuery] = useState('');
const q = query.trim().toLowerCase();
const filteredMatches = useMemo(() => {
if (!q) return null;
const matches: Array<AdminSection & { groupTitle: string }> = [];
for (const g of GROUPS) {
for (const s of g.sections) {
const hay = [s.label, s.description, g.title, ...(s.keywords ?? [])]
.join(' ')
.toLowerCase();
if (hay.includes(q)) matches.push({ ...s, groupTitle: g.title });
}
}
return matches;
}, [q]);
return (
<div className="space-y-6">
<div className="relative max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
inputMode="search"
placeholder="Search settings…"
aria-label="Search admin settings"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 pl-9 pr-9"
/>
{query ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setQuery('')}
className="absolute right-1 top-1 h-7 w-7"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
{filteredMatches ? (
filteredMatches.length === 0 ? (
<p className="rounded-md border border-dashed py-8 text-center text-sm text-muted-foreground">
No settings match &quot;{query}&quot;.
</p>
) : (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{filteredMatches.length} match{filteredMatches.length === 1 ? '' : 'es'}
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredMatches.map((s) => (
<SectionCard
key={`${s.href}-${s.groupTitle}`}
portSlug={portSlug}
section={s}
groupTitle={s.groupTitle}
/>
))}
</div>
</div>
)
) : (
GROUPS.map((group) => (
<section key={group.title} className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</h2>
<p className="text-xs text-muted-foreground/80">{group.description}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{group.sections.map((s) => (
<SectionCard key={s.href} portSlug={portSlug} section={s} />
))}
</div>
</section>
))
)}
</div>
);
}
function SectionCard({
portSlug,
section,
groupTitle,
}: {
portSlug: string;
section: AdminSection;
groupTitle?: string;
}) {
const Icon = section.icon;
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/admin/${section.href}` as any}
className="block group"
>
<Card
className={cn(
'h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30',
)}
>
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
<div className="flex-1">
<CardTitle className="text-base">{section.label}</CardTitle>
{groupTitle ? (
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{groupTitle}
</p>
) : null}
</div>
</CardHeader>
<CardContent>
<CardDescription>{section.description}</CardDescription>
</CardContent>
</Card>
</Link>
);
}