Files
helix-engage/src/components/leads/lead-activity-slideout.tsx
2026-03-16 15:08:28 +05:30

151 lines
6.9 KiB
TypeScript

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