mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: add Assign, WhatsApp Send, Mark Spam, Merge modals and Lead Activity slideout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
150
src/components/leads/lead-activity-slideout.tsx
Normal file
150
src/components/leads/lead-activity-slideout.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import type { Lead, LeadActivity, LeadActivityType } from '@/types/entities';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type LeadActivitySlideoutProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
lead: Lead;
|
||||
activities: LeadActivity[];
|
||||
};
|
||||
|
||||
type ActivityConfig = {
|
||||
icon: string;
|
||||
dotClass: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const ACTIVITY_CONFIG: Record<LeadActivityType, ActivityConfig> = {
|
||||
STATUS_CHANGE: { icon: '🔄', dotClass: 'bg-brand-secondary', label: 'Status Changed' },
|
||||
CALL_MADE: { icon: '📞', dotClass: 'bg-brand-secondary', label: 'Call Made' },
|
||||
CALL_RECEIVED: { icon: '📲', dotClass: 'bg-brand-secondary', label: 'Call Received' },
|
||||
WHATSAPP_SENT: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Sent' },
|
||||
WHATSAPP_RECEIVED: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Received' },
|
||||
SMS_SENT: { icon: '✉️', dotClass: 'bg-brand-secondary', label: 'SMS Sent' },
|
||||
EMAIL_SENT: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Sent' },
|
||||
EMAIL_RECEIVED: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Received' },
|
||||
NOTE_ADDED: { icon: '📝', dotClass: 'bg-warning-solid', label: 'Note Added' },
|
||||
ASSIGNED: { icon: '📤', dotClass: 'bg-brand-secondary', label: 'Assigned' },
|
||||
APPOINTMENT_BOOKED: { icon: '📅', dotClass: 'bg-brand-secondary', label: 'Appointment Booked' },
|
||||
FOLLOW_UP_CREATED: { icon: '🔁', dotClass: 'bg-brand-secondary', label: 'Follow-up Created' },
|
||||
CONVERTED: { icon: '✅', dotClass: 'bg-success-solid', label: 'Converted' },
|
||||
MARKED_SPAM: { icon: '🚫', dotClass: 'bg-error-solid', label: 'Marked as Spam' },
|
||||
DUPLICATE_DETECTED: { icon: '🔍', dotClass: 'bg-warning-solid', label: 'Duplicate Detected' },
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary', label: 'Activity' };
|
||||
|
||||
const StatusChangeContent = ({ previousValue, newValue }: { previousValue: string | null; newValue: string | null }) => (
|
||||
<span className="text-sm text-secondary">
|
||||
{previousValue && (
|
||||
<span className="mr-1 text-sm line-through text-quaternary">{previousValue}</span>
|
||||
)}
|
||||
{previousValue && newValue && '→ '}
|
||||
{newValue && <span className="font-medium text-brand-secondary">{newValue}</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: boolean }) => {
|
||||
const type = activity.activityType;
|
||||
const config = type ? (ACTIVITY_CONFIG[type] ?? DEFAULT_CONFIG) : DEFAULT_CONFIG;
|
||||
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : '';
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-3 pb-4">
|
||||
{/* Vertical connecting line */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[15px] top-[36px] bottom-0 w-0.5 bg-tertiary" />
|
||||
)}
|
||||
|
||||
{/* Dot */}
|
||||
<div
|
||||
className={cx(
|
||||
'relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm',
|
||||
config.dotClass,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col gap-0.5 pt-1 min-w-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-1 text-sm font-semibold text-primary">
|
||||
{activity.summary ?? config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{type === 'STATUS_CHANGE' && (activity.previousValue || activity.newValue) && (
|
||||
<StatusChangeContent previousValue={activity.previousValue} newValue={activity.newValue} />
|
||||
)}
|
||||
|
||||
{type !== 'STATUS_CHANGE' && activity.newValue && (
|
||||
<p className="text-xs text-tertiary">{activity.newValue}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-quaternary">
|
||||
{occurredAt}
|
||||
{activity.performedBy ? ` · by ${activity.performedBy}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }: LeadActivitySlideoutProps) => {
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null;
|
||||
const email = lead.contactEmail?.[0]?.address ?? null;
|
||||
|
||||
const filteredActivities = activities
|
||||
.filter((a) => a.leadId === lead.id)
|
||||
.sort((a, b) => {
|
||||
if (!a.occurredAt) return 1;
|
||||
if (!b.occurredAt) return -1;
|
||||
return new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime();
|
||||
});
|
||||
|
||||
return (
|
||||
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
<div className="flex flex-col gap-0.5 pr-8">
|
||||
<h2 className="text-lg font-semibold text-primary">Lead Activity — {fullName}</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
{[phone, email, lead.leadSource, lead.utmCampaign]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
{filteredActivities.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<span className="text-3xl">📭</span>
|
||||
<p className="text-sm font-medium text-secondary">No activity yet</p>
|
||||
<p className="text-xs text-tertiary">Activity will appear here as interactions occur.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filteredActivities.map((activity, idx) => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
isLast={idx === filteredActivities.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SlideoutMenu.Content>
|
||||
</>
|
||||
)}
|
||||
</SlideoutMenu>
|
||||
);
|
||||
};
|
||||
152
src/components/modals/assign-modal.tsx
Normal file
152
src/components/modals/assign-modal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
import { getInitials } from '@/lib/format';
|
||||
import type { Agent, Lead } from '@/types/entities';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type AssignModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedLeads: Lead[];
|
||||
agents: Agent[];
|
||||
onAssign: (agentId: string) => void;
|
||||
};
|
||||
|
||||
const agentColors = ['bg-brand-secondary', 'bg-success-solid', 'bg-warning-solid', 'bg-error-solid'];
|
||||
|
||||
export const AssignModal = ({ isOpen, onOpenChange, selectedLeads, agents, onAssign }: AssignModalProps) => {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [removedLeadIds, setRemovedLeadIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const visibleLeads = selectedLeads.filter((l) => !removedLeadIds.has(l.id));
|
||||
const selectedAgent = agents.find((a) => a.id === selectedAgentId);
|
||||
|
||||
const handleRemoveLead = (leadId: string) => {
|
||||
setRemovedLeadIds((prev) => new Set([...prev, leadId]));
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedAgentId) {
|
||||
onAssign(selectedAgentId);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getLeadName = (lead: Lead) => {
|
||||
if (lead.contactName) {
|
||||
return `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim();
|
||||
}
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||
<Modal className="sm:max-w-lg">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex w-full flex-col gap-0 rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b border-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Assign to Call Center</h2>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
Assign {visibleLeads.length} selected {visibleLeads.length === 1 ? 'lead' : 'leads'} to a call center agent for outbound calling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col gap-4 px-6 py-4 overflow-y-auto max-h-[60vh]">
|
||||
{/* Recipient chips */}
|
||||
{visibleLeads.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleLeads.map((lead) => (
|
||||
<span
|
||||
key={lead.id}
|
||||
className="flex items-center gap-1.5 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs text-brand-secondary"
|
||||
>
|
||||
{getLeadName(lead)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveLead(lead.id)}
|
||||
className="ml-0.5 flex items-center justify-center rounded-full text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||
aria-label={`Remove ${getLeadName(lead)}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-secondary">Select agent</p>
|
||||
{agents.map((agent, idx) => {
|
||||
const isSelected = selectedAgentId === agent.id;
|
||||
const colorClass = agentColors[idx % agentColors.length];
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
className={cx(
|
||||
'flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 p-3 text-left transition duration-100 ease-linear',
|
||||
isSelected
|
||||
? 'border-brand bg-brand-primary'
|
||||
: 'border-secondary hover:border-brand/50',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white',
|
||||
colorClass,
|
||||
)}
|
||||
>
|
||||
{agent.initials ?? getInitials(agent.name?.split(' ')[0] ?? '', agent.name?.split(' ')[1] ?? '')}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<span className="text-sm font-semibold text-primary truncate">{agent.name}</span>
|
||||
<span className="text-xs text-tertiary truncate">
|
||||
{agent.isOnShift ? '🟢 On shift' : '⚫ Off shift'} · Avg {agent.avgResponseHours}h response
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
<span className="text-lg font-bold text-primary">{agent.activeLeadCount ?? 0}</span>
|
||||
<span className="text-xs text-quaternary">Active leads</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Warning block */}
|
||||
<div className="rounded-xl border border-warning bg-warning-primary p-3.5">
|
||||
<p className="text-xs font-bold text-warning-primary">⚠ This action cannot be undone</p>
|
||||
<p className="mt-0.5 text-xs text-warning-primary/80">
|
||||
Once assigned, the lead status will change to Contacted and the agent will be notified immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 border-t border-secondary px-6 py-4">
|
||||
<Button size="md" color="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isDisabled={!selectedAgentId || visibleLeads.length === 0}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Assign {visibleLeads.length} {visibleLeads.length === 1 ? 'Lead' : 'Leads'}
|
||||
{selectedAgent ? ` to ${selectedAgent.name}` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
141
src/components/modals/mark-spam-modal.tsx
Normal file
141
src/components/modals/mark-spam-modal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { formatPhone, getInitials } from '@/lib/format';
|
||||
import type { Lead } from '@/types/entities';
|
||||
|
||||
type MarkSpamModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
lead: Lead;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
type SpamFactor = {
|
||||
icon: string;
|
||||
description: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
const computeSpamFactors = (lead: Lead): SpamFactor[] => {
|
||||
const factors: SpamFactor[] = [];
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
if (firstName.length < 3 && lastName.length < 3) {
|
||||
factors.push({ icon: '👤', description: 'Name is unusually short (under 3 characters each)', score: 30 });
|
||||
}
|
||||
|
||||
if (!lead.contactEmail || lead.contactEmail.length === 0) {
|
||||
factors.push({ icon: '📧', description: 'No email address provided', score: 10 });
|
||||
}
|
||||
|
||||
if (lead.createdAt) {
|
||||
const hour = new Date(lead.createdAt).getHours();
|
||||
if (hour >= 0 && hour < 5) {
|
||||
factors.push({ icon: '🌙', description: 'Submitted during late night hours (12–5 AM)', score: 20 });
|
||||
}
|
||||
}
|
||||
|
||||
if (lead.contactAttempts !== null && lead.contactAttempts === 0 && lead.createdAt) {
|
||||
factors.push({ icon: '⚡', description: 'Submitted with no contact attempts — possible bot', score: 18 });
|
||||
}
|
||||
|
||||
if (!lead.campaignId) {
|
||||
factors.push({ icon: '📊', description: 'No associated campaign or ad source', score: 10 });
|
||||
}
|
||||
|
||||
return factors.filter((f) => f.score > 0);
|
||||
};
|
||||
|
||||
export const MarkSpamModal = ({ isOpen, onOpenChange, lead, onConfirm }: MarkSpamModalProps) => {
|
||||
const factors = computeSpamFactors(lead);
|
||||
const computedScore = Math.min(100, factors.reduce((acc, f) => acc + f.score, 0));
|
||||
const displayScore = lead.spamScore ?? computedScore;
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
||||
const initials = getInitials(firstName || 'U', lastName || 'L');
|
||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : '—';
|
||||
const email = lead.contactEmail?.[0]?.address ?? '—';
|
||||
const source = lead.leadSource ?? '—';
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||
<Modal className="sm:max-w-lg">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b border-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Mark as Spam</h2>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
Review spam indicators before marking this lead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col gap-4 px-6 py-4 overflow-y-auto max-h-[65vh]">
|
||||
{/* Lead summary */}
|
||||
<div className="flex items-center gap-3 rounded-xl bg-error-primary p-4">
|
||||
<Avatar initials={initials} size="md" />
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<span className="text-sm font-semibold text-primary truncate">{fullName}</span>
|
||||
<span className="text-xs text-tertiary truncate">{phone}</span>
|
||||
<span className="text-xs text-tertiary truncate">{email}</span>
|
||||
<span className="text-xs text-quaternary">Source: {source}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-center">
|
||||
<span className="text-display-xs font-bold text-error-primary">{displayScore}</span>
|
||||
<span className="text-xs text-error-primary/70">spam score</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spam factors */}
|
||||
{factors.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-secondary">Spam indicators</p>
|
||||
{factors.map((factor, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 rounded-lg bg-secondary p-3"
|
||||
>
|
||||
<span className="text-base">{factor.icon}</span>
|
||||
<span className="flex-1 text-xs text-secondary">{factor.description}</span>
|
||||
<span className="shrink-0 text-xs font-bold text-error-primary">+{factor.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info block */}
|
||||
<div className="rounded-xl border border-error_subtle bg-error-primary/50 p-3.5">
|
||||
<p className="text-xs font-bold text-primary">This can be undone</p>
|
||||
<p className="mt-0.5 text-xs text-secondary">
|
||||
You can unmark this lead as spam later from the lead details panel or by filtering the Spam queue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 border-t border-secondary px-6 py-4">
|
||||
<Button size="md" color="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" color="primary-destructive" onClick={handleConfirm}>
|
||||
Mark as Spam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
191
src/components/modals/merge-modal.tsx
Normal file
191
src/components/modals/merge-modal.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type MergeModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
primaryLead: Lead;
|
||||
duplicateLead: Lead;
|
||||
onMerge: () => void;
|
||||
onKeepSeparate: () => void;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
label: string;
|
||||
primary: string;
|
||||
duplicate: string;
|
||||
};
|
||||
|
||||
const getLeadName = (lead: Lead) => {
|
||||
if (lead.contactName) {
|
||||
return `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim();
|
||||
}
|
||||
return '—';
|
||||
};
|
||||
|
||||
const getLeadPhone = (lead: Lead) => {
|
||||
if (lead.contactPhone && lead.contactPhone.length > 0) {
|
||||
return formatPhone(lead.contactPhone[0]);
|
||||
}
|
||||
return '—';
|
||||
};
|
||||
|
||||
const getLeadEmail = (lead: Lead) => lead.contactEmail?.[0]?.address ?? '—';
|
||||
|
||||
const buildFieldRows = (primary: Lead, duplicate: Lead): FieldRow[] => [
|
||||
{
|
||||
label: 'Name',
|
||||
primary: getLeadName(primary),
|
||||
duplicate: getLeadName(duplicate),
|
||||
},
|
||||
{
|
||||
label: 'Phone',
|
||||
primary: getLeadPhone(primary),
|
||||
duplicate: getLeadPhone(duplicate),
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
primary: getLeadEmail(primary),
|
||||
duplicate: getLeadEmail(duplicate),
|
||||
},
|
||||
{
|
||||
label: 'Source',
|
||||
primary: primary.leadSource ?? '—',
|
||||
duplicate: duplicate.leadSource ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Campaign',
|
||||
primary: primary.campaignId ?? '—',
|
||||
duplicate: duplicate.campaignId ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Created',
|
||||
primary: primary.createdAt ? formatShortDate(primary.createdAt) : '—',
|
||||
duplicate: duplicate.createdAt ? formatShortDate(duplicate.createdAt) : '—',
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
primary: primary.leadStatus ?? '—',
|
||||
duplicate: duplicate.leadStatus ?? '—',
|
||||
},
|
||||
];
|
||||
|
||||
const LeadCard = ({
|
||||
label,
|
||||
isPrimary,
|
||||
fieldRows,
|
||||
}: {
|
||||
label: string;
|
||||
isPrimary: boolean;
|
||||
fieldRows: FieldRow[];
|
||||
}) => (
|
||||
<div
|
||||
className={cx(
|
||||
'flex-1 rounded-xl border-2 p-4 flex flex-col gap-3',
|
||||
isPrimary ? 'border-brand' : 'border-secondary',
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cx(
|
||||
'text-xs font-bold uppercase tracking-wide',
|
||||
isPrimary ? 'text-brand-secondary' : 'text-quaternary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{fieldRows.map((row) => {
|
||||
const value = isPrimary ? row.primary : row.duplicate;
|
||||
const conflicts = row.primary !== row.duplicate;
|
||||
return (
|
||||
<div key={row.label} className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-quaternary">{row.label}</span>
|
||||
<span
|
||||
className={cx(
|
||||
'text-xs break-words',
|
||||
!isPrimary && conflicts ? 'font-semibold text-warning-primary' : 'text-primary',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MergeModal = ({ isOpen, onOpenChange, primaryLead, duplicateLead, onMerge, onKeepSeparate }: MergeModalProps) => {
|
||||
const fieldRows = buildFieldRows(primaryLead, duplicateLead);
|
||||
const primaryName = getLeadName(primaryLead);
|
||||
|
||||
const handleMerge = () => {
|
||||
onMerge();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleKeepSeparate = () => {
|
||||
onKeepSeparate();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||
<Modal className="sm:max-w-2xl">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b border-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Merge Duplicate Leads</h2>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
Compare and merge two leads with the same phone number.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col gap-4 px-6 py-4 overflow-y-auto max-h-[70vh]">
|
||||
{/* Side-by-side comparison */}
|
||||
<div className="flex items-start gap-2">
|
||||
<LeadCard
|
||||
label="Keep (Primary)"
|
||||
isPrimary={true}
|
||||
fieldRows={fieldRows}
|
||||
/>
|
||||
|
||||
<div className="flex shrink-0 items-center self-center px-1 pt-6">
|
||||
<span className="text-xl text-quaternary">→</span>
|
||||
</div>
|
||||
|
||||
<LeadCard
|
||||
label="Merge Into Primary"
|
||||
isPrimary={false}
|
||||
fieldRows={fieldRows}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<p className="text-xs text-quaternary">
|
||||
Fields shown in amber differ between records. The primary lead's values will be preserved after merging.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 border-t border-secondary px-6 py-4">
|
||||
<Button size="md" color="secondary" onClick={handleKeepSeparate}>
|
||||
Keep Separate
|
||||
</Button>
|
||||
<Button size="md" color="primary" onClick={handleMerge}>
|
||||
Merge → Keep {primaryName}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
163
src/components/modals/whatsapp-send-modal.tsx
Normal file
163
src/components/modals/whatsapp-send-modal.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import type { Lead, WhatsAppTemplate } from '@/types/entities';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type WhatsAppSendModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedLeads: Lead[];
|
||||
templates: WhatsAppTemplate[];
|
||||
onSend: (templateId: string) => void;
|
||||
};
|
||||
|
||||
const getLeadName = (lead: Lead) => {
|
||||
if (lead.contactName) {
|
||||
return `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim();
|
||||
}
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const getLeadPhone = (lead: Lead) => {
|
||||
if (lead.contactPhone && lead.contactPhone.length > 0) {
|
||||
return formatPhone(lead.contactPhone[0]);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const populateVariables = (body: string, lead: Lead) => {
|
||||
const name = getLeadName(lead);
|
||||
return body
|
||||
.replace(/\{\{name\}\}/gi, name)
|
||||
.replace(/\{\{first_name\}\}/gi, lead.contactName?.firstName ?? name)
|
||||
.replace(/\{\{service\}\}/gi, lead.interestedService ?? 'our services');
|
||||
};
|
||||
|
||||
const highlightVariables = (text: string) => {
|
||||
const parts = text.split(/(\{\{[^}]+\}\})/g);
|
||||
return parts.map((part, i) => {
|
||||
if (/^\{\{[^}]+\}\}$/.test(part)) {
|
||||
return (
|
||||
<span key={i} className="rounded bg-success-secondary px-1 font-semibold text-success-primary">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
export const WhatsAppSendModal = ({ isOpen, onOpenChange, selectedLeads, templates, onSend }: WhatsAppSendModalProps) => {
|
||||
const approvedTemplates = templates.filter((t) => t.approvalStatus === 'APPROVED');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(approvedTemplates[0]?.id ?? '');
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||
const previewLead = selectedLeads[0];
|
||||
|
||||
const selectItems = approvedTemplates.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.name ?? 'Untitled Template',
|
||||
supportingText: t.linkedCampaignName ?? undefined,
|
||||
}));
|
||||
|
||||
const handleSend = () => {
|
||||
if (selectedTemplateId) {
|
||||
onSend(selectedTemplateId);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||
<Modal className="sm:max-w-lg">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b border-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Send WhatsApp Message</h2>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
Send a template message to {selectedLeads.length} selected{' '}
|
||||
{selectedLeads.length === 1 ? 'lead' : 'leads'} via WhatsApp.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col gap-4 px-6 py-4 overflow-y-auto max-h-[65vh]">
|
||||
{/* Recipient chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedLeads.map((lead) => (
|
||||
<span
|
||||
key={lead.id}
|
||||
className="flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs text-brand-secondary"
|
||||
>
|
||||
<span className="font-medium">{getLeadName(lead)}</span>
|
||||
{getLeadPhone(lead) && (
|
||||
<span className="text-brand-tertiary">· {getLeadPhone(lead)}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template select */}
|
||||
<Select
|
||||
label="Template"
|
||||
placeholder="Select a template"
|
||||
size="sm"
|
||||
items={selectItems}
|
||||
selectedKey={selectedTemplateId}
|
||||
onSelectionChange={(key) => setSelectedTemplateId(String(key))}
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item id={item.id} supportingText={item.supportingText}>
|
||||
{item.label}
|
||||
</Select.Item>
|
||||
)}
|
||||
</Select>
|
||||
|
||||
{/* Preview */}
|
||||
{selectedTemplate && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-secondary">Preview</p>
|
||||
<div className="rounded-xl p-3" style={{ backgroundColor: '#e5ddd5' }}>
|
||||
<div className="max-w-[85%] rounded-xl rounded-tl-none bg-white px-3 py-2 shadow-sm">
|
||||
<p className="text-sm text-primary leading-relaxed">
|
||||
{previewLead
|
||||
? highlightVariables(populateVariables(selectedTemplate.body ?? '', previewLead))
|
||||
: highlightVariables(selectedTemplate.body ?? '')}
|
||||
</p>
|
||||
<p className="mt-1 text-right text-xs text-quaternary">12:00 PM ✓✓</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-quaternary">
|
||||
Variables auto-populated from lead data. Template is pre-approved by WhatsApp.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 border-t border-secondary px-6 py-4">
|
||||
<Button size="md" color="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isDisabled={!selectedTemplateId}
|
||||
onClick={handleSend}
|
||||
className={cx('bg-success-solid hover:bg-success-solid/90 text-white')}
|
||||
>
|
||||
Send to {selectedLeads.length} {selectedLeads.length === 1 ? 'Lead' : 'Leads'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user