feat(uat-batch): Groups F + G + H — DocsHub/signing + admin consolidation + email

F27–F29, G30, G31, H32, H33 from the 2026-05-21 plan.

Shipped now:
  F28  Past-milestones expandable history. The Past strip on the
       Interest overview becomes an <Accordion> — each row collapses
       to the same one-line summary as before, expands to render the
       full <MilestoneSection> (steps list, sub-status, inline doc
       actions). Reuses the existing MilestoneSection so no new
       per-milestone rendering needs to be maintained.
  F29  Watchers configurable at document creation time. The unified
       create-document wizard gets a Watchers section with a
       multi-select checkbox list backed by /api/v1/admin/users/picker.
       Selected user ids are sent in the `watchers` array on the POST
       (replacing the prior hardcoded `[]`). UI matches the
       post-creation WatchersCard so reps see the same identity rows
       regardless of entry point.
  G30  /admin/invitations merged into /admin/users. The Users page
       now wraps the existing UserList + InvitationsManager in a
       Tabs control (Active users / Invitations). The standalone
       /admin/invitations route returns a redirect to the merged page
       for bookmark back-compat. Removed nav catalog entry +
       admin-sections-browser tile; extended the Users catalog
       keywords with "invitations / pending invites / onboarding"
       so command-K search still lands on the right surface.
  G31  /admin/ai picks up the berth-PDF-parser section + a "planned
       AI surfaces" placeholder. Berth PDF parser remains
       env-configured today; the page now documents it so admins
       don't hunt for the controls. Closes the "where do I configure
       AI?" loop.
  H32  Email settings explainer panel above the SMTP cards. Spells
       out why noreply + sales have separate credentials and which
       workflows ship from each mailbox. Existing field titles
       gained the "(noreply)" suffix so the model maps cleanly.
  H33  Supplemental-info-request email rebuilt to use the shared
       branded shell (logo + blurred overhead background + max-
       width 600 table layout) instead of the prior plain-HTML
       page. Per-port branding (logo / primary color / background /
       header / footer) flows from getPortBrandingConfig. CTA
       button picks up the port's primary color.

Already shipped (verified pre-shipped):
  F27  DocumentsHub root view already hides the breadcrumb via
       `selectedFolderId !== undefined` conditional.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:40:48 +02:00
parent 431375d794
commit 94c24a123a
9 changed files with 278 additions and 57 deletions

View File

@@ -1,4 +1,4 @@
import { Bot } from 'lucide-react';
import { Bot, FileScan, Lightbulb } from 'lucide-react';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { PageHeader } from '@/components/shared/page-header';
@@ -10,7 +10,7 @@ export default function AiAdminPage() {
<div className="space-y-6">
<PageHeader
title="AI configuration"
description="One place to manage every AI-using feature. Provider credentials and the master AI switch live here; per-feature thresholds remain in their dedicated pages, linked below."
description="One place to manage every AI-using feature. Provider credentials and the master AI switch live here; per-feature thresholds are embedded below."
eyebrow="ADMIN"
/>
@@ -40,6 +40,46 @@ export default function AiAdminPage() {
<OcrSettingsForm embedded />
</CardContent>
</Card>
{/*
Berth-PDF parser AI fallback — currently configured via the
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
when one is added, it lands here so admins don't have to hunt.
*/}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<FileScan className="h-4 w-4" /> Berth PDF parser
</CardTitle>
<CardDescription>
3-tier extraction (AcroForm on-device OCR AI fallback on low confidence) for
per-berth PDFs and brochures. Provider + confidence threshold are env-controlled today
(BERTH_PDF_PARSER_PROVIDER, BERTH_PDF_PARSER_CONFIDENCE_FLOOR); a per-port override UI
lands in a follow-up. The master switch above gates the AI tier across every port.
</CardDescription>
</CardHeader>
</Card>
{/*
Future AI surfaces. Each gets a section here once it ships:
- Recommender embeddings (currently rule-based, not LLM-based)
- Contact-log action extraction (deferred — needs user demand)
- Inquiry-form auto-classification (deferred)
Listing them inert here closes the "where do I configure AI?"
loop — admins land on /admin/ai and see the full landscape.
*/}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2 text-muted-foreground">
<Lightbulb className="h-4 w-4" /> Planned AI surfaces
</CardTitle>
<CardDescription>
Recommender embeddings, contact-log action extraction, and inquiry-form auto-
classification are queued. They will surface as additional sections on this page when
shipped, with no scattered admin entries to hunt down.
</CardDescription>
</CardHeader>
</Card>
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { Info } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
@@ -11,18 +13,43 @@ export default function EmailSettingsPage() {
title="Email Settings"
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
/>
{/* Explainer for the "two accounts" model — addresses the recurring
UAT question "why are there separate SMTP credentials for sales
and noreply?". Keeps the answer in front of the admin before
they reach the per-card form below. */}
<div className="rounded-md border border-border bg-muted/40 px-4 py-3 text-sm">
<div className="flex items-start gap-2">
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="space-y-1 text-muted-foreground">
<p>
<strong className="text-foreground">Why two accounts?</strong> Transactional emails
(signing invites, notifications, password resets) ship from your noreply mailbox over
the SMTP credentials below. Rep-authored sales emails (one-off messages, proposal
sends) ship from the sales mailbox with separate credentials so replies land in a
human-monitored inbox.
</p>
<p>
The noreply credentials are also used by the supplemental-info workflow + portal
activation, i.e. anywhere the platform sends on its own initiative. The sales
credentials are only used when a rep clicks Send in the compose UI.
</p>
</div>
</div>
</div>
{/* Registry-driven so each field shows the "Using env fallback /
port / global / default" badge inline — admins can tell at a
glance which fields are coming from .env vs. UI overrides. */}
<RegistryDrivenForm
sections={['email.from']}
title="From address"
description="Identity headers used by system-generated emails."
title="From address (noreply)"
description="Identity headers used by system-generated emails. Set the From + Reply-To here; the matching SMTP credentials live in the next card."
/>
<RegistryDrivenForm
sections={['email.smtp']}
title="SMTP transport overrides"
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
title="SMTP transport overrides (noreply)"
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
/>
<SmtpTestSendCard />
<SalesEmailConfigCard />

View File

@@ -1,14 +1,15 @@
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
import { PageHeader } from '@/components/shared/page-header';
import { redirect } from 'next/navigation';
export default function InvitationsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Invitations"
description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
/>
<InvitationsManager />
</div>
);
/**
* 2026-05-21: /admin/invitations was merged into /admin/users (Users +
* Invitations tabs on a single page). This stub keeps old bookmarks +
* external links working by redirecting to the canonical destination.
*/
export default async function InvitationsRedirectPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
redirect(`/${portSlug}/admin/users`);
}

View File

@@ -1,5 +1,34 @@
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
import { UserList } from '@/components/admin/users/user-list';
import { PageHeader } from '@/components/shared/page-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
/**
* "People with access" surface — covers BOTH currently-active CRM users
* and pending invitations. Previously these lived on separate routes
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
* on one page and tab between states. The standalone /admin/invitations
* route now redirects here for back-compat with bookmarks.
*/
export default function UserManagementPage() {
return <UserList />;
return (
<div className="space-y-6">
<PageHeader
title="Users"
description="Active CRM users and pending invitations. Switch tabs to manage invitations."
/>
<Tabs defaultValue="active" className="space-y-4">
<TabsList>
<TabsTrigger value="active">Active users</TabsTrigger>
<TabsTrigger value="invitations">Invitations</TabsTrigger>
</TabsList>
<TabsContent value="active">
<UserList />
</TabsContent>
<TabsContent value="invitations">
<InvitationsManager />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -9,7 +9,8 @@ import {
} from '@/lib/services/supplemental-forms.service';
import { sendEmail } from '@/lib/email';
import { env } from '@/lib/env';
import { getPortEmailConfig } from '@/lib/services/port-config';
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
import { brandingPrimaryColor, renderShell } from '@/lib/email/shell';
/**
* POST /api/v1/interests/[id]/supplemental-info-request
@@ -82,22 +83,56 @@ export const POST = withAuth(
// `sendEmail` body flag.
const willSendEmail = resendTokenId ? true : shouldSendEmail;
if (willSendEmail && result.clientEmail) {
const html = `
<p>Hello ${escapeHtml(result.clientName)},</p>
<p>Before we draft your Expression of Interest, we need to confirm a few details.
// Render through the shared branded shell (logo + blurred overhead
// background + max-width 600 table layout) so the supplemental-
// info email matches portal-activation / reset / login + the rest
// of the branded surfaces. Per-port branding (logo, primary
// color, background image, header/footer) flows from
// system_settings via getPortBrandingConfig.
const branding = await getPortBrandingConfig(ctx.portId);
const accent = brandingPrimaryColor({
logoUrl: branding.logoUrl,
backgroundUrl: branding.emailBackgroundUrl,
primaryColor: branding.primaryColor,
emailHeaderHtml: branding.emailHeaderHtml,
emailFooterHtml: branding.emailFooterHtml,
});
const body = `
<h1 style="font-family:Arial,sans-serif;font-size:20px;font-weight:600;color:#0f172a;margin:0 0 16px;">
One quick step before your EOI
</h1>
<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 12px;">
Hello ${escapeHtml(result.clientName)},
</p>
<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 16px;">
Before we draft your Expression of Interest, we need to confirm a few details.
The form below is pre-filled with what we have on file — please review, correct
anything that's wrong, and add what's missing.</p>
<p style="text-align:center;margin:24px 0">
anything that&apos;s wrong, and add what&apos;s missing.
</p>
<p style="text-align:center;margin:24px 0;">
<a href="${link}"
style="background:#1e3a8a;color:#fff;text-decoration:none;padding:12px 24px;border-radius:6px;display:inline-block">
style="display:inline-block;background:${accent};color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:6px;font-family:Arial,sans-serif;font-size:14px;font-weight:600;">
Open the form
</a>
</p>
<p style="color:#64748b;font-size:12px">
<p style="font-family:Arial,sans-serif;font-size:12px;color:#64748b;margin:0 0 4px;">
This link expires on ${result.expiresAt.toUTCString()}.
If you didn't expect this email, please let us know.
</p>
<p style="font-family:Arial,sans-serif;font-size:12px;color:#64748b;margin:0;">
If you didn&apos;t expect this email, please let us know.
</p>
`;
const html = renderShell({
title: 'Please complete a few details before we draft your EOI',
body,
branding: {
logoUrl: branding.logoUrl,
backgroundUrl: branding.emailBackgroundUrl,
primaryColor: branding.primaryColor,
emailHeaderHtml: branding.emailHeaderHtml,
emailFooterHtml: branding.emailFooterHtml,
},
});
await sendEmail(
result.clientEmail,
'Please complete a few details before we draft your EOI',