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 {
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)}

View File

@@ -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>
);

View File

@@ -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">

View File

@@ -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))}>

View File

@@ -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>

View File

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