mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
import { useMemo } from "react";
|
|
import { faCircleCheck, faClock, faEnvelope, faPhone, faPhoneArrowDown, faStars } from "@fortawesome/pro-duotone-svg-icons";
|
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
import { Avatar } from "@/components/base/avatar/avatar";
|
|
import { Badge } from "@/components/base/badges/badges";
|
|
import { AgeIndicator } from "@/components/shared/age-indicator";
|
|
import { SourceTag } from "@/components/shared/source-tag";
|
|
import { formatPhone, formatShortDate, getInitials } from "@/lib/format";
|
|
import type { CallDisposition, Campaign, Lead, LeadActivity } from "@/types/entities";
|
|
import { DispositionForm } from "./disposition-form";
|
|
|
|
type CallState = "idle" | "ringing" | "active" | "completed";
|
|
|
|
interface IncomingCallCardProps {
|
|
callState: CallState;
|
|
lead: Lead | null;
|
|
activities: LeadActivity[];
|
|
campaigns: Campaign[];
|
|
onDisposition: (disposition: CallDisposition, notes: string) => void;
|
|
completedDisposition?: CallDisposition | null;
|
|
}
|
|
|
|
const activityTypeIcons: Record<string, string> = {
|
|
CALL_MADE: "phone",
|
|
CALL_RECEIVED: "phone",
|
|
WHATSAPP_SENT: "message",
|
|
WHATSAPP_RECEIVED: "message",
|
|
SMS_SENT: "message",
|
|
EMAIL_SENT: "email",
|
|
EMAIL_RECEIVED: "email",
|
|
NOTE_ADDED: "note",
|
|
ASSIGNED: "assign",
|
|
STATUS_CHANGE: "status",
|
|
APPOINTMENT_BOOKED: "calendar",
|
|
FOLLOW_UP_CREATED: "clock",
|
|
CONVERTED: "check",
|
|
MARKED_SPAM: "alert",
|
|
DUPLICATE_DETECTED: "alert",
|
|
};
|
|
|
|
const ActivityIcon = ({ type }: { type: string }) => {
|
|
const iconType = activityTypeIcons[type] ?? "note";
|
|
const baseClass = "size-3.5 shrink-0 text-fg-quaternary";
|
|
|
|
if (iconType === "phone") return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
|
|
if (iconType === "email") return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
|
|
if (iconType === "clock") return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
|
if (iconType === "check") return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
|
|
return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
|
};
|
|
|
|
const dispositionLabels: Record<CallDisposition, string> = {
|
|
APPOINTMENT_BOOKED: "Appointment Booked",
|
|
FOLLOW_UP_SCHEDULED: "Follow-up Needed",
|
|
INFO_PROVIDED: "Info Provided",
|
|
NO_ANSWER: "No Answer",
|
|
WRONG_NUMBER: "Wrong Number",
|
|
CALLBACK_REQUESTED: "Not Interested",
|
|
};
|
|
|
|
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
|
if (callState === "idle") {
|
|
return <IdleState />;
|
|
}
|
|
|
|
if (callState === "ringing") {
|
|
return <RingingState lead={lead} />;
|
|
}
|
|
|
|
if (callState === "active" && lead !== null) {
|
|
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
|
|
}
|
|
|
|
if (callState === "completed") {
|
|
return <CompletedState disposition={completedDisposition ?? null} />;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const IdleState = () => (
|
|
<div className="flex flex-col items-center justify-center rounded-2xl border border-secondary bg-secondary p-12 text-center">
|
|
<div className="mb-4 animate-pulse">
|
|
<FontAwesomeIcon icon={faPhone} className="size-12 text-fg-quaternary" />
|
|
</div>
|
|
<p className="text-lg text-tertiary">Waiting for incoming call...</p>
|
|
</div>
|
|
);
|
|
|
|
const RingingState = ({ lead }: { lead: Lead | null }) => {
|
|
const phoneDisplay = lead?.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "+91 98765 43210";
|
|
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-2xl bg-brand-primary p-12 text-center">
|
|
<div className="relative mb-4">
|
|
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
|
<div className="relative animate-bounce">
|
|
<FontAwesomeIcon icon={faPhoneArrowDown} className="size-12 text-fg-brand-primary" />
|
|
</div>
|
|
</div>
|
|
<span className="mb-1 text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</span>
|
|
<span className="text-display-xs font-bold text-primary">{phoneDisplay}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ActiveState = ({
|
|
lead,
|
|
activities,
|
|
campaigns,
|
|
onDisposition,
|
|
}: {
|
|
lead: Lead;
|
|
activities: LeadActivity[];
|
|
campaigns: Campaign[];
|
|
onDisposition: (disposition: CallDisposition, notes: string) => void;
|
|
}) => {
|
|
const leadActivities = useMemo(
|
|
() =>
|
|
activities
|
|
.filter((a) => a.leadId === lead.id)
|
|
.sort((a, b) => {
|
|
const dateA = a.occurredAt ?? a.createdAt ?? "";
|
|
const dateB = b.occurredAt ?? b.createdAt ?? "";
|
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
|
})
|
|
.slice(0, 3),
|
|
[activities, lead.id],
|
|
);
|
|
|
|
const campaignName = useMemo(() => {
|
|
if (lead.campaignId === null) return null;
|
|
const campaign = campaigns.find((c) => c.id === lead.campaignId);
|
|
return campaign?.campaignName ?? null;
|
|
}, [campaigns, lead.campaignId]);
|
|
|
|
const firstName = lead.contactName?.firstName ?? "";
|
|
const lastName = lead.contactName?.lastName ?? "";
|
|
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
|
|
const initials = firstName && lastName ? getInitials(firstName, lastName) : "UL";
|
|
const phoneDisplay = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "No phone";
|
|
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-secondary bg-primary shadow-lg">
|
|
<div className="flex flex-col gap-6 p-6 lg:flex-row">
|
|
{/* Left section: lead details */}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start gap-4">
|
|
<Avatar size="xl" initials={initials} />
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
|
|
<div className="mt-1 flex items-center gap-1.5">
|
|
<FontAwesomeIcon icon={faPhone} className="size-3.5 shrink-0 text-fg-quaternary" />
|
|
<span className="text-md text-secondary">{phoneDisplay}</span>
|
|
</div>
|
|
{emailDisplay !== null && (
|
|
<div className="mt-0.5 flex items-center gap-1.5">
|
|
<FontAwesomeIcon icon={faEnvelope} className="size-3.5 shrink-0 text-fg-quaternary" />
|
|
<span className="text-sm text-tertiary">{emailDisplay}</span>
|
|
</div>
|
|
)}
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
{lead.leadSource !== null && <SourceTag source={lead.leadSource} size="sm" />}
|
|
{campaignName !== null && (
|
|
<Badge size="sm" color="brand">
|
|
{campaignName}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{lead.interestedService !== null && <p className="mt-1.5 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
|
|
{lead.createdAt !== null && (
|
|
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
|
|
<span>Lead age:</span>
|
|
<AgeIndicator dateStr={lead.createdAt} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* AI Insight panel */}
|
|
<div className="mt-4 rounded-xl bg-brand-primary p-4">
|
|
<div className="mb-2 flex items-center gap-1.5">
|
|
<FontAwesomeIcon icon={faStars} className="size-4 text-fg-brand-primary" />
|
|
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Insight</span>
|
|
</div>
|
|
{lead.aiSummary !== null ? (
|
|
<>
|
|
<p className="text-sm text-primary">{lead.aiSummary}</p>
|
|
{lead.aiSuggestedAction !== null && (
|
|
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">
|
|
{lead.aiSuggestedAction}
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-quaternary">No AI insights available for this lead</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Previous interactions */}
|
|
<div className="mt-4">
|
|
<h4 className="mb-2 text-sm font-bold text-primary">Recent Activity</h4>
|
|
{leadActivities.length > 0 ? (
|
|
<div className="flex flex-col gap-2">
|
|
{leadActivities.map((activity) => (
|
|
<div key={activity.id} className="flex items-start gap-2">
|
|
<ActivityIcon type={activity.activityType ?? "NOTE_ADDED"} />
|
|
<span className="flex-1 text-xs text-secondary">{activity.summary}</span>
|
|
<span className="shrink-0 text-xs text-quaternary">
|
|
{activity.occurredAt !== null ? formatShortDate(activity.occurredAt) : ""}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-quaternary">No previous interactions</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right section: disposition form */}
|
|
<div className="w-full shrink-0 border-t border-secondary pt-4 lg:w-72 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
|
<DispositionForm onSubmit={onDisposition} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
|
|
const label = disposition !== null ? dispositionLabels[disposition] : "Unknown";
|
|
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-2xl bg-success-primary p-8 text-center">
|
|
<FontAwesomeIcon icon={faCircleCheck} className="mb-3 size-12 text-fg-success-primary" />
|
|
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
|
|
{disposition !== null && (
|
|
<Badge size="md" color="success" className="mt-2">
|
|
{label}
|
|
</Badge>
|
|
)}
|
|
<p className="mt-2 text-sm text-tertiary">Returning to call desk...</p>
|
|
</div>
|
|
);
|
|
};
|