mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
- 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>
435 lines
21 KiB
TypeScript
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>
|
|
);
|
|
};
|