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>
This commit is contained in:
2026-05-21 23:02:33 +02:00
parent 989cc4d72b
commit 03a7521729
4 changed files with 293 additions and 29 deletions

View File

@@ -112,6 +112,14 @@ const STEPS: OnboardingStep[] = [
},
];
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;
@@ -125,6 +133,11 @@ 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);
@@ -133,23 +146,40 @@ export function OnboardingChecklist() {
async function load() {
setLoading(true);
try {
const settings = await apiFetch<SettingsResp>('/api/v1/admin/settings');
const all = [...settings.data.portSettings, ...settings.data.globalSettings];
const byKey = new Map(all.map((r) => [r.key, r.value]));
const isPresent = (v: unknown) => v !== undefined && v !== null && v !== '' && v !== false;
// 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(byKey.get(s.autoCheckSettingKey))] as const;
return [s.id, isPresent(s.autoCheckSettingKey)] as const;
}
if (s.autoCheckSettingKeysAll) {
return [
s.id,
s.autoCheckSettingKeysAll.every((k) => isPresent(byKey.get(k))),
] as const;
return [s.id, s.autoCheckSettingKeysAll.every((k) => isPresent(k))] as const;
}
if (s.autoCheckListEndpoint) {
try {
@@ -165,7 +195,35 @@ export function OnboardingChecklist() {
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 {
@@ -254,6 +312,20 @@ export function OnboardingChecklist() {
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>