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

@@ -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',

View File

@@ -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<string[]>([]);
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<SignerRow[]>([
{ 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) {
</div>
</section>
<section className="rounded-md border bg-white p-4 lg:col-span-2">
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
<Eye className="size-3.5" aria-hidden />
Watchers
</h2>
<p className="mb-2 text-xs text-muted-foreground">
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.
</p>
{watcherUsers.length === 0 ? (
<p className="text-xs italic text-muted-foreground">No users available to add.</p>
) : (
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{watcherUsers.map((u) => {
const checked = watcherUserIds.includes(u.id);
return (
<label
key={u.id}
className="flex cursor-pointer items-center gap-2 rounded-sm px-1 py-0.5 text-sm hover:bg-muted/50"
>
<input
type="checkbox"
checked={checked}
onChange={() =>
setWatcherUserIds((prev) =>
checked ? prev.filter((id) => id !== u.id) : [...prev, u.id],
)
}
/>
<span className="truncate">
{u.name ?? u.email ?? `User ${u.id.slice(0, 8)}`}
</span>
</label>
);
})}
</div>
)}
</section>
<section className="rounded-md border bg-white p-4 lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Reminders

View File

@@ -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 && (
<div className="rounded-lg border bg-muted/20 px-4 py-2.5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="text-[10px] font-semibold uppercase tracking-wide">Past</span>
{pastMilestones.map((m) => (
<span key={m.key} className="inline-flex items-center gap-1.5">
<CheckCircle2 className="size-3 text-emerald-600" aria-hidden />
<span className="font-medium text-foreground">{m.title}</span>
<span>·</span>
<span>{m.pastSummary}</span>
</span>
))}
<div className="rounded-lg border bg-muted/20">
<div className="flex items-center gap-2 border-b px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>Past</span>
</div>
<Accordion type="multiple" className="px-4">
{pastMilestones.map((m) => (
<AccordionItem key={m.key} value={m.key} className="border-0">
<AccordionTrigger className="py-2 text-xs font-medium hover:no-underline">
<div className="flex flex-1 items-center gap-2 text-left text-muted-foreground">
<CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden />
<span className="font-medium text-foreground">{m.title}</span>
<span className="text-[10px]">·</span>
<span className="truncate text-xs">{m.pastSummary}</span>
</div>
</AccordionTrigger>
<AccordionContent>
{/* 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. */}
<MilestoneSection
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={false}
steps={m.steps}
footer={m.footer}
/>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
)}