mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +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 = () => {
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Outreach" />
|
<TopBar title="Outreach" subtitle="WhatsApp templates and message history" />
|
||||||
<div className="flex flex-1 items-center justify-center p-8">
|
|
||||||
<p className="text-tertiary">Outreach — coming soon</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user