Files
pn-new-crm/src/components/admin/onboarding-checklist.tsx
Matt 4329db7fc3 fix(compiler): React Compiler safety triage — 5 categories cleared
Cleared 4 rule buckets (37 violations, including 5 real bugs) and
silenced 1 informational bucket from the Next 16 / react-hooks v7
upgrade. Cleared rules promoted from `warn` back to `error` so new
regressions block CI.

Real bug fixes:
- `interest-contact-log-tab.tsx`: `useMemo` used for side effects
  (5 setState calls inside a memo body); converted to `useEffect`.
- `PieChart.tsx`: cumulative `let angle` mutation in a render-phase
  `map`; converted to `reduce` so the slice array is built without
  re-assignment.
- `documents-hub.tsx`: `useMemo(() => ({ count: 0 }))` used as a
  mutable drag counter; converted to `useRef`.
- `notes-list.tsx`: `Date.now()` read during render for note-edit
  countdown (impure) → pinned to a `now` state ticked every 30s.
- `onboarding-checklist.tsx` / `user-profile.tsx` /
  `user-settings.tsx`: `useEffect(() => void load(), [])` with the
  `load` function declared AFTER the effect — relied on hoisting,
  trips Compiler's "access before declared" rule. Declared inside
  the effect.

Pattern fixes (intentional cache-via-ref → state or layout-effect):
- 6 `ref.current = x` writes during render moved into layout
  effects (`use-realtime-invalidation`, `settings-form-card`,
  `inbox`).
- 3 `ref.current` reads during render (search totals cache,
  scanner file ref) rewritten to backed-by-state.
- `use-is-mobile.ts` rewritten on `useSyncExternalStore` to avoid
  the SSR-then-rehydrate setState dance.
- `use-notifications.ts` rewritten to write socket pushes directly
  into the React Query cache via `setQueryData`, removing a local
  state mirror.

Rule config (`eslint.config.mjs`):
- `react-hooks/purity` → error (was warn, cleared)
- `react-hooks/set-state-in-render` → error (was warn, cleared)
- `react-hooks/immutability` → error (was warn, cleared)
- `react-hooks/refs` → error (was warn, cleared)
- `react-hooks/incompatible-library` → off (informational only)
- `react-hooks/set-state-in-effect` → warn (51 remaining, all the
  useEffect→fetch→setState data-fetch pattern; migration to
  useQuery tracked in BACKLOG)

Verified: tsc clean, eslint 0 errors / 69 warnings (down from 105),
vitest 1315/1315, next build green.

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

261 lines
9.4 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;
/** 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: 'sales_email_smtp_host',
},
{
id: 'documenso',
href: 'documenso',
label: 'Connect Documenso for EOIs',
description:
'API credentials and the EOI template id, plus the in-app vs Documenso pathway choice.',
autoCheckSettingKey: 'documenso_api_url',
},
{
id: 'settings',
href: 'settings',
label: 'Tune business rules + recommender weights',
description:
'Pipeline weights, net-10 discount, berth recommender knobs (heat weights, fall-through policy).',
autoCheckSettingKey: 'recommender_top_n_default',
},
{
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: '../',
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 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>>({});
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 {
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 checks: Record<string, boolean> = {};
const listChecks = await Promise.all(
STEPS.map(async (s) => {
if (s.autoCheckSettingKey) {
const v = byKey.get(s.autoCheckSettingKey);
return [s.id, v !== undefined && v !== null && v !== '' && v !== false] 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);
// Pull the manual-checkbox state from system_settings.
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" />
) : loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Circle className="h-4 w-4" />
)}
</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" />
</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.autoCheckListEndpoint}
</code>
</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" />
) : manual ? (
'Mark incomplete'
) : (
'Mark done'
)}
</Button>
)}
</div>
</div>
</li>
);
})}
</ol>
</CardContent>
</Card>
</div>
);
}