262 lines
9.3 KiB
TypeScript
262 lines
9.3 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;
|
|||
|
|
/** 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: '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(() => {
|
|||
|
|
void load();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|