Files
pn-new-crm/src/components/admin/onboarding-checklist.tsx
Matt 03a7521729 feat(uat-batch): Groups J + K — activity feed + onboarding resolver-chain
J38, J39, K40 (core) from the 2026-05-21 plan.

Shipped:
  J38  EntityActivityFeed sentence rendering surfaces the new value
       inline. Was "<actor> updated the X"; now "<actor> set X to
       <value>" when the audit row carries `newValue`. Field-level
       diff line underneath keeps showing the old → new strikethrough
       for context. Truncates inline value at 60 chars to keep long
       notes / descriptions from blowing out the row.
  J39  Client → Companies tab CTA. Empty state gains a "Link to a
       company" action; populated state grows a top-right "Link to
       company" button. New <LinkCompanyDialog> wraps the existing
       <CompanyPicker> + a membership-role select + an "is primary"
       checkbox, then POSTs to /api/v1/companies/[id]/members.
       Empty-state copy dropped "Add a membership from a company's
       detail page" — the rep can act inline now.
  K40  OnboardingChecklist resolver-chain. The auto-check no longer
       reads raw `/admin/settings` rows (which miss env fallbacks).
       Resolved endpoint widened to accept `?keys=k1,k2,...` so the
       checklist can batch-resolve any heterogenous set of registry
       keys through port → global → env → default in one round-trip.
       Checklist captures the dominant source per step ("env fallback",
       "global default", "built-in default") and surfaces it inline
       under the green tick so super-admins see when a step is
       relying on env rather than a per-port override. Compound-key
       gates report the weakest sub-key's source so a partially-env
       config still flags clearly.
       Topbar banner / dashboard tile / weekly nudge / celebration
       sub-items remain queued — the core resolver-chain gap was
       the actual cause of the "step never ticks" UAT complaint.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:02:33 +02:00

359 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Check, Circle, Loader2, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { apiFetch } from '@/lib/api/client';
interface OnboardingStep {
id: string;
href: string;
label: string;
description: string;
/** Setting key whose presence proves the step is done. When set, the
* checkmark auto-fills from the settings list. When undefined, the
* step relies on the manual checkbox in `onboarding_status`. */
autoCheckSettingKey?: string;
/** Multi-key gate: all listed setting keys must be present (non-empty)
* for the step to auto-complete. Useful for compound checks where a
* single key would falsely mark "done" — e.g. Documenso needs a URL
* plus signer identity plus a template id, not just the URL. */
autoCheckSettingKeysAll?: readonly string[];
/** Override: read this many users / tags / roles from a list endpoint
* and consider the step done when count > 0. */
autoCheckListEndpoint?: string;
}
const STEPS: OnboardingStep[] = [
{
id: 'branding',
href: 'branding',
label: 'Set port name, logo, primary colour',
description: 'Branding flows into the navbar, emails, EOI PDFs, and the public auth shell.',
autoCheckSettingKey: 'branding_logo_url',
},
{
id: 'email',
href: 'email',
label: 'Configure outgoing email',
description:
'From-address, signature, footer, plus per-port SMTP overrides if you dont use the global account.',
autoCheckSettingKey: 'smtp_host_override',
},
{
id: 'documenso',
href: 'documenso',
label: 'Connect Documenso for EOIs',
description:
'API credentials, the EOI template id, plus the developer + approver identity that signs every EOI.',
// Compound gate: an EOI cannot be sent without ALL of these. A
// port-admin who saves only the URL would otherwise see the step go
// green and discover the gap on first EOI attempt (Documenso 404s
// on a missing template, or sends recipients with empty names).
autoCheckSettingKeysAll: [
'documenso_api_url_override',
'documenso_developer_email',
'documenso_approver_email',
'documenso_eoi_template_id',
],
},
{
id: 'settings',
href: 'settings',
label: 'Tune business rules + recommender weights',
description:
'Pipeline weights, net-10 discount, berth recommender knobs (heat weights, fall-through policy).',
// Recommender defaults are layered (port > global > built-in), so a
// port that uses the built-ins never writes a row. Use a tuned
// heat-weight as the "admin actually saw + chose" sentinel instead.
autoCheckSettingKey: 'heat_weight_recency',
},
{
id: 'roles',
href: 'roles',
label: 'Create roles & assign users',
description: 'Per-port roles inherit from system roles; override permissions here.',
autoCheckListEndpoint: '/api/v1/admin/roles',
},
{
id: 'users',
href: 'users',
label: 'Invite the rest of the team',
description:
'Invite users, assign roles, optionally grant residential access. Track pending vs accepted.',
autoCheckListEndpoint: '/api/v1/admin/users',
},
{
id: 'tags',
href: 'tags',
label: 'Define starter tags',
description: 'Color-coded labels used across clients, yachts, companies, and interests.',
autoCheckListEndpoint: '/api/v1/tags/options',
},
{
id: 'storage',
href: 'storage',
label: 'Configure storage backend',
description:
'Verify S3/filesystem and run a test connection before going live so PDFs and avatars persist correctly.',
autoCheckSettingKey: 'storage_backend',
},
{
id: 'forms',
href: 'forms',
label: 'Wire the website intake forms',
description:
'Inquiry forms on the marketing site dual-write into the CRM via /api/public/website-inquiries. Manually mark complete when verified.',
},
];
interface ResolvedValue {
isSet: boolean;
source?: 'port' | 'global' | 'env' | 'default' | 'none';
value?: unknown;
}
interface ResolvedResp {
data: { entries: Array<{ key: string }>; values: Record<string, ResolvedValue> };
}
interface SettingRow {
key: string;
value: unknown;
portId: string | null;
}
interface SettingsResp {
data: { portSettings: SettingRow[]; globalSettings: SettingRow[] };
}
export function OnboardingChecklist() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [autoChecks, setAutoChecks] = useState<Record<string, boolean>>({});
// Per-step source flags — populated for steps whose auto-check resolved
// via the env / default fallback rather than a port / global override.
// Surfaces "Resolving from env" copy so super admins see what's
// backing each green tick without digging into the settings page.
const [autoSources, setAutoSources] = useState<Record<string, ResolvedValue['source']>>({});
const [manualChecks, setManualChecks] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
useEffect(() => {
async function load() {
setLoading(true);
try {
// Collect every setting key referenced by the checklist so we can
// batch-resolve them through the full chain in one round-trip.
// The `resolved` endpoint reads port→global→env→default, so a
// port using env-only credentials still auto-ticks (the old
// raw `/admin/settings` query missed env fallback entirely).
const keys = new Set<string>();
for (const s of STEPS) {
if (s.autoCheckSettingKey) keys.add(s.autoCheckSettingKey);
if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k);
}
// Manual-checkbox state still lives in the raw system_settings
// row (it's a JSON blob, not a per-key registry entry) — keep
// fetching it the old way.
const [resolved, settings] = await Promise.all([
keys.size > 0
? apiFetch<ResolvedResp>(
`/api/v1/admin/settings/resolved?keys=${Array.from(keys)
.map(encodeURIComponent)
.join(',')}`,
)
: Promise.resolve({ data: { entries: [], values: {} } } as ResolvedResp),
apiFetch<SettingsResp>('/api/v1/admin/settings'),
]);
const values = resolved.data.values;
const isPresent = (key: string): boolean => Boolean(values[key]?.isSet);
const checks: Record<string, boolean> = {};
const listChecks = await Promise.all(
STEPS.map(async (s) => {
if (s.autoCheckSettingKey) {
return [s.id, isPresent(s.autoCheckSettingKey)] as const;
}
if (s.autoCheckSettingKeysAll) {
return [s.id, s.autoCheckSettingKeysAll.every((k) => isPresent(k))] as const;
}
if (s.autoCheckListEndpoint) {
try {
const res = await apiFetch<{ data: unknown[] }>(s.autoCheckListEndpoint);
return [s.id, Array.isArray(res.data) && res.data.length > 0] as const;
} catch {
return [s.id, false] as const;
}
}
return [s.id, false] as const;
}),
);
for (const [id, done] of listChecks) checks[id] = done;
setAutoChecks(checks);
// Capture the dominant source per step. For single-key checks this
// is the key's source; for multi-key checks we report the
// "weakest" source so the rep sees env if any sub-key is env.
const sourcesByStep: Record<string, ResolvedValue['source']> = {};
const PRIORITY: Record<string, number> = {
port: 4,
global: 3,
env: 2,
default: 1,
none: 0,
};
for (const s of STEPS) {
if (s.autoCheckSettingKey) {
sourcesByStep[s.id] = values[s.autoCheckSettingKey]?.source;
} else if (s.autoCheckSettingKeysAll) {
const sources = s.autoCheckSettingKeysAll
.map((k) => values[k]?.source)
.filter((x): x is NonNullable<ResolvedValue['source']> => Boolean(x));
// Pick the lowest-priority (weakest) source so the rep sees
// "env" if the compound has any env-only sub-key.
sources.sort((a, b) => (PRIORITY[a] ?? 0) - (PRIORITY[b] ?? 0));
sourcesByStep[s.id] = sources[0];
}
}
setAutoSources(sourcesByStep);
// Pull the manual-checkbox state from system_settings.
const allSettings = [...settings.data.portSettings, ...settings.data.globalSettings];
const byKey = new Map(allSettings.map((r) => [r.key, r.value]));
const manual = (byKey.get('onboarding_manual_status') ?? {}) as Record<string, boolean>;
setManualChecks(manual);
} finally {
setLoading(false);
}
}
void load();
}, []);
async function toggleManual(id: string) {
const next = { ...manualChecks, [id]: !manualChecks[id] };
setManualChecks(next);
setSaving(id);
try {
await apiFetch('/api/v1/admin/settings', {
method: 'PUT',
body: { key: 'onboarding_manual_status', value: next },
});
} finally {
setSaving(null);
}
}
const stepDone = (id: string) => Boolean(autoChecks[id]) || Boolean(manualChecks[id]);
const completed = STEPS.filter((s) => stepDone(s.id)).length;
const percent = Math.round((completed / STEPS.length) * 100);
return (
<div className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Setup checklist</CardTitle>
<CardDescription>
{completed} of {STEPS.length} complete. Auto-checked steps update when you save the
underlying setting; manual ones (like website-form integration) need the checkbox.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<Progress value={percent} className="h-2" />
<ol className="space-y-3">
{STEPS.map((step, idx) => {
const auto = Boolean(autoChecks[step.id]);
const manual = Boolean(manualChecks[step.id]);
const done = auto || manual;
return (
<li
key={step.id}
className={
done
? 'flex items-start gap-3 rounded-md border border-emerald-200 bg-emerald-50/50 p-3'
: 'flex items-start gap-3 rounded-md border p-3'
}
>
<span
className={
done
? 'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white'
: 'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground'
}
>
{done ? (
<Check className="h-4 w-4" aria-hidden />
) : loading ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<Circle className="h-4 w-4" aria-hidden />
)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<Link
href={`/${portSlug}/admin/${step.href}` as never}
className="text-sm font-medium hover:underline inline-flex items-center gap-1"
>
{idx + 1}. {step.label}
<ExternalLink className="h-3 w-3 opacity-50" aria-hidden />
</Link>
<p className="mt-0.5 text-xs text-muted-foreground">{step.description}</p>
{auto && (
<p className="mt-1 text-[11px] text-emerald-700">
Auto-detected complete via{' '}
<code className="text-[10px]">
{step.autoCheckSettingKey ??
step.autoCheckSettingKeysAll?.join(' + ') ??
step.autoCheckListEndpoint}
</code>
{autoSources[step.id] && autoSources[step.id] !== 'port' ? (
<span className="ml-1 text-[10px] text-amber-700">
· resolving from{' '}
<strong className="font-medium">
{autoSources[step.id] === 'env'
? 'env fallback'
: autoSources[step.id] === 'global'
? 'global default'
: autoSources[step.id] === 'default'
? 'built-in default'
: autoSources[step.id]}
</strong>
</span>
) : null}
</p>
)}
</div>
{!auto && (
<Button
size="sm"
variant={manual ? 'secondary' : 'outline'}
disabled={saving === step.id}
onClick={() => toggleManual(step.id)}
>
{saving === step.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : manual ? (
'Mark incomplete'
) : (
'Mark done'
)}
</Button>
)}
</div>
</div>
</li>
);
})}
</ol>
</CardContent>
</Card>
</div>
);
}