diff --git a/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx b/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
index 3a8649a2..2a56eac5 100644
--- a/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
@@ -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() {
@@ -40,6 +40,46 @@ export default function AiAdminPage() {
+
+ {/*
+ 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.
+ */}
+
+
+
+ Berth PDF parser
+
+
+ 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.
+
+
+
+
+ {/*
+ 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.
+ */}
+
+
+
+ Planned AI surfaces
+
+
+ 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.
+
+
+
);
}
diff --git a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx
index 941d1ba4..42db8ff9 100644
--- a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx
@@ -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. */}
+
+
+
+
+
+ Why two accounts? 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.
+
+
+ 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.
+
+
+
+
+
{/* 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. */}
diff --git a/src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx b/src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx
index da5e4fcd..bc33b313 100644
--- a/src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx
@@ -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 (
-
-
-
-
- );
+/**
+ * 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`);
}
diff --git a/src/app/(dashboard)/[portSlug]/admin/users/page.tsx b/src/app/(dashboard)/[portSlug]/admin/users/page.tsx
index 2a767c0e..32d0ac77 100644
--- a/src/app/(dashboard)/[portSlug]/admin/users/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/users/page.tsx
@@ -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 ;
+ return (
+
+ );
}
diff --git a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts
index 4f323759..0b93b76c 100644
--- a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts
+++ b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts
@@ -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 = `
-
Hello ${escapeHtml(result.clientName)},
-
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 = `
+
+ One quick step before your EOI
+
+
+ Hello ${escapeHtml(result.clientName)},
+
+
+ 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.
-
+ anything that's wrong, and add what's missing.
+
This link expires on ${result.expiresAt.toUTCString()}.
- If you didn't expect this email, please let us know.
+
+
+ If you didn't expect this email, please let us know.
`;
+ 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',
diff --git a/src/components/admin/admin-sections-browser.tsx b/src/components/admin/admin-sections-browser.tsx
index ab85e737..6b522ef3 100644
--- a/src/components/admin/admin-sections-browser.tsx
+++ b/src/components/admin/admin-sections-browser.tsx
@@ -18,7 +18,6 @@ import {
Inbox,
ListChecks,
Mail,
- MailPlus,
Paintbrush,
ScrollText,
Search,
@@ -87,12 +86,10 @@ const GROUPS: AdminGroup[] = [
'residential access',
],
},
- {
- href: 'invitations',
- label: 'Invitations',
- description: 'Send invitations, track pending invites, and resend or revoke them.',
- icon: MailPlus,
- },
+ // /admin/invitations merged into /admin/users (Active users +
+ // Invitations tabs) on 2026-05-21. The standalone tile was
+ // removed; reps still find the invitation flow via the Users
+ // tile's "Invitations" tab.
{
href: 'roles',
label: 'Roles & Permissions',
diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx
index bd1a3363..bd423044 100644
--- a/src/components/documents/create-document-wizard.tsx
+++ b/src/components/documents/create-document-wizard.tsx
@@ -1,9 +1,9 @@
'use client';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
-import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
+import { ArrowLeft, Eye, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -78,6 +78,23 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
const [subjectType, setSubjectType] = useState<(typeof SUBJECT_TYPES)[number]['key']>('interest');
const [subjectId, setSubjectId] = useState('');
+ // Watchers picked at create-time. Each selected user gets an in-app
+ // notification on every signing event (opened, signed, declined,
+ // completed) once the document is created. Same surface the
+ // post-creation WatchersCard exposes, but lets the rep wire it
+ // upfront instead of digging into the detail page afterwards.
+ const [watcherUserIds, setWatcherUserIds] = useState([]);
+ const [watcherUsers, setWatcherUsers] = useState<
+ Array<{ id: string; name: string | null; email: string | null }>
+ >([]);
+ useEffect(() => {
+ void apiFetch<{
+ data: Array<{ id: string; name: string | null; email: string | null }>;
+ }>('/api/v1/admin/users/picker')
+ .then((res) => setWatcherUsers(res.data ?? []))
+ .catch(() => setWatcherUsers([]));
+ }, []);
+
const [signers, setSigners] = useState([
{ signerName: '', signerEmail: '', signerRole: 'client', signingOrder: 1 },
]);
@@ -154,7 +171,7 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
notes: notes.trim() || undefined,
[subjectField]: subjectId.trim(),
signingMode,
- watchers: [],
+ watchers: watcherUserIds,
autoPlaceFields: true,
sendImmediately: false,
remindersDisabled: reminderMode === 'disabled',
@@ -427,6 +444,45 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
+
+
+
+ Watchers
+
+
+ Selected users receive an in-app notification on every signing event (opened, signed,
+ declined, completed). Can be edited from the document detail page after creation.
+
+
+
+ {/* Reuse the same MilestoneSection layout used for the
+ current milestone — the steps list, sub-status badge,
+ and any inline doc actions all render the same way.
+ `isActive={false}` keeps the NEXT-STEP pill off. */}
+
+
+
+ ))}
+
)}
diff --git a/src/lib/services/search-nav-catalog.ts b/src/lib/services/search-nav-catalog.ts
index f6cb7ce9..4bd0836f 100644
--- a/src/lib/services/search-nav-catalog.ts
+++ b/src/lib/services/search-nav-catalog.ts
@@ -167,7 +167,17 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
href: '/:portSlug/admin/users',
label: 'Users & roles',
category: 'admin',
- keywords: ['accounts', 'permissions', 'invites', 'team', 'staff', 'roles'],
+ keywords: [
+ 'accounts',
+ 'permissions',
+ 'invites',
+ 'invitations',
+ 'pending invites',
+ 'onboarding',
+ 'team',
+ 'staff',
+ 'roles',
+ ],
requires: 'admin.manage_users',
},
{
@@ -395,13 +405,10 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
keywords: ['roles', 'permissions', 'access control', 'rbac'],
requires: 'admin.manage_users',
},
- {
- href: '/:portSlug/admin/invitations',
- label: 'Invitations',
- category: 'admin',
- keywords: ['invite', 'pending invites', 'onboarding'],
- requires: 'admin.manage_users',
- },
+ // /admin/invitations was merged into /admin/users on 2026-05-21 — the
+ // standalone catalog entry would route to the redirect stub. Reps
+ // searching for "invite" still land on the right surface via the
+ // /admin/users keyword list (extended below).
];
/** Substitute `:portSlug` placeholder for the current port. */