Files
pn-new-crm/src/components/admin/admin-sections-browser.tsx
Matt 6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00

513 lines
15 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 {
Activity,
BarChart3,
BellRing,
BookOpen,
ClipboardList,
CopyCheck,
DatabaseBackup,
FilePen,
FileSignature,
FileText,
FileUp,
Inbox,
ListChecks,
Mail,
MailPlus,
Paintbrush,
ScrollText,
Search,
Send,
Server,
Settings,
Shield,
Ship,
SlidersHorizontal,
Sparkles,
Tag,
TrendingUp,
Users,
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: MailPlus,
},
{
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: FileSignature,
},
{
href: 'reminders',
label: 'Reminders',
description: 'Default reminder behaviour and the daily-digest delivery window.',
icon: BellRing,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Paintbrush,
},
{
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: ClipboardList,
},
{
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: FilePen,
},
{
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: SlidersHorizontal,
},
],
},
{
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: Send,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: CopyCheck,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: FileUp,
},
{
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: BarChart3,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Activity,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: DatabaseBackup,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: Server,
},
],
},
{
title: 'Tenancy',
description: 'Multi-port and multi-install scaffolding.',
sections: [
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Ship,
},
{
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: ListChecks,
},
],
},
{
title: 'Integrations',
description: 'Third-party providers wired into the app.',
sections: [
{
href: 'ai',
label: 'AI configuration',
description:
'Master switch, provider credentials, and the Receipt OCR settings in one place. Per-feature pages (berth-PDF parser, recommender) link out from here.',
icon: Sparkles,
keywords: [
'openai',
'anthropic',
'gpt',
'claude',
'llm',
'api key',
'embeddings',
'receipt',
'scan',
'tesseract',
'ocr',
'expense scanner',
],
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: TrendingUp,
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: ListChecks,
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
},
{
href: 'qualification-criteria',
label: 'Qualification criteria',
description:
'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the soft "ready to qualify" hint on the interest detail.',
icon: ListChecks,
keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'],
},
],
},
];
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"
aria-hidden
/>
<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"
aria-hidden
/>
<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>
);
}