From 94c24a123af618282b85a3ba27971651439e0b45 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 22:40:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20Groups=20F=20+=20G=20+=20H?= =?UTF-8?q?=20=E2=80=94=20DocsHub/signing=20+=20admin=20consolidation=20+?= =?UTF-8?q?=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — each row collapses to the same one-line summary as before, expands to render the full (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) --- .../(dashboard)/[portSlug]/admin/ai/page.tsx | 44 ++++++++++++- .../[portSlug]/admin/email/page.tsx | 35 +++++++++-- .../[portSlug]/admin/invitations/page.tsx | 25 ++++---- .../[portSlug]/admin/users/page.tsx | 31 +++++++++- .../[id]/supplemental-info-request/route.ts | 53 +++++++++++++--- .../admin/admin-sections-browser.tsx | 11 ++-- .../documents/create-document-wizard.tsx | 62 ++++++++++++++++++- src/components/interests/interest-tabs.tsx | 51 +++++++++++---- src/lib/services/search-nav-catalog.ts | 23 ++++--- 9 files changed, 278 insertions(+), 57 deletions(-) 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 ( +
+ + + + Active users + Invitations + + + + + + + + +
+ ); } 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. +

+

+ 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

-

+

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. +

+ {watcherUsers.length === 0 ? ( +

No users available to add.

+ ) : ( +
+ {watcherUsers.map((u) => { + const checked = watcherUserIds.includes(u.id); + return ( + + ); + })} +
+ )} +
+

Reminders diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 32675232..e5e61e50 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -11,6 +11,12 @@ import type { DetailTab } from '@/components/shared/detail-layout'; import { Button } from '@/components/ui/button'; import { DatePicker } from '@/components/ui/date-picker'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; import { NotesList } from '@/components/shared/notes-list'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { ClientChannelEditor } from '@/components/clients/client-channel-editor'; @@ -1022,18 +1028,41 @@ function OverviewTab({ skip-ahead when reality calls for it (an override-confirm gates the actual stage move). */} {pastMilestones.length > 0 && ( -
-
- Past - {pastMilestones.map((m) => ( - - - {m.title} - · - {m.pastSummary} - - ))} +
+
+ Past
+ + {pastMilestones.map((m) => ( + + +
+ + {m.title} + · + {m.pastSummary} +
+
+ + {/* 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. */