mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: build Outreach page with template list, WhatsApp preview, and message history
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
83
src/components/outreach/message-list.tsx
Normal file
83
src/components/outreach/message-list.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import type { PatientMessage, MessageStatus } from '@/types/entities';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: PatientMessage[];
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
MessageStatus,
|
||||
{ label: string; color: 'blue' | 'success' | 'gray' | 'error' }
|
||||
> = {
|
||||
READ: { label: 'Read', color: 'blue' },
|
||||
DELIVERED: { label: 'Delivered', color: 'success' },
|
||||
SENT: { label: 'Sent', color: 'gray' },
|
||||
FAILED: { label: 'Failed', color: 'error' },
|
||||
};
|
||||
|
||||
const getInitials = (name: string | null | undefined): string => {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(' ');
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
};
|
||||
|
||||
const formatTime = (sentAt: string | null): string => {
|
||||
if (!sentAt) return '';
|
||||
const date = new Date(sentAt);
|
||||
return date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
};
|
||||
|
||||
export const MessageList = ({ messages }: MessageListProps) => {
|
||||
return (
|
||||
<div className="bg-primary rounded-2xl border border-secondary overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-secondary">
|
||||
<p className="text-md font-bold text-primary">Recent Messages</p>
|
||||
<button type="button" className="text-sm text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear cursor-pointer">
|
||||
View All →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message rows */}
|
||||
<div className="divide-y divide-secondary">
|
||||
{messages.slice(0, 5).map((message) => {
|
||||
const recipientName = message.recipientName ?? message.senderName ?? 'Unknown';
|
||||
const status = message.status ?? 'SENT';
|
||||
const statusInfo = statusConfig[status];
|
||||
|
||||
return (
|
||||
<div key={message.id} className="flex items-center gap-3 px-5 py-3.5 hover:bg-secondary transition duration-100 ease-linear">
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
initials={getInitials(recipientName)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Name + preview */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-primary">{recipientName}</p>
|
||||
<p className="text-xs text-tertiary overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{message.subject ?? message.body ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Time + status */}
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<span className="text-xs text-quaternary">{formatTime(message.sentAt)}</span>
|
||||
<Badge type="pill-color" color={statusInfo.color} size="sm">
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{messages.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-tertiary">No messages yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
135
src/components/outreach/template-list.tsx
Normal file
135
src/components/outreach/template-list.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { SearchLg } from '@untitledui/icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { WhatsAppTemplate } from '@/types/entities';
|
||||
|
||||
interface TemplateListProps {
|
||||
templates: WhatsAppTemplate[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
type TabId = 'all' | 'campaign' | 'custom';
|
||||
|
||||
export const TemplateList = ({ templates, selectedId, onSelect }: TemplateListProps) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<TabId>('all');
|
||||
|
||||
const filtered = templates.filter((t) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
t.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
t.body?.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
const matchesTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'campaign' && t.linkedCampaignId !== null) ||
|
||||
(activeTab === 'custom' && t.linkedCampaignId === null);
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all' as TabId, label: `All (${templates.length})` },
|
||||
{ id: 'campaign' as TabId, label: 'By Campaign' },
|
||||
{ id: 'custom' as TabId, label: 'Custom' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-96 border-r border-secondary bg-primary flex flex-col h-full flex-shrink-0">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-secondary space-y-3">
|
||||
<p className="text-sm font-bold text-primary">Message Templates</p>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder="Search templates..."
|
||||
icon={SearchLg}
|
||||
value={search}
|
||||
onChange={(val) => setSearch(val)}
|
||||
aria-label="Search templates"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary px-5">
|
||||
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(key as TabId)}>
|
||||
<TabList type="underline" size="sm" items={tabs}>
|
||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
|
||||
</TabList>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} id={tab.id} />
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-tertiary">No templates found.</p>
|
||||
)}
|
||||
{filtered.map((template) => {
|
||||
const isSelected = template.id === selectedId;
|
||||
const isPending = template.approvalStatus === 'PENDING';
|
||||
const deliveredRate =
|
||||
template.sendCount && template.sendCount > 0
|
||||
? Math.round(((template.deliveredCount ?? 0) / template.sendCount) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(template.id)}
|
||||
className={cx(
|
||||
'w-full text-left p-3.5 rounded-xl border-2 transition duration-100 ease-linear cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-brand-primary border-brand'
|
||||
: 'border-transparent hover:bg-secondary',
|
||||
isPending && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className={cx('text-sm font-semibold truncate', isSelected ? 'text-white' : 'text-primary')}>
|
||||
{template.name}
|
||||
</span>
|
||||
{template.linkedCampaignId ? (
|
||||
<Badge type="pill-color" color="brand" size="sm">
|
||||
Campaign
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge type="pill-color" color="gray" size="sm">
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview text */}
|
||||
<p className={cx('text-xs line-clamp-2 mb-1.5', isSelected ? 'text-white/80' : 'text-tertiary')}>
|
||||
{template.body}
|
||||
</p>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className={cx('flex gap-3 text-xs', isSelected ? 'text-white/70' : 'text-quaternary')}>
|
||||
{template.approvalStatus === 'APPROVED' ? (
|
||||
<span className={isSelected ? 'text-white/90' : 'text-success-primary'}>✓ Approved</span>
|
||||
) : (
|
||||
<span className={isSelected ? 'text-white/90' : 'text-warning-primary'}>⏳ Pending</span>
|
||||
)}
|
||||
<span>
|
||||
Sent <strong>{template.sendCount ?? 0}</strong> times
|
||||
</span>
|
||||
<span>
|
||||
<strong>{deliveredRate}%</strong> delivered
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
138
src/components/outreach/template-preview.tsx
Normal file
138
src/components/outreach/template-preview.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { WhatsAppMockup } from './whatsapp-mockup';
|
||||
import type { WhatsAppTemplate } from '@/types/entities';
|
||||
|
||||
interface TemplatePreviewProps {
|
||||
template: WhatsAppTemplate;
|
||||
}
|
||||
|
||||
const variableDescriptions: Record<string, string> = {
|
||||
patient_name: "Lead's contact name",
|
||||
hospital_name: 'Clinic or hospital name',
|
||||
campaign_name: 'Linked campaign name',
|
||||
booking_link: 'Appointment booking URL',
|
||||
};
|
||||
|
||||
export const TemplatePreview = ({ template }: TemplatePreviewProps) => {
|
||||
const totalSent = template.sendCount ?? 0;
|
||||
const delivered = template.deliveredCount ?? 0;
|
||||
const read = template.readCount ?? 0;
|
||||
const clicked = template.clickedCount ?? 0;
|
||||
const failed = template.failedCount ?? 0;
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Sent', value: totalSent },
|
||||
{ label: 'Delivered', value: delivered },
|
||||
{ label: 'Read', value: read },
|
||||
{ label: 'Clicked', value: clicked },
|
||||
{ label: 'Failed', value: failed },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-primary rounded-2xl border border-secondary overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-secondary flex items-center justify-between">
|
||||
<p className="text-md font-bold text-primary">
|
||||
Template Preview — {template.name}
|
||||
</p>
|
||||
<Button color="secondary" size="sm">
|
||||
Edit Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex">
|
||||
{/* Left: phone mockup */}
|
||||
<div className="bg-secondary p-5 flex items-start justify-center flex-shrink-0">
|
||||
<WhatsAppMockup body={template.body ?? ''} variables={template.variables} />
|
||||
</div>
|
||||
|
||||
{/* Right: details */}
|
||||
<div className="flex-1 p-5 space-y-5">
|
||||
{/* Variables */}
|
||||
{template.variables.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Variables</p>
|
||||
<div className="space-y-1.5">
|
||||
{template.variables.map((variable) => (
|
||||
<div key={variable} className="flex items-center gap-2 text-sm">
|
||||
<code className="bg-secondary px-1.5 py-0.5 rounded text-xs text-brand-secondary font-mono">
|
||||
{`{{${variable}}}`}
|
||||
</code>
|
||||
<span className="text-quaternary">→</span>
|
||||
<span className="text-tertiary">
|
||||
{variableDescriptions[variable] ?? variable}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Campaign */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Linked Campaign</p>
|
||||
{template.linkedCampaignName ? (
|
||||
<Badge type="pill-color" color="brand" size="md">
|
||||
{template.linkedCampaignName}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge type="pill-color" color="gray" size="md">
|
||||
None (Custom)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approval Status */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Approval Status</p>
|
||||
{template.approvalStatus === 'APPROVED' ? (
|
||||
<Badge type="pill-color" color="success" size="md">
|
||||
✓ Approved
|
||||
</Badge>
|
||||
) : template.approvalStatus === 'PENDING' ? (
|
||||
<Badge type="pill-color" color="warning" size="md">
|
||||
⏳ Pending Review
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge type="pill-color" color="error" size="md">
|
||||
Rejected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Performance */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Performance</p>
|
||||
<div className="flex gap-3">
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className="flex-1 bg-secondary rounded-xl p-3 text-center min-w-0"
|
||||
>
|
||||
<p className="text-lg font-bold text-primary">{metric.value}</p>
|
||||
<p className="text-xs text-quaternary mt-0.5">{metric.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Languages */}
|
||||
{template.language.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">Language</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{template.language.map((lang) => (
|
||||
<Badge key={lang} type="color" color="gray" size="sm">
|
||||
{lang.toUpperCase()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
src/components/outreach/whatsapp-mockup.tsx
Normal file
51
src/components/outreach/whatsapp-mockup.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
interface WhatsAppMockupProps {
|
||||
body: string;
|
||||
variables: string[];
|
||||
}
|
||||
|
||||
const highlightVariables = (text: string): React.ReactNode => {
|
||||
const parts = text.split(/({{[^}]+}})/g);
|
||||
return parts.map((part, index) => {
|
||||
if (/^{{[^}]+}}$/.test(part)) {
|
||||
return (
|
||||
<span key={index} className="bg-[#c6e8b0] px-1 rounded font-semibold text-[#1a6b00]">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
export const WhatsAppMockup = ({ body }: WhatsAppMockupProps) => {
|
||||
const lines = body.split('\n');
|
||||
// Extract CTA line — last non-empty line that looks like a URL or "Book here"
|
||||
const ctaLine = lines.find((l) => l.includes('{{booking_link}}') || l.toLowerCase().includes('book '));
|
||||
|
||||
return (
|
||||
<div className="w-[260px] rounded-2xl overflow-hidden shadow-lg">
|
||||
{/* WA Header */}
|
||||
<div className="bg-[#075e54] px-3.5 py-2.5 flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-[#128c7e] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-xs font-bold">R</span>
|
||||
</div>
|
||||
<span className="text-white text-xs font-semibold">Ramaiah Memorial</span>
|
||||
</div>
|
||||
|
||||
{/* WA Body */}
|
||||
<div className="bg-[#e5ddd5] p-3.5 min-h-[200px]">
|
||||
<div className="bg-[#dcf8c6] rounded-lg rounded-br-none p-3 max-w-[220px] ml-auto text-xs text-[#303030] leading-relaxed shadow-xs">
|
||||
<p className="whitespace-pre-wrap">{highlightVariables(body)}</p>
|
||||
{ctaLine && (
|
||||
<span className="block mt-2 p-2 bg-white rounded-md text-center text-[#075e54] font-semibold text-xs cursor-pointer">
|
||||
Book Now →
|
||||
</span>
|
||||
)}
|
||||
<p className="text-[10px] text-right text-[#667781] mt-1">2:30 PM ✓✓</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,148 @@
|
||||
import { TopBar } from "@/components/layout/top-bar";
|
||||
import { useState } from 'react';
|
||||
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { TemplateList } from '@/components/outreach/template-list';
|
||||
import { TemplatePreview } from '@/components/outreach/template-preview';
|
||||
import { MessageList } from '@/components/outreach/message-list';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { PatientMessage } from '@/types/entities';
|
||||
|
||||
type OutreachKpiCard = {
|
||||
label: string;
|
||||
value: number;
|
||||
delta: string;
|
||||
deltaColor: string;
|
||||
};
|
||||
|
||||
const recentMessages: PatientMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
createdAt: '2026-03-16T08:30:00Z',
|
||||
subject: "Women's Day Health Checkup",
|
||||
body: 'Namaste Priya, Happy Women\'s Day from Ramaiah Memorial! Book your free checkup here.',
|
||||
direction: 'CLINIC_TO_PATIENT',
|
||||
channel: 'WHATSAPP',
|
||||
priority: 'NORMAL',
|
||||
sentAt: '2026-03-16T08:30:00Z',
|
||||
readAt: '2026-03-16T09:15:00Z',
|
||||
senderName: 'Care Team',
|
||||
patientId: 'p-001',
|
||||
status: 'READ',
|
||||
recipientName: 'Priya Sharma',
|
||||
recipientPhone: '+91 98765 43210',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
createdAt: '2026-03-16T09:10:00Z',
|
||||
subject: 'IVF Free Consultation',
|
||||
body: 'Namaste Anitha, We would like to invite you for a FREE first consultation with our IVF specialist.',
|
||||
direction: 'CLINIC_TO_PATIENT',
|
||||
channel: 'WHATSAPP',
|
||||
priority: 'NORMAL',
|
||||
sentAt: '2026-03-16T09:10:00Z',
|
||||
readAt: null,
|
||||
senderName: 'Care Team',
|
||||
patientId: 'p-002',
|
||||
status: 'DELIVERED',
|
||||
recipientName: 'Anitha Reddy',
|
||||
recipientPhone: '+91 87654 32109',
|
||||
},
|
||||
{
|
||||
id: 'msg-3',
|
||||
createdAt: '2026-03-16T10:00:00Z',
|
||||
subject: 'Cervical Screening Reminder',
|
||||
body: 'Namaste Kavitha, This is a gentle reminder for your cervical cancer screening appointment.',
|
||||
direction: 'CLINIC_TO_PATIENT',
|
||||
channel: 'WHATSAPP',
|
||||
priority: 'HIGH',
|
||||
sentAt: '2026-03-16T10:00:00Z',
|
||||
readAt: null,
|
||||
senderName: 'Care Team',
|
||||
patientId: 'p-003',
|
||||
status: 'SENT',
|
||||
recipientName: 'Kavitha Nair',
|
||||
recipientPhone: '+91 76543 21098',
|
||||
},
|
||||
{
|
||||
id: 'msg-4',
|
||||
createdAt: '2026-03-16T10:45:00Z',
|
||||
subject: 'General Follow-up',
|
||||
body: 'Namaste Meena, This is Ramaiah Memorial reaching out to follow up on your recent enquiry.',
|
||||
direction: 'CLINIC_TO_PATIENT',
|
||||
channel: 'WHATSAPP',
|
||||
priority: 'NORMAL',
|
||||
sentAt: '2026-03-16T10:45:00Z',
|
||||
readAt: '2026-03-16T11:02:00Z',
|
||||
senderName: 'Care Team',
|
||||
patientId: 'p-004',
|
||||
status: 'READ',
|
||||
recipientName: 'Meena Iyer',
|
||||
recipientPhone: '+91 65432 10987',
|
||||
},
|
||||
{
|
||||
id: 'msg-5',
|
||||
createdAt: '2026-03-16T11:30:00Z',
|
||||
subject: 'Senior Health Package',
|
||||
body: 'Namaste Lakshmi, Our Senior Health Package is designed specifically for individuals above 60 years.',
|
||||
direction: 'CLINIC_TO_PATIENT',
|
||||
channel: 'WHATSAPP',
|
||||
priority: 'NORMAL',
|
||||
sentAt: '2026-03-16T11:30:00Z',
|
||||
readAt: null,
|
||||
senderName: 'Care Team',
|
||||
patientId: 'p-005',
|
||||
status: 'FAILED',
|
||||
recipientName: 'Lakshmi Devi',
|
||||
recipientPhone: '+91 54321 09876',
|
||||
},
|
||||
];
|
||||
|
||||
const kpiCards: OutreachKpiCard[] = [
|
||||
{ label: 'Messages Sent (24h)', value: 87, delta: '+14% vs yesterday', deltaColor: 'text-success-primary' },
|
||||
{ label: 'Delivered', value: 78, delta: '90% delivery rate', deltaColor: 'text-brand-secondary' },
|
||||
{ label: 'Read', value: 52, delta: '67% read rate', deltaColor: 'text-brand-secondary' },
|
||||
{ label: 'CTA Clicked', value: 18, delta: '+5 vs yesterday', deltaColor: 'text-success-primary' },
|
||||
];
|
||||
|
||||
export const OutreachPage = () => {
|
||||
const { templates } = useData();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(templates[0]?.id ?? null);
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="Outreach" />
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<p className="text-tertiary">Outreach — coming soon</p>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Outreach" subtitle="WhatsApp templates and message history" />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left: Template List */}
|
||||
<TemplateList templates={templates} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
|
||||
{/* Right: Preview + KPIs + Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-7 space-y-5">
|
||||
{/* Outreach KPIs */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{kpiCards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="bg-primary rounded-2xl border border-secondary p-4 transition hover:shadow-md"
|
||||
>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-quaternary">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="mt-1 text-display-sm font-bold text-primary">{card.value}</p>
|
||||
<p className={cx('mt-1 text-xs', card.deltaColor)}>{card.delta}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template Preview */}
|
||||
{selectedTemplate && <TemplatePreview template={selectedTemplate} />}
|
||||
|
||||
{/* Recent Messages */}
|
||||
<MessageList messages={recentMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user