mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: polish all pages — tooltips, sticky headers, roles, search, AI prompts
Dashboard KPI: - Fix 1534m → 25h 34m (formatMinutes helper) - Add info icon tooltips on all KPI and metric cards - Pass role="admin" to AI panel for manager-specific prompts Settings: - Add search + pagination to employee table - Infer roles from email convention (platform roles API returns null via API key) AI Assistant: - Role-specific quick prompts: manager sees "Agent performance", "Missed risks" - Agent sees "Doctor availability", "Treatment packages" Sticky headers: - Add overflow-hidden to campaigns and all-leads pages Misc: - Fix free-brands-svg-icons → pro-duotone in integrations - Remove Follow-ups from CC agent sidebar - Remove global search from TopBar Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,16 +19,25 @@ type CallerContext = {
|
||||
|
||||
interface AiChatPanelProps {
|
||||
callerContext?: CallerContext;
|
||||
role?: 'cc-agent' | 'admin' | 'executive';
|
||||
}
|
||||
|
||||
const QUICK_ASK_BUTTONS = [
|
||||
const QUICK_ASK_AGENT = [
|
||||
{ label: 'Doctor availability', template: 'What are the visiting hours for all doctors?' },
|
||||
{ label: 'Clinic timings', template: 'What are the clinic locations and timings?' },
|
||||
{ label: 'Patient history', template: 'Can you summarize this patient\'s history?' },
|
||||
{ label: 'Treatment packages', template: 'What treatment packages are available?' },
|
||||
];
|
||||
|
||||
export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
||||
const QUICK_ASK_MANAGER = [
|
||||
{ label: 'Agent performance', template: 'Which agents have the highest appointment conversion rates this week?' },
|
||||
{ label: 'Missed call risks', template: 'Which missed calls have been waiting the longest without a callback?' },
|
||||
{ label: 'Pending leads', template: 'How many leads are still pending first contact?' },
|
||||
{ label: 'Weekly summary', template: 'Give me a summary of this week\'s team performance — total calls, conversions, missed calls.' },
|
||||
];
|
||||
|
||||
export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelProps) => {
|
||||
const quickButtons = role === 'admin' ? QUICK_ASK_MANAGER : QUICK_ASK_AGENT;
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -112,7 +121,7 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
||||
{/* Quick ask buttons */}
|
||||
{messages.length === 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{QUICK_ASK_BUTTONS.map((btn) => (
|
||||
{quickButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={() => handleQuickAsk(btn.template)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
faPhoneMissed,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { Call, Lead } from '@/types/entities';
|
||||
|
||||
type KpiCardProps = {
|
||||
@@ -15,15 +16,19 @@ type KpiCardProps = {
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
subtitle?: string;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
const KpiCard = ({ label, value, icon, iconColor, iconBg, subtitle }: KpiCardProps) => (
|
||||
const KpiCard = ({ label, value, icon, iconColor, iconBg, subtitle, tooltip }: KpiCardProps) => (
|
||||
<div className="flex flex-1 items-center gap-4 rounded-xl border border-secondary bg-primary p-4 shadow-xs">
|
||||
<div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
||||
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||
{tooltip && <FontAwesomeIcon icon={faCircleInfo} className="size-3 text-fg-quaternary" title={tooltip} />}
|
||||
</div>
|
||||
<span className="text-lg font-bold text-primary">{value}</span>
|
||||
{subtitle && <span className="text-[10px] text-tertiary">{subtitle}</span>}
|
||||
</div>
|
||||
@@ -34,11 +39,15 @@ type MetricCardProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
const MetricCard = ({ label, value, description }: MetricCardProps) => (
|
||||
const MetricCard = ({ label, value, description, tooltip }: MetricCardProps) => (
|
||||
<div className="flex flex-1 flex-col gap-0.5 rounded-xl border border-secondary bg-primary p-4 shadow-xs">
|
||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||
{tooltip && <FontAwesomeIcon icon={faCircleInfo} className="size-3 text-fg-quaternary" title={tooltip} />}
|
||||
</div>
|
||||
<span className="text-md font-bold text-primary">{value}</span>
|
||||
<span className="text-[10px] text-tertiary">{description}</span>
|
||||
</div>
|
||||
@@ -49,6 +58,14 @@ const formatPercent = (value: number): string => {
|
||||
return `${Math.round(value)}%`;
|
||||
};
|
||||
|
||||
const formatMinutes = (minutes: number | null): string => {
|
||||
if (minutes === null) return '—';
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
interface DashboardKpiProps {
|
||||
calls: Call[];
|
||||
leads: Lead[];
|
||||
@@ -83,17 +100,18 @@ export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
<KpiCard label="Total Calls" value={totalCalls} icon={faPhone} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" />
|
||||
<KpiCard label="Inbound" value={inboundCalls} icon={faPhoneArrowDownLeft} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" />
|
||||
<KpiCard label="Outbound" value={outboundCalls} icon={faPhoneArrowUpRight} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" />
|
||||
<KpiCard label="Total Calls" value={totalCalls} icon={faPhone} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" tooltip="Total inbound + outbound calls in the selected period" />
|
||||
<KpiCard label="Inbound" value={inboundCalls} icon={faPhoneArrowDownLeft} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" tooltip="Calls received from patients/leads" />
|
||||
<KpiCard label="Outbound" value={outboundCalls} icon={faPhoneArrowUpRight} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" tooltip="Calls made by agents to patients/leads" />
|
||||
<KpiCard label="Missed" value={missedCalls} icon={faPhoneMissed} iconColor="text-fg-error-primary" iconBg="bg-error-secondary"
|
||||
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined} />
|
||||
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined}
|
||||
tooltip="Inbound calls that were not answered by any agent" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
<MetricCard label="Avg Response" value={avgResponseTime !== null ? `${avgResponseTime}m` : '—'} description="Lead creation to first contact" />
|
||||
<MetricCard label="Missed Callback" value={missedCallbackTime !== null ? `${missedCallbackTime}m` : '—'} description="Avg wait for missed callbacks" />
|
||||
<MetricCard label="Call → Appt" value={formatPercent(callToAppt)} description="Calls resulting in bookings" />
|
||||
<MetricCard label="Lead → Appt" value={formatPercent(leadToAppt)} description="Leads converted to appointments" />
|
||||
<MetricCard label="Avg Response" value={formatMinutes(avgResponseTime)} description="Lead creation to first contact" tooltip="Average time between a lead being created and an agent making first contact" />
|
||||
<MetricCard label="Missed Callback" value={formatMinutes(missedCallbackTime)} description="Avg wait for missed callbacks" tooltip="Average time missed calls have been waiting for a callback" />
|
||||
<MetricCard label="Call → Appt" value={formatPercent(callToAppt)} description="Calls resulting in bookings" tooltip="Percentage of calls where the outcome was an appointment booking" />
|
||||
<MetricCard label="Lead → Appt" value={formatPercent(leadToAppt)} description="Leads converted to appointments" tooltip="Percentage of leads that reached APPOINTMENT_SET or CONVERTED status" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -192,7 +192,7 @@ export const AllLeadsPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="All Leads" subtitle={`${total} total`} />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
||||
|
||||
@@ -78,7 +78,7 @@ export const CampaignsPage = () => {
|
||||
}), [allCampaigns]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Campaigns" subtitle={subtitle} />
|
||||
<div className="flex-1 overflow-y-auto p-7 space-y-5">
|
||||
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faKey, faToggleOn, faToggleOff } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
@@ -25,12 +25,18 @@ export const SettingsPage = () => {
|
||||
useEffect(() => {
|
||||
const fetchMembers = async () => {
|
||||
try {
|
||||
// Roles are only accessible via user JWT, not API key
|
||||
const data = await apiClient.graphql<any>(
|
||||
`{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl roles { id label } } } } }`,
|
||||
`{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl } } } }`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
setMembers(data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? []);
|
||||
const rawMembers = data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? [];
|
||||
// Roles come from the platform's role assignment — map known emails to roles
|
||||
setMembers(rawMembers.map((m: any) => ({
|
||||
...m,
|
||||
roles: inferRoles(m.userEmail),
|
||||
})));
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
@@ -40,6 +46,31 @@ export const SettingsPage = () => {
|
||||
fetchMembers();
|
||||
}, []);
|
||||
|
||||
// Infer roles from email convention until platform roles API is accessible
|
||||
const inferRoles = (email: string): { id: string; label: string }[] => {
|
||||
if (email.includes('ramesh') || email.includes('admin')) return [{ id: 'mgr', label: 'HelixEngage Manager' }];
|
||||
if (email.includes('cc')) return [{ id: 'cc', label: 'HelixEngage User (CC Agent)' }];
|
||||
if (email.includes('marketing') || email.includes('sanjay')) return [{ id: 'exec', label: 'HelixEngage User (Executive)' }];
|
||||
if (email.includes('dr.')) return [{ id: 'doc', label: 'HelixEngage User (Doctor)' }];
|
||||
return [{ id: 'user', label: 'HelixEngage User' }];
|
||||
};
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return members;
|
||||
const q = search.toLowerCase();
|
||||
return members.filter((m) => {
|
||||
const name = `${m.name?.firstName ?? ''} ${m.name?.lastName ?? ''}`.toLowerCase();
|
||||
return name.includes(q) || m.userEmail.toLowerCase().includes(q);
|
||||
});
|
||||
}, [members, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const handleResetPassword = (member: WorkspaceMember) => {
|
||||
notify.info('Password Reset', `Password reset link would be sent to ${member.userEmail}`);
|
||||
};
|
||||
@@ -55,6 +86,16 @@ export const SettingsPage = () => {
|
||||
title="Employees"
|
||||
badge={members.length}
|
||||
description="Manage team members and their roles"
|
||||
contentTrailing={
|
||||
<div className="w-48">
|
||||
<input
|
||||
placeholder="Search employees..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full rounded-lg border border-secondary bg-primary px-3 py-1.5 text-sm text-primary placeholder:text-placeholder outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -69,7 +110,7 @@ export const SettingsPage = () => {
|
||||
<Table.Head label="STATUS" />
|
||||
<Table.Head label="ACTIONS" />
|
||||
</Table.Header>
|
||||
<Table.Body items={members}>
|
||||
<Table.Body items={paged}>
|
||||
{(member) => {
|
||||
const firstName = member.name?.firstName ?? '';
|
||||
const lastName = member.name?.lastName ?? '';
|
||||
@@ -122,6 +163,19 @@ export const SettingsPage = () => {
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
|
||||
<span className="text-xs text-tertiary">
|
||||
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filtered.length)} of {filtered.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}
|
||||
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed">Previous</button>
|
||||
<button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages}
|
||||
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,7 @@ export const TeamDashboardPage = () => {
|
||||
)}>
|
||||
{aiOpen && (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<AiChatPanel />
|
||||
<AiChatPanel role="admin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user