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>
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
/**
|
|
* Static catalog of navigation destinations the global search bar can jump
|
|
* to: settings pages, admin panels, and top-level dashboards.
|
|
*
|
|
* Each entry has an `href` template (run through `resolveHref` with the
|
|
* current portSlug), a human label, and a list of keyword aliases. The
|
|
* search service substring-matches the query against the label + every
|
|
* keyword, so `smtp` lands on the email settings page even though the
|
|
* label reads "Email accounts".
|
|
*
|
|
* Why hardcoded vs introspecting routes? The catalog is curated — only
|
|
* pages worth jumping to from a global search appear here, and each
|
|
* entry has hand-picked keyword synonyms that route inference can't
|
|
* derive. Adding a route to the catalog is cheap; misfiring routes are
|
|
* expensive.
|
|
*/
|
|
|
|
import type { RolePermissions } from '@/lib/db/schema/users';
|
|
|
|
export type NavCatalogCategory = 'settings' | 'admin' | 'dashboard';
|
|
|
|
export interface NavCatalogEntry {
|
|
/** Path template — `:portSlug` is substituted at lookup time. */
|
|
href: string;
|
|
label: string;
|
|
category: NavCatalogCategory;
|
|
/** Lowercase aliases — query is matched against label + these. */
|
|
keywords: string[];
|
|
/**
|
|
* Permission gate; only shown to users whose `RolePermissions` resolves
|
|
* truthy at the given dot-path (e.g. `'admin.manage_users'`). Super
|
|
* admins bypass the gate.
|
|
*/
|
|
requires?: string;
|
|
/** When set, only super admins see the entry. */
|
|
superAdminOnly?: boolean;
|
|
}
|
|
|
|
export const NAV_CATALOG: NavCatalogEntry[] = [
|
|
// ─── Dashboards ─────────────────────────────────────────────────────────
|
|
{
|
|
href: '/:portSlug/dashboard',
|
|
label: 'Dashboard',
|
|
category: 'dashboard',
|
|
keywords: ['home', 'overview', 'kpis', 'metrics'],
|
|
},
|
|
{
|
|
href: '/:portSlug/website-analytics',
|
|
label: 'Website analytics',
|
|
category: 'dashboard',
|
|
keywords: ['umami', 'traffic', 'visitors', 'pageviews', 'marketing'],
|
|
},
|
|
|
|
// ─── Settings ───────────────────────────────────────────────────────────
|
|
{
|
|
href: '/:portSlug/settings',
|
|
label: 'Settings',
|
|
category: 'settings',
|
|
keywords: ['preferences', 'configuration', 'config'],
|
|
},
|
|
// The granular settings cards below redirect to the `/admin/<x>` routes
|
|
// that actually exist — the catalog previously listed `/settings/<x>`
|
|
// paths that have never had route folders. We keep the keyword aliases
|
|
// so the cmd-K search still finds them under the right destination.
|
|
{
|
|
href: '/:portSlug/admin/email',
|
|
label: 'Email accounts (SMTP / IMAP)',
|
|
category: 'settings',
|
|
keywords: [
|
|
'smtp',
|
|
'imap',
|
|
'mail',
|
|
'mail server',
|
|
'email credentials',
|
|
'send-from',
|
|
'inbox',
|
|
'bounces',
|
|
],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/branding',
|
|
label: 'Branding (per-port logo, colors, copy)',
|
|
category: 'settings',
|
|
keywords: ['logo', 'theme', 'colors', 'tenant brand', 'white-label'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/templates',
|
|
label: 'Document templates',
|
|
category: 'settings',
|
|
keywords: [
|
|
'eoi',
|
|
'documenso',
|
|
'pdf templates',
|
|
'email templates',
|
|
'template merge fields',
|
|
'merge fields',
|
|
'eoi template',
|
|
],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/storage',
|
|
label: 'File storage backend',
|
|
category: 'settings',
|
|
keywords: ['s3', 'minio', 'filesystem', 'storage'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/settings',
|
|
label: 'Berth recommender weights',
|
|
category: 'settings',
|
|
keywords: ['ranking', 'tier ladder', 'heat', 'fallthrough', 'recommend'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/tags',
|
|
label: 'Tags',
|
|
category: 'settings',
|
|
keywords: ['labels', 'categories', 'classification'],
|
|
},
|
|
{
|
|
href: '/:portSlug/settings',
|
|
label: 'Notification preferences',
|
|
category: 'settings',
|
|
keywords: ['alerts', 'email digest', 'in-app', 'push', 'reminders digest'],
|
|
},
|
|
{
|
|
href: '/:portSlug/settings',
|
|
label: 'My profile & preferences',
|
|
category: 'settings',
|
|
keywords: [
|
|
'profile',
|
|
'avatar',
|
|
'display name',
|
|
'full name',
|
|
'phone',
|
|
'timezone',
|
|
'locale',
|
|
'country',
|
|
'dark mode',
|
|
'theme',
|
|
'password',
|
|
'change email',
|
|
'security',
|
|
'account',
|
|
'me',
|
|
],
|
|
},
|
|
{
|
|
href: '/:portSlug/dashboard?customize=1',
|
|
label: 'Customize dashboard widgets',
|
|
category: 'settings',
|
|
keywords: ['widgets', 'tiles', 'dashboard layout', 'kpi', 'reorder widgets'],
|
|
},
|
|
|
|
// ─── Admin ──────────────────────────────────────────────────────────────
|
|
{
|
|
href: '/:portSlug/admin',
|
|
label: 'Administration',
|
|
category: 'admin',
|
|
keywords: ['admin'],
|
|
requires: 'admin.manage_users',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/users',
|
|
label: 'Users & roles',
|
|
category: 'admin',
|
|
keywords: [
|
|
'accounts',
|
|
'permissions',
|
|
'invites',
|
|
'invitations',
|
|
'pending invites',
|
|
'onboarding',
|
|
'team',
|
|
'staff',
|
|
'roles',
|
|
],
|
|
requires: 'admin.manage_users',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/audit',
|
|
label: 'Audit log',
|
|
category: 'admin',
|
|
keywords: ['activity', 'history', 'events', 'who did what', 'compliance'],
|
|
requires: 'admin.view_audit_log',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/inquiries',
|
|
label: 'Website inquiries inbox',
|
|
category: 'admin',
|
|
keywords: ['enquiries', 'leads', 'contact form', 'eoi requests', 'website'],
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/errors',
|
|
label: 'Platform errors',
|
|
category: 'admin',
|
|
keywords: ['errors', 'exceptions', 'incidents', 'failures'],
|
|
superAdminOnly: true,
|
|
},
|
|
|
|
// ─── Admin → granular section cards (the AdminSectionsBrowser groups) ────
|
|
// These deep-link to specific admin sub-pages. Each one's `keywords`
|
|
// mirrors the corresponding entry in src/components/admin/
|
|
// admin-sections-browser.tsx — so typing a setting key in the topbar
|
|
// global search finds the same card the in-admin search would.
|
|
{
|
|
href: '/:portSlug/admin/settings',
|
|
label: 'System Settings',
|
|
category: 'admin',
|
|
keywords: [
|
|
'client portal',
|
|
'client portal enabled',
|
|
'ai interest scoring',
|
|
'ai email drafts',
|
|
'invoice net10 discount',
|
|
'net-10',
|
|
'pipeline weights',
|
|
'pipeline stage weights',
|
|
'forecast',
|
|
'berth rules',
|
|
'berth status rules',
|
|
'inquiry contact email',
|
|
'inquiry notification recipients',
|
|
'residential notification recipients',
|
|
'eoi signers',
|
|
'developer',
|
|
'approver',
|
|
'countersign',
|
|
'recommender max oversize',
|
|
'recommender top n',
|
|
'recommender default count',
|
|
'fallthrough policy',
|
|
'fallthrough cooldown',
|
|
'heat weight recency',
|
|
'heat weight furthest stage',
|
|
'heat weight interest count',
|
|
'heat weight eoi count',
|
|
'tier ladder',
|
|
'hide late stage',
|
|
'documents show expired tab',
|
|
'expired tab',
|
|
'berths default currency',
|
|
'default currency',
|
|
],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/branding',
|
|
label: 'Branding',
|
|
category: 'admin',
|
|
keywords: ['logo', 'app name', 'theme', 'colors', 'email header', 'white-label'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/email',
|
|
label: 'Email settings',
|
|
category: 'admin',
|
|
keywords: ['smtp', 'imap', 'mail', 'from address', 'signature', 'mail server'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/documenso',
|
|
label: 'EOI signing service',
|
|
category: 'admin',
|
|
keywords: ['documenso', 'signing', 'eoi', 'api credentials', 'webhook secret'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/reminders',
|
|
label: 'Reminder settings',
|
|
category: 'admin',
|
|
keywords: ['reminders', 'daily digest', 'delivery window'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/webhooks',
|
|
label: 'Webhooks',
|
|
category: 'admin',
|
|
keywords: ['webhook', 'outgoing', 'callback', 'delivery log'],
|
|
requires: 'admin.manage_webhooks',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/forms',
|
|
label: 'Forms',
|
|
category: 'admin',
|
|
keywords: ['form templates', 'inquiry', 'intake', 'public form'],
|
|
requires: 'admin.manage_forms',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/email-templates',
|
|
label: 'Email templates',
|
|
category: 'admin',
|
|
keywords: ['transactional emails', 'subject lines', 'portal email', 'inquiry email'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/tags',
|
|
label: 'Tags',
|
|
category: 'admin',
|
|
keywords: ['labels', 'color-coded', 'classification'],
|
|
requires: 'admin.manage_tags',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/vocabularies',
|
|
label: 'Vocabularies',
|
|
category: 'admin',
|
|
keywords: [
|
|
'pick lists',
|
|
'interest temperatures',
|
|
'status reasons',
|
|
'tenure types',
|
|
'expense categories',
|
|
'document types',
|
|
],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/custom-fields',
|
|
label: 'Custom fields',
|
|
category: 'admin',
|
|
keywords: ['custom fields', 'tenant fields', 'extra fields'],
|
|
requires: 'admin.manage_custom_fields',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/duplicates',
|
|
label: 'Duplicates queue',
|
|
category: 'admin',
|
|
keywords: ['dedup', 'duplicate clients', 'merge', 'review queue'],
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/import',
|
|
label: 'Bulk import',
|
|
category: 'admin',
|
|
keywords: ['csv', 'import', 'bulk upload'],
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/sends',
|
|
label: 'Send log',
|
|
category: 'admin',
|
|
keywords: ['email sends', 'brochures', 'send failures', 'retries'],
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/monitoring',
|
|
label: 'Queue monitoring',
|
|
category: 'admin',
|
|
keywords: ['bullmq', 'queue', 'jobs', 'throughput', 'retries'],
|
|
requires: 'admin.system_backup',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/backup',
|
|
label: 'Backup & restore',
|
|
category: 'admin',
|
|
keywords: ['backup', 'restore', 'retention', 'disaster recovery'],
|
|
requires: 'admin.system_backup',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/storage',
|
|
label: 'Storage backend',
|
|
category: 'admin',
|
|
keywords: ['s3', 'minio', 'filesystem', 'storage backend', 'object store'],
|
|
requires: 'admin.system_backup',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/ports',
|
|
label: 'Ports',
|
|
category: 'admin',
|
|
keywords: ['marinas', 'tenancy', 'port management', 'multi-port'],
|
|
superAdminOnly: true,
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/ai',
|
|
label: 'AI configuration',
|
|
category: 'admin',
|
|
keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/ocr',
|
|
label: 'Receipt OCR',
|
|
category: 'admin',
|
|
keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/website-analytics',
|
|
label: 'Website analytics (Umami)',
|
|
category: 'admin',
|
|
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/residential-stages',
|
|
label: 'Residential pipeline stages',
|
|
category: 'admin',
|
|
keywords: ['residential stages', 'pipeline', 'residential funnel'],
|
|
requires: 'admin.manage_settings',
|
|
},
|
|
{
|
|
href: '/:portSlug/admin/roles',
|
|
label: 'Roles & permissions',
|
|
category: 'admin',
|
|
keywords: ['roles', 'permissions', 'access control', 'rbac'],
|
|
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. */
|
|
export function resolveHref(href: string, portSlug: string): string {
|
|
return href.replace(':portSlug', portSlug);
|
|
}
|
|
|
|
/**
|
|
* Returns nav catalog entries matching the query, filtered by what the
|
|
* current user is allowed to see. Match is a substring check against
|
|
* label + each keyword; ranking favors label hits over keyword hits and
|
|
* prefix hits over mid-string hits.
|
|
*
|
|
* Pure / sync — runs in-process. The catalog is ~15 entries today, so
|
|
* the linear scan is irrelevant cost-wise.
|
|
*/
|
|
export function searchNavCatalog(
|
|
query: string,
|
|
opts: { isSuperAdmin: boolean; permissions: RolePermissions | null; limit?: number },
|
|
): Array<NavCatalogEntry & { score: number }> {
|
|
const q = query.trim().toLowerCase();
|
|
if (q.length === 0) return [];
|
|
|
|
const limit = opts.limit ?? 5;
|
|
const byHref = new Map<string, NavCatalogEntry & { score: number }>();
|
|
|
|
for (const entry of NAV_CATALOG) {
|
|
if (entry.superAdminOnly && !opts.isSuperAdmin) continue;
|
|
if (entry.requires && !opts.isSuperAdmin && !hasPermission(opts.permissions, entry.requires)) {
|
|
continue;
|
|
}
|
|
|
|
const score = scoreEntry(q, entry);
|
|
if (score === 0) continue;
|
|
|
|
// Some hrefs intentionally appear in multiple catalog categories
|
|
// (e.g. /admin/templates lives under both 'settings' and 'admin').
|
|
// Keep the highest-scoring variant so the dropdown never renders
|
|
// two rows with the same `id` (href) — React would otherwise warn
|
|
// about duplicate keys.
|
|
const existing = byHref.get(entry.href);
|
|
if (!existing || score > existing.score) {
|
|
byHref.set(entry.href, { ...entry, score });
|
|
}
|
|
}
|
|
|
|
const out = Array.from(byHref.values());
|
|
out.sort((a, b) => b.score - a.score);
|
|
return out.slice(0, limit);
|
|
}
|
|
|
|
function scoreEntry(q: string, entry: NavCatalogEntry): number {
|
|
const label = entry.label.toLowerCase();
|
|
|
|
// Strongest signals first.
|
|
if (label === q) return 100;
|
|
if (label.startsWith(q)) return 80;
|
|
if (label.includes(q)) return 60;
|
|
|
|
// Keyword hits — strongest if the keyword exactly equals the query
|
|
// (e.g. user types "smtp"), then prefix, then substring.
|
|
for (const kw of entry.keywords) {
|
|
const k = kw.toLowerCase();
|
|
if (k === q) return 50;
|
|
if (k.startsWith(q)) return 35;
|
|
if (k.includes(q)) return 20;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function hasPermission(perms: RolePermissions | null, dotPath: string): boolean {
|
|
if (!perms) return false;
|
|
const parts = dotPath.split('.');
|
|
let cur: unknown = perms;
|
|
for (const part of parts) {
|
|
if (typeof cur !== 'object' || cur === null) return false;
|
|
cur = (cur as Record<string, unknown>)[part];
|
|
}
|
|
return cur === true;
|
|
}
|