mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
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>
This commit is contained in:
@@ -1,43 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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;
|
||||
systemPromptAddendum?: string;
|
||||
prompts?: Record<string, ServerPromptConfig>;
|
||||
};
|
||||
|
||||
// AI step — loads the current AI config, lets the admin pick provider and
|
||||
// model, and saves. This is the last step, so on save we fire the finish
|
||||
// flow instead of advancing.
|
||||
// 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);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<ServerAiConfig>('/api/config/ai', { silent: true })
|
||||
.then((data) => {
|
||||
setValues({
|
||||
provider: data.provider ?? 'openai',
|
||||
model: data.model ?? 'gpt-4o-mini',
|
||||
temperature: data.temperature != null ? String(data.temperature) : '0.7',
|
||||
systemPromptAddendum: data.systemPromptAddendum ?? '',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// non-fatal — defaults will do
|
||||
})
|
||||
.finally(() => setLoading(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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleSaveProviderConfig = async () => {
|
||||
if (!values.model.trim()) {
|
||||
notify.error('Model is required');
|
||||
return;
|
||||
@@ -49,20 +116,92 @@ export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
provider: values.provider,
|
||||
model: values.model.trim(),
|
||||
temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)),
|
||||
systemPromptAddendum: values.systemPromptAddendum,
|
||||
});
|
||||
notify.success('AI settings saved', 'Your assistant is ready.');
|
||||
await props.onComplete('ai');
|
||||
// Don't auto-advance — this is the last step, the WizardStep
|
||||
// shell already renders a "Finish setup" button the admin taps
|
||||
// themselves.
|
||||
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 failed', 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"
|
||||
@@ -70,15 +209,226 @@ export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onMarkComplete={handleSaveProviderConfig}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<AiRightPane actors={actorSummaries} />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading AI settings…</p>
|
||||
) : (
|
||||
<AiForm value={values} onChange={setValues} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user