Files
helix-engage/src/components/setup/wizard-step-ai.tsx
saridsa2 f57fbc1f24 feat(onboarding/phase-6): setup wizard polish, seed script alignment, doctor visit slots
- Setup wizard: 3-pane layout with right-side live previews, resume
  banner, edit/copy icons on team step, AI prompt configuration
- Forms: employee-create replaces invite-member (no email invites),
  clinic form with address/hours/payment, doctor form with visit slots
- Seed script: aligned to current SDK schema — doctors created as
  workspace members (HelixEngage Manager role), visitingHours replaced
  by doctorVisitSlot entity, clinics seeded, portalUserId linked
  dynamically, SUB/ORIGIN/GQL configurable via env vars
- Pages: clinics + doctors CRUD updated for new schema, team settings
  with temp password + role assignment
- New components: time-picker, day-selector, wizard-right-panes,
  wizard-layout-context, resume-setup-banner
- Removed: invite-member-form (replaced by employee-create-form per
  no-email-invites rule)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:34 +05:30

435 lines
21 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPenToSquare, faRotateLeft, faRobot } from '@fortawesome/pro-duotone-svg-icons';
import { WizardStep } from './wizard-step';
import { AiRightPane, type AiActorSummary } from './wizard-right-panes';
import { AiForm, emptyAiFormValues, type AiFormValues, type AiProvider } from '@/components/forms/ai-form';
import { Button } from '@/components/base/buttons/button';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { useAuth } from '@/providers/auth-provider';
import type { WizardStepComponentProps } from './wizard-step-types';
// AI step (post-prompt-config rework). The middle pane has two sections:
//
// 1. Provider / model / temperature picker — same as before, drives the
// provider that all actors use under the hood.
// 2. AI personas — list of 7 actor cards, each with name, description,
// truncated current template, and an Edit button. Edit triggers a
// confirmation modal warning about unintended consequences, then
// opens a slideout with the full template + a "variables you can
// use" reference + Save / Reset.
//
// The right pane shows the same 7 personas as compact "last edited" cards
// so the admin can scan recent activity at a glance.
//
// Backend wiring lives in helix-engage-server/src/config/ai.defaults.ts
// (DEFAULT_AI_PROMPTS) + ai-config.service.ts (renderPrompt / updatePrompt
// / resetPrompt). The 7 service files (widget chat, CC agent helper,
// supervisor, lead enrichment, call insight, call assist, recording
// analysis) all call AiConfigService.renderPrompt(actor, vars) so any
// edit here lands instantly.
type ServerPromptConfig = {
label: string;
description: string;
variables: { key: string; description: string }[];
template: string;
defaultTemplate: string;
lastEditedAt: string | null;
lastEditedBy: string | null;
};
type ServerAiConfig = {
provider?: AiProvider;
model?: string;
temperature?: number;
prompts?: Record<string, ServerPromptConfig>;
};
// Display order for the actor cards. Mirrors AI_ACTOR_KEYS in
// ai.defaults.ts so the wizard renders personas in the same order
// admins see them documented elsewhere.
const ACTOR_ORDER = [
'widgetChat',
'ccAgentHelper',
'supervisorChat',
'leadEnrichment',
'callInsight',
'callAssist',
'recordingAnalysis',
] as const;
const truncate = (s: string, max: number): string =>
s.length > max ? s.slice(0, max).trimEnd() + '…' : s;
export const WizardStepAi = (props: WizardStepComponentProps) => {
const { user } = useAuth();
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
const [prompts, setPrompts] = useState<Record<string, ServerPromptConfig>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Edit flow state — three phases:
// 1. confirmingActor: which actor's Edit button was just clicked
// (drives the confirmation modal)
// 2. editingActor: which actor's slideout is open (only set after
// the user confirms past the warning prompt)
// 3. draftTemplate: the current textarea contents in the slideout
const [confirmingActor, setConfirmingActor] = useState<string | null>(null);
const [editingActor, setEditingActor] = useState<string | null>(null);
const [draftTemplate, setDraftTemplate] = useState('');
const [savingPrompt, setSavingPrompt] = useState(false);
const fetchConfig = useCallback(async () => {
try {
const data = await apiClient.get<ServerAiConfig>('/api/config/ai', { silent: true });
setValues({
provider: data.provider ?? 'openai',
model: data.model ?? 'gpt-4o-mini',
temperature: data.temperature != null ? String(data.temperature) : '0.7',
systemPromptAddendum: '',
});
setPrompts(data.prompts ?? {});
} catch (err) {
console.error('[wizard/ai] fetch failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
const handleSaveProviderConfig = async () => {
if (!values.model.trim()) {
notify.error('Model is required');
return;
}
const temperature = Number(values.temperature);
setSaving(true);
try {
await apiClient.put('/api/config/ai', {
provider: values.provider,
model: values.model.trim(),
temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)),
});
notify.success('AI settings saved', 'Provider and model updated.');
await fetchConfig();
if (!props.isCompleted) {
await props.onComplete('ai');
}
} catch (err) {
console.error('[wizard/ai] save provider failed', err);
} finally {
setSaving(false);
}
};
// Confirmation modal → slideout flow.
const handleEditClick = (actor: string) => {
setConfirmingActor(actor);
};
const handleConfirmEdit = () => {
if (!confirmingActor) return;
const prompt = prompts[confirmingActor];
if (!prompt) return;
setEditingActor(confirmingActor);
setDraftTemplate(prompt.template);
setConfirmingActor(null);
};
const handleSavePrompt = async (close: () => void) => {
if (!editingActor) return;
if (!draftTemplate.trim()) {
notify.error('Prompt cannot be empty');
return;
}
setSavingPrompt(true);
try {
await apiClient.put(`/api/config/ai/prompts/${editingActor}`, {
template: draftTemplate,
editedBy: user?.email ?? null,
});
notify.success('Prompt updated', `${prompts[editingActor]?.label ?? editingActor} saved`);
await fetchConfig();
close();
setEditingActor(null);
} catch (err) {
console.error('[wizard/ai] save prompt failed', err);
} finally {
setSavingPrompt(false);
}
};
const handleResetPrompt = async (close: () => void) => {
if (!editingActor) return;
setSavingPrompt(true);
try {
await apiClient.post(`/api/config/ai/prompts/${editingActor}/reset`);
notify.success('Prompt reset', `${prompts[editingActor]?.label ?? editingActor} restored to default`);
await fetchConfig();
close();
setEditingActor(null);
} catch (err) {
console.error('[wizard/ai] reset prompt failed', err);
} finally {
setSavingPrompt(false);
}
};
// Build the right-pane summary entries from the loaded prompts.
// `isCustom` is true when the template differs from the shipped
// default OR when the audit fields are populated — either way the
// admin has touched it.
const actorSummaries = useMemo<AiActorSummary[]>(() => {
return ACTOR_ORDER.filter((key) => prompts[key]).map((key) => {
const p = prompts[key];
return {
key,
label: p.label,
description: p.description,
lastEditedAt: p.lastEditedAt,
isCustom: p.template !== p.defaultTemplate || p.lastEditedAt !== null,
};
});
}, [prompts]);
const editingPrompt = editingActor ? prompts[editingActor] : null;
const confirmingLabel = confirmingActor ? prompts[confirmingActor]?.label : '';
return (
<WizardStep
step="ai"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSaveProviderConfig}
onFinish={props.onFinish}
saving={saving}
rightPane={<AiRightPane actors={actorSummaries} />}
>
{loading ? (
<p className="text-sm text-tertiary">Loading AI settings</p>
) : (
<div className="flex flex-col gap-8">
<section>
<h3 className="mb-3 text-sm font-semibold text-primary">Provider & model</h3>
<AiForm value={values} onChange={setValues} />
</section>
<section>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-primary">AI personas</h3>
<span className="text-xs text-tertiary">
{actorSummaries.length} configurable prompts
</span>
</div>
<p className="mb-4 text-xs text-tertiary">
Each persona below is a different AI surface in Helix Engage. Editing a
prompt changes how that persona sounds and what rules it follows. Defaults
are tuned for hospital call centers only edit if you have a specific
reason and can test the result.
</p>
<ul className="flex flex-col gap-3">
{ACTOR_ORDER.map((key) => {
const prompt = prompts[key];
if (!prompt) return null;
const isCustom =
prompt.template !== prompt.defaultTemplate ||
prompt.lastEditedAt !== null;
return (
<li
key={key}
className="rounded-xl border border-secondary bg-primary p-4 shadow-xs"
>
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h4 className="truncate text-sm font-semibold text-primary">
{prompt.label}
</h4>
<p className="mt-0.5 text-xs text-tertiary">
{prompt.description}
</p>
</div>
{isCustom && (
<span className="shrink-0 rounded-full bg-warning-secondary px-2 py-0.5 text-xs font-medium text-warning-primary">
Edited
</span>
)}
</div>
<p className="mt-3 line-clamp-3 rounded-lg border border-secondary bg-secondary p-3 font-mono text-xs leading-relaxed text-tertiary">
{truncate(prompt.template, 220)}
</p>
<div className="mt-3 flex justify-end">
<Button
size="sm"
color="secondary"
onClick={() => handleEditClick(key)}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon
icon={faPenToSquare}
className={className}
/>
)}
>
Edit
</Button>
</div>
</div>
</div>
</li>
);
})}
</ul>
</section>
</div>
)}
{/* Confirmation modal — reused from the patient-edit gate. */}
<EditPatientConfirmModal
isOpen={confirmingActor !== null}
onOpenChange={(open) => {
if (!open) setConfirmingActor(null);
}}
onConfirm={handleConfirmEdit}
title={`Edit ${confirmingLabel} prompt?`}
description={
<>
Modifying this prompt can affect call quality, lead summaries, and supervisor
insights in ways that are hard to predict. The defaults are tuned for hospital
call centers only edit if you have a specific reason and can test the
result. You can always reset back to default from the editor.
</>
}
confirmLabel="Yes, edit prompt"
/>
{/* Slideout editor — only opens after the warning is confirmed. */}
<SlideoutMenu
isOpen={editingActor !== null}
onOpenChange={(open) => {
if (!open) setEditingActor(null);
}}
isDismissable
>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex items-center gap-3 pr-8">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon
icon={faRobot}
className="size-5 text-fg-brand-primary"
/>
</div>
<div>
<h2 className="text-lg font-semibold text-primary">
Edit {editingPrompt?.label}
</h2>
<p className="text-sm text-tertiary">
{editingPrompt?.description}
</p>
</div>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
<div className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-secondary">
Prompt template
</label>
<textarea
value={draftTemplate}
onChange={(e) => setDraftTemplate(e.target.value)}
rows={18}
className="mt-1.5 w-full resize-y rounded-lg border border-secondary bg-primary p-3 font-mono text-xs text-primary outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
/>
<p className="mt-1 text-xs text-tertiary">
Variables wrapped in <code>{'{{double-braces}}'}</code> get
substituted at runtime with live data.
</p>
</div>
{editingPrompt?.variables && editingPrompt.variables.length > 0 && (
<div className="rounded-lg border border-secondary bg-secondary p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Variables you can use
</p>
<ul className="mt-2 flex flex-col gap-1.5">
{editingPrompt.variables.map((v) => (
<li key={v.key} className="flex items-start gap-2 text-xs">
<code className="shrink-0 rounded bg-primary px-1.5 py-0.5 font-mono text-brand-primary">
{`{{${v.key}}}`}
</code>
<span className="text-tertiary">
{v.description}
</span>
</li>
))}
</ul>
</div>
)}
{editingPrompt?.lastEditedAt && (
<p className="text-xs text-tertiary">
Last edited{' '}
{new Date(editingPrompt.lastEditedAt).toLocaleString()}
{editingPrompt.lastEditedBy && ` by ${editingPrompt.lastEditedBy}`}
</p>
)}
</div>
</SlideoutMenu.Content>
<SlideoutMenu.Footer>
<div className="flex items-center justify-between gap-3">
<Button
size="md"
color="link-gray"
isDisabled={savingPrompt}
onClick={() => handleResetPrompt(close)}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
>
Reset to default
</Button>
<div className="flex items-center gap-3">
<Button
size="md"
color="secondary"
isDisabled={savingPrompt}
onClick={close}
>
Cancel
</Button>
<Button
size="md"
color="primary"
isLoading={savingPrompt}
showTextWhileLoading
onClick={() => handleSavePrompt(close)}
>
{savingPrompt ? 'Saving…' : 'Save prompt'}
</Button>
</div>
</div>
</SlideoutMenu.Footer>
</>
)}
</SlideoutMenu>
</WizardStep>
);
};