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:
@@ -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 { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
@@ -10,7 +10,7 @@ export default function AiAdminPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="AI configuration"
|
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"
|
eyebrow="ADMIN"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -40,6 +40,46 @@ export default function AiAdminPage() {
|
|||||||
<OcrSettingsForm embedded />
|
<OcrSettingsForm embedded />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||||
@@ -11,18 +13,43 @@ export default function EmailSettingsPage() {
|
|||||||
title="Email Settings"
|
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."
|
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 /
|
{/* Registry-driven so each field shows the "Using env fallback /
|
||||||
port / global / default" badge inline — admins can tell at a
|
port / global / default" badge inline — admins can tell at a
|
||||||
glance which fields are coming from .env vs. UI overrides. */}
|
glance which fields are coming from .env vs. UI overrides. */}
|
||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
sections={['email.from']}
|
sections={['email.from']}
|
||||||
title="From address"
|
title="From address (noreply)"
|
||||||
description="Identity headers used by system-generated emails."
|
description="Identity headers used by system-generated emails. Set the From + Reply-To here; the matching SMTP credentials live in the next card."
|
||||||
/>
|
/>
|
||||||
<RegistryDrivenForm
|
<RegistryDrivenForm
|
||||||
sections={['email.smtp']}
|
sections={['email.smtp']}
|
||||||
title="SMTP transport overrides"
|
title="SMTP transport overrides (noreply)"
|
||||||
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."
|
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 />
|
<SmtpTestSendCard />
|
||||||
<SalesEmailConfigCard />
|
<SalesEmailConfigCard />
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
import { redirect } from 'next/navigation';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
|
||||||
|
|
||||||
export default function InvitationsPage() {
|
/**
|
||||||
return (
|
* 2026-05-21: /admin/invitations was merged into /admin/users (Users +
|
||||||
<div className="space-y-6">
|
* Invitations tabs on a single page). This stub keeps old bookmarks +
|
||||||
<PageHeader
|
* external links working by redirecting to the canonical destination.
|
||||||
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."
|
export default async function InvitationsRedirectPage({
|
||||||
/>
|
params,
|
||||||
<InvitationsManager />
|
}: {
|
||||||
</div>
|
params: Promise<{ portSlug: string }>;
|
||||||
);
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
redirect(`/${portSlug}/admin/users`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
|
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
||||||
import { UserList } from '@/components/admin/users/user-list';
|
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() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
} from '@/lib/services/supplemental-forms.service';
|
} from '@/lib/services/supplemental-forms.service';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import { env } from '@/lib/env';
|
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
|
* POST /api/v1/interests/[id]/supplemental-info-request
|
||||||
@@ -82,22 +83,56 @@ export const POST = withAuth(
|
|||||||
// `sendEmail` body flag.
|
// `sendEmail` body flag.
|
||||||
const willSendEmail = resendTokenId ? true : shouldSendEmail;
|
const willSendEmail = resendTokenId ? true : shouldSendEmail;
|
||||||
if (willSendEmail && result.clientEmail) {
|
if (willSendEmail && result.clientEmail) {
|
||||||
const html = `
|
// Render through the shared branded shell (logo + blurred overhead
|
||||||
<p>Hello ${escapeHtml(result.clientName)},</p>
|
// background + max-width 600 table layout) so the supplemental-
|
||||||
<p>Before we draft your Expression of Interest, we need to confirm a few details.
|
// 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
|
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>
|
anything that's wrong, and add what's missing.
|
||||||
<p style="text-align:center;margin:24px 0">
|
</p>
|
||||||
|
<p style="text-align:center;margin:24px 0;">
|
||||||
<a href="${link}"
|
<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
|
Open the form
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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()}.
|
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't expect this email, please let us know.
|
||||||
</p>
|
</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(
|
await sendEmail(
|
||||||
result.clientEmail,
|
result.clientEmail,
|
||||||
'Please complete a few details before we draft your EOI',
|
'Please complete a few details before we draft your EOI',
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
Inbox,
|
Inbox,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Mail,
|
Mail,
|
||||||
MailPlus,
|
|
||||||
Paintbrush,
|
Paintbrush,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
@@ -87,12 +86,10 @@ const GROUPS: AdminGroup[] = [
|
|||||||
'residential access',
|
'residential access',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
// /admin/invitations merged into /admin/users (Active users +
|
||||||
href: 'invitations',
|
// Invitations tabs) on 2026-05-21. The standalone tile was
|
||||||
label: 'Invitations',
|
// removed; reps still find the invitation flow via the Users
|
||||||
description: 'Send invitations, track pending invites, and resend or revoke them.',
|
// tile's "Invitations" tab.
|
||||||
icon: MailPlus,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: 'roles',
|
href: 'roles',
|
||||||
label: 'Roles & Permissions',
|
label: 'Roles & Permissions',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
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 { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
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 [subjectType, setSubjectType] = useState<(typeof SUBJECT_TYPES)[number]['key']>('interest');
|
||||||
const [subjectId, setSubjectId] = useState('');
|
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[]>([
|
const [signers, setSigners] = useState<SignerRow[]>([
|
||||||
{ signerName: '', signerEmail: '', signerRole: 'client', signingOrder: 1 },
|
{ signerName: '', signerEmail: '', signerRole: 'client', signingOrder: 1 },
|
||||||
]);
|
]);
|
||||||
@@ -154,7 +171,7 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
|||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
[subjectField]: subjectId.trim(),
|
[subjectField]: subjectId.trim(),
|
||||||
signingMode,
|
signingMode,
|
||||||
watchers: [],
|
watchers: watcherUserIds,
|
||||||
autoPlaceFields: true,
|
autoPlaceFields: true,
|
||||||
sendImmediately: false,
|
sendImmediately: false,
|
||||||
remindersDisabled: reminderMode === 'disabled',
|
remindersDisabled: reminderMode === 'disabled',
|
||||||
@@ -427,6 +444,45 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<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">
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Reminders
|
Reminders
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import type { DetailTab } from '@/components/shared/detail-layout';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
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 { NotesList } from '@/components/shared/notes-list';
|
||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
|
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
|
||||||
@@ -1022,18 +1028,41 @@ function OverviewTab({
|
|||||||
skip-ahead when reality calls for it (an override-confirm
|
skip-ahead when reality calls for it (an override-confirm
|
||||||
gates the actual stage move). */}
|
gates the actual stage move). */}
|
||||||
{pastMilestones.length > 0 && (
|
{pastMilestones.length > 0 && (
|
||||||
<div className="rounded-lg border bg-muted/20 px-4 py-2.5">
|
<div className="rounded-lg border bg-muted/20">
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 border-b px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wide">Past</span>
|
<span>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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,17 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
|||||||
href: '/:portSlug/admin/users',
|
href: '/:portSlug/admin/users',
|
||||||
label: 'Users & roles',
|
label: 'Users & roles',
|
||||||
category: 'admin',
|
category: 'admin',
|
||||||
keywords: ['accounts', 'permissions', 'invites', 'team', 'staff', 'roles'],
|
keywords: [
|
||||||
|
'accounts',
|
||||||
|
'permissions',
|
||||||
|
'invites',
|
||||||
|
'invitations',
|
||||||
|
'pending invites',
|
||||||
|
'onboarding',
|
||||||
|
'team',
|
||||||
|
'staff',
|
||||||
|
'roles',
|
||||||
|
],
|
||||||
requires: 'admin.manage_users',
|
requires: 'admin.manage_users',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -395,13 +405,10 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
|||||||
keywords: ['roles', 'permissions', 'access control', 'rbac'],
|
keywords: ['roles', 'permissions', 'access control', 'rbac'],
|
||||||
requires: 'admin.manage_users',
|
requires: 'admin.manage_users',
|
||||||
},
|
},
|
||||||
{
|
// /admin/invitations was merged into /admin/users on 2026-05-21 — the
|
||||||
href: '/:portSlug/admin/invitations',
|
// standalone catalog entry would route to the redirect stub. Reps
|
||||||
label: 'Invitations',
|
// searching for "invite" still land on the right surface via the
|
||||||
category: 'admin',
|
// /admin/users keyword list (extended below).
|
||||||
keywords: ['invite', 'pending invites', 'onboarding'],
|
|
||||||
requires: 'admin.manage_users',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Substitute `:portSlug` placeholder for the current port. */
|
/** Substitute `:portSlug` placeholder for the current port. */
|
||||||
|
|||||||
Reference in New Issue
Block a user