/** * AI Conversations — WealthX Backoffice * * 3-panel inbox for managing AI + advisor conversations from website chatbot leads. * * Component hierarchy: * Atom → ConversationStatusChip * Molecule → ConversationListItem, ChatBubble * Organism → ConversationList, ChatThread, ChatComposer, AICollectedDataSection, LeadInfoPanel * Template → ConversationsPage */ import React, { useState } from "react"; import { ChevronLeft, MessageSquare, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import type { AiConvAdvisor, AiConvAppointmentData, AiConvChannel, AiConvChannelFilter, AiConvContact, AiConvDataField, AiConvFilterTab, AiConvListItemData, AiConvMessage, AiConvMode, AiConvStatus, } from "./types"; import type { AiConvEmailPayload } from "./thread"; import { ConversationList } from "./list"; import { ChatThread } from "./thread"; import { LeadInfoPanel } from "./lead-panel"; // Re-export everything so consumers import from the one public path. export * from "./types"; export * from "./list"; export * from "./bubble"; export * from "./thread"; export * from "./lead-panel"; // --------------------------------------------------------------------------- // ConversationsPage // --------------------------------------------------------------------------- export interface ConversationsPageProps { conversations: AiConvListItemData[]; activeConversationId?: string; /** Contact for the currently-selected conversation. */ contact?: AiConvContact; status?: AiConvStatus; mode?: AiConvMode; messages?: AiConvMessage[]; aiFields?: AiConvDataField[]; appointment?: AiConvAppointmentData; /** When the lead first contacted via the chatbot. */ leadFirstSeen?: string; /** Traffic source label (e.g. "Website Chatbot"). */ leadSource?: string; /** Conversation topic tag — e.g. "Buy a Home", "Refinance", "Investment". */ leadTopic?: string; searchQuery?: string; activeFilter?: AiConvFilterTab; /** Filter conversation list by channel type. */ channelFilter?: AiConvChannelFilter; /** Active reply channel — "chat" (default) or "email". */ channel?: AiConvChannel; onChannelChange?: (channel: AiConvChannel) => void; /** When true, the Email tab is shown in the composer. Defaults to false. */ isEmailIntegrated?: boolean; inputValue?: string; internalNotes?: string; showLeadPanel?: boolean; hasMore?: boolean; isLoadingMore?: boolean; /** True when older messages can be loaded for the active conversation. */ hasMoreMessages?: boolean; /** True while a `onLoadMoreMessages` request is in-flight. */ isLoadingMoreMessages?: boolean; /** Fired when the consumer should fetch older messages for the active conversation. */ onLoadMoreMessages?: () => void; onSelectConversation?: (id: string) => void; onRead?: (id: string) => void; onSearchChange?: (v: string) => void; onFilterChange?: (f: AiConvFilterTab) => void; onChannelFilterChange?: (f: AiConvChannelFilter) => void; onInputChange?: (v: string) => void; /** Fired when the user sends a chat message. */ onSend?: (content: string) => void; /** Fired when the user sends an email. */ onSendEmail?: (payload: AiConvEmailPayload) => void; onTakeOver?: () => void; onLetAiHandle?: () => void; /** Called when the user selects files via the attachment button in the composer. */ onAttachFile?: (files: FileList) => void; /** Called when the user selects images via the image upload button in the composer. */ onAttachImage?: (files: FileList) => void; /** Pre-fills the email Subject field with "Re: [emailReplySubject]" for reply threads. */ emailReplySubject?: string; onReopen?: () => void; onMarkUrgent?: () => void; onUnmarkUrgent?: () => void; onArchive?: () => void; onAssignToAdvisor?: () => void; /** True when this lead is already a client in the system. Disables Add to Clients. */ isKnownContact?: boolean; onAddToContacts?: () => void; onCreateOpportunity?: () => void; onBookAppointment?: () => void; onApproveAppointment?: () => void; onDeclineAppointment?: () => void; onRescheduleAppointment?: (contactId: string) => void; onNotesChange?: (v: string) => void; onToggleLeadPanel?: () => void; onLoadMore?: () => void; isAiTyping?: boolean; notesSaveStatus?: "idle" | "saving" | "saved"; className?: string; } export function ConversationsPage({ conversations, activeConversationId, contact, status = "ai-active", mode = "ai", messages = [], aiFields = [], appointment, leadFirstSeen, leadSource, leadTopic, searchQuery, activeFilter, channelFilter, onChannelFilterChange, channel, onChannelChange, isEmailIntegrated, inputValue, internalNotes, showLeadPanel = true, hasMore, isLoadingMore, hasMoreMessages, isLoadingMoreMessages, onLoadMoreMessages, isAiTyping, notesSaveStatus, onSelectConversation, onRead, onSearchChange, onFilterChange, onInputChange, onSend, onSendEmail, onTakeOver, onLetAiHandle, onAttachFile, onAttachImage, emailReplySubject, onReopen, onMarkUrgent, onUnmarkUrgent, onArchive, onAssignToAdvisor, isKnownContact, onAddToContacts, onCreateOpportunity, onBookAppointment, onApproveAppointment, onDeclineAppointment, onRescheduleAppointment, onNotesChange, onToggleLeadPanel, onLoadMore, className, }: ConversationsPageProps) { const [mobilePanel, setMobilePanel] = useState<"list" | "chat" | "lead">( "list", ); const handleSelectConversation = (id: string) => { onSelectConversation?.(id); setMobilePanel("chat"); }; const handleToggleLeadPanel = () => { onToggleLeadPanel?.(); setMobilePanel(showLeadPanel ? "chat" : "lead"); }; return (
{/* Left — Conversation List */} {/* Center — Chat Thread */} {contact ? ( setMobilePanel("list")} onShowLeadInfo={() => setMobilePanel("lead")} className={cn( "min-w-0 flex-1 border-r border-border", mobilePanel === "chat" ? "flex flex-col md:flex" : "hidden md:flex", )} /> ) : (

Select a conversation

)} {/* Right — Lead Info Panel */} {contact && ( <>
setMobilePanel("chat")} className="flex h-full w-full flex-col border-l border-border md:w-[320px]" />
{!showLeadPanel && onToggleLeadPanel && (
} /> Show lead info
)} )}
); } // --------------------------------------------------------------------------- // AiConvAssignAdvisorDialog // --------------------------------------------------------------------------- export interface AiConvAssignAdvisorDialogProps { open: boolean; onOpenChange: (open: boolean) => void; advisors: AiConvAdvisor[]; /** Currently selected advisor id */ value: string; onValueChange: (id: string) => void; onConfirm: () => void; } export function AiConvAssignAdvisorDialog({ open, onOpenChange, advisors, value, onValueChange, onConfirm, }: AiConvAssignAdvisorDialogProps) { const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState(""); const roles = Array.from( new Set(advisors.map((a) => a.role).filter(Boolean)), ) as string[]; const filtered = advisors.filter( (a) => a.name.toLowerCase().includes(search.toLowerCase()) && (!roleFilter || a.role === roleFilter), ); const handleOpenChange = (v: boolean) => { onOpenChange(v); if (!v) { setSearch(""); setRoleFilter(""); } }; return ( Assign to advisor Choose an advisor to handle this conversation.
{roles.length > 0 && (
{ const v = values[0]; setRoleFilter(!v || v === "__all__" ? "" : v); }} > All {roles.map((role) => ( {role} ))}
)}
setSearch(e.target.value)} className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" />
{filtered.length === 0 ? (

No advisors found.

) : ( filtered.map((advisor) => ( )) )}
); }