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>
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
'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 don’t 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>
|
||
);
|
||
}
|