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:
2026-03-19 16:23:10 +05:30
parent c776782af6
commit bb004744f4
6 changed files with 104 additions and 23 deletions

View File

@@ -19,16 +19,25 @@ type CallerContext = {
interface AiChatPanelProps { interface AiChatPanelProps {
callerContext?: CallerContext; 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: 'Doctor availability', template: 'What are the visiting hours for all doctors?' },
{ label: 'Clinic timings', template: 'What are the clinic locations and timings?' }, { label: 'Clinic timings', template: 'What are the clinic locations and timings?' },
{ label: 'Patient history', template: 'Can you summarize this patient\'s history?' }, { label: 'Patient history', template: 'Can you summarize this patient\'s history?' },
{ label: 'Treatment packages', template: 'What treatment packages are available?' }, { 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 [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -112,7 +121,7 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
{/* Quick ask buttons */} {/* Quick ask buttons */}
{messages.length === 0 && ( {messages.length === 0 && (
<div className="mb-3 flex flex-wrap gap-1.5"> <div className="mb-3 flex flex-wrap gap-1.5">
{QUICK_ASK_BUTTONS.map((btn) => ( {quickButtons.map((btn) => (
<button <button
key={btn.label} key={btn.label}
onClick={() => handleQuickAsk(btn.template)} onClick={() => handleQuickAsk(btn.template)}

View File

@@ -6,6 +6,7 @@ import {
faPhoneMissed, faPhoneMissed,
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import type { Call, Lead } from '@/types/entities'; import type { Call, Lead } from '@/types/entities';
type KpiCardProps = { type KpiCardProps = {
@@ -15,15 +16,19 @@ type KpiCardProps = {
iconColor: string; iconColor: string;
iconBg: string; iconBg: string;
subtitle?: 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 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}`}> <div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} /> <FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col flex-1">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-tertiary">{label}</span> <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> <span className="text-lg font-bold text-primary">{value}</span>
{subtitle && <span className="text-[10px] text-tertiary">{subtitle}</span>} {subtitle && <span className="text-[10px] text-tertiary">{subtitle}</span>}
</div> </div>
@@ -34,11 +39,15 @@ type MetricCardProps = {
label: string; label: string;
value: string; value: string;
description: 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"> <div className="flex flex-1 flex-col gap-0.5 rounded-xl border border-secondary bg-primary p-4 shadow-xs">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-tertiary">{label}</span> <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-md font-bold text-primary">{value}</span>
<span className="text-[10px] text-tertiary">{description}</span> <span className="text-[10px] text-tertiary">{description}</span>
</div> </div>
@@ -49,6 +58,14 @@ const formatPercent = (value: number): string => {
return `${Math.round(value)}%`; 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 { interface DashboardKpiProps {
calls: Call[]; calls: Call[];
leads: Lead[]; leads: Lead[];
@@ -83,17 +100,18 @@ export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-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="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" /> <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" /> <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" <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>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4"> <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="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={missedCallbackTime !== null ? `${missedCallbackTime}m` : '—'} description="Avg wait for missed callbacks" /> <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" /> <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" /> <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>
</div> </div>
); );

View File

@@ -192,7 +192,7 @@ export const AllLeadsPage = () => {
}; };
return ( return (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="All Leads" subtitle={`${total} total`} /> <TopBar title="All Leads" subtitle={`${total} total`} />
<div className="flex flex-1 flex-col overflow-y-auto p-7"> <div className="flex flex-1 flex-col overflow-y-auto p-7">

View File

@@ -78,7 +78,7 @@ export const CampaignsPage = () => {
}), [allCampaigns]); }), [allCampaigns]);
return ( return (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Campaigns" subtitle={subtitle} /> <TopBar title="Campaigns" subtitle={subtitle} />
<div className="flex-1 overflow-y-auto p-7 space-y-5"> <div className="flex-1 overflow-y-auto p-7 space-y-5">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}> <Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faToggleOn, faToggleOff } from '@fortawesome/pro-duotone-svg-icons'; import { faKey, faToggleOn, faToggleOff } from '@fortawesome/pro-duotone-svg-icons';
import { Avatar } from '@/components/base/avatar/avatar'; import { Avatar } from '@/components/base/avatar/avatar';
@@ -25,12 +25,18 @@ export const SettingsPage = () => {
useEffect(() => { useEffect(() => {
const fetchMembers = async () => { const fetchMembers = async () => {
try { try {
// Roles are only accessible via user JWT, not API key
const data = await apiClient.graphql<any>( 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, undefined,
{ silent: true }, { 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 { } catch {
// silently fail // silently fail
} finally { } finally {
@@ -40,6 +46,31 @@ export const SettingsPage = () => {
fetchMembers(); 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) => { const handleResetPassword = (member: WorkspaceMember) => {
notify.info('Password Reset', `Password reset link would be sent to ${member.userEmail}`); notify.info('Password Reset', `Password reset link would be sent to ${member.userEmail}`);
}; };
@@ -55,6 +86,16 @@ export const SettingsPage = () => {
title="Employees" title="Employees"
badge={members.length} badge={members.length}
description="Manage team members and their roles" 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 ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -69,7 +110,7 @@ export const SettingsPage = () => {
<Table.Head label="STATUS" /> <Table.Head label="STATUS" />
<Table.Head label="ACTIONS" /> <Table.Head label="ACTIONS" />
</Table.Header> </Table.Header>
<Table.Body items={members}> <Table.Body items={paged}>
{(member) => { {(member) => {
const firstName = member.name?.firstName ?? ''; const firstName = member.name?.firstName ?? '';
const lastName = member.name?.lastName ?? ''; const lastName = member.name?.lastName ?? '';
@@ -122,6 +163,19 @@ export const SettingsPage = () => {
}} }}
</Table.Body> </Table.Body>
</Table> </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> </TableCard.Root>
</div> </div>

View File

@@ -153,7 +153,7 @@ export const TeamDashboardPage = () => {
)}> )}>
{aiOpen && ( {aiOpen && (
<div className="flex h-full flex-col p-4"> <div className="flex h-full flex-col p-4">
<AiChatPanel /> <AiChatPanel role="admin" />
</div> </div>
)} )}
</div> </div>