mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 10:48:14 +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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user