4 Commits
13 changed files with 1092 additions and 4 deletions
+2
View File
@@ -12,6 +12,7 @@ const RecoveryIncidentTabs = lazy(() => import('./app/recoveryIncidents/tabs/ind
const AuditLogsList = lazy(() => import('./app/auditLogs/components/AuditLogsList'))
const ConfigurationPage = lazy(() => import('./app/configuration'))
const SimulationPage = lazy(() => import('./app/simulation'))
const ChatbotPage = lazy(() => import('./app/chatbot'))
function AppRoutes() {
return (
@@ -19,6 +20,7 @@ function AppRoutes() {
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/chatbot" element={<ChatbotPage />} />
<Route path="/simulation" element={<SimulationPage />} />
<Route path="/cohorts" element={<CohortManage />} />
<Route path="/policy-engine" element={<PolicyEngineList />} />
+172
View File
@@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from 'react';
import { ConversationList } from './components/ConversationList';
import { ChatWindow } from './components/ChatWindow';
import {
getConversations,
createConversation,
getConversationMessages,
sendMessage as sendChatMessage,
deleteConversation as deleteChatConversation,
} from './services/chatApi';
import type { Conversation, ChatMessage } from './types/ChatTypes';
export default function ChatbotPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loadingConversations, setLoadingConversations] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch initial user conversations
const loadConversations = useCallback(async (autoSelectFirst = true) => {
setLoadingConversations(true);
try {
const list = await getConversations();
setConversations(list);
if (autoSelectFirst && list.length > 0 && !activeConversationId) {
setActiveConversationId(list[0].id);
}
} catch (err) {
console.error('Failed to load conversations:', err);
setError('Unable to load conversations. Please try again.');
} finally {
setLoadingConversations(false);
}
}, [activeConversationId]);
useEffect(() => {
loadConversations();
}, [loadConversations]);
// Fetch messages when active conversation changes
useEffect(() => {
if (!activeConversationId) {
setMessages([]);
return;
}
setLoadingMessages(true);
getConversationMessages(activeConversationId)
.then((msgs) => setMessages(msgs))
.catch((err) => {
console.error('Failed to load messages:', err);
setError('Failed to retrieve message history.');
})
.finally(() => setLoadingMessages(false));
}, [activeConversationId]);
// Handle "+ New Conversation"
const handleNewConversation = async () => {
setError(null);
try {
const newConv = await createConversation('New Conversation');
setConversations((prev) => [newConv, ...prev]);
setActiveConversationId(newConv.id);
setMessages([]);
} catch (err) {
console.error('Failed to create conversation:', err);
setError('Failed to create new conversation.');
}
};
// Handle sending a message
const handleSendMessage = async (text: string) => {
setError(null);
let convId = activeConversationId;
// Auto-create conversation if none selected
if (!convId) {
try {
const newConv = await createConversation(text.length > 30 ? text.substring(0, 30) + '...' : text);
setConversations((prev) => [newConv, ...prev]);
convId = newConv.id;
setActiveConversationId(convId);
} catch (err) {
console.error('Failed to create initial conversation:', err);
setError('Failed to initiate conversation.');
return;
}
}
// Optimistically add user message to UI
const tempUserMsg: ChatMessage = {
id: crypto.randomUUID(),
conversationId: convId,
role: 'user',
content: text,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUserMsg]);
setSending(true);
try {
const response = await sendChatMessage({
conversationId: convId,
message: text,
});
const assistantMsg: ChatMessage = {
id: response.messageId,
conversationId: response.conversationId,
role: response.role,
content: response.content,
createdAt: response.createdAt,
};
setMessages((prev) => [...prev, assistantMsg]);
// Refresh list to update conversation title/timestamps
loadConversations(false);
} catch (err: any) {
console.error('Failed to send message:', err);
setError(
err.response?.data?.message ||
'Unable to connect to the chatbot service. Please try again.',
);
} finally {
setSending(false);
}
};
// Handle deleting a conversation
const handleDeleteConversation = async (id: string) => {
try {
await deleteChatConversation(id);
const updated = conversations.filter((c) => c.id !== id);
setConversations(updated);
if (activeConversationId === id) {
setActiveConversationId(updated.length > 0 ? updated[0].id : null);
}
} catch (err) {
console.error('Failed to delete conversation:', err);
setError('Failed to delete conversation.');
}
};
const activeConversation =
conversations.find((c) => c.id === activeConversationId) || null;
return (
<div className="w-full h-[calc(100vh-80px)] flex bg-white rounded-2xl border border-slate-200/80 shadow-sm overflow-hidden">
<ConversationList
conversations={conversations}
activeId={activeConversationId}
loading={loadingConversations}
onSelect={setActiveConversationId}
onNew={handleNewConversation}
onDelete={handleDeleteConversation}
/>
<ChatWindow
activeConversation={activeConversation}
messages={messages}
loadingMessages={loadingMessages}
sending={sending}
error={error}
onClearError={() => setError(null)}
onSendMessage={handleSendMessage}
/>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useState, useRef, type KeyboardEvent } from 'react';
import { PaperPlaneRightIcon } from '@phosphor-icons/react';
import { CustomButton } from '../../../components/custom';
interface ChatInputProps {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({
onSend,
disabled = false,
placeholder = 'Ask Aero Resolve AI anything about IROPS, policies, or cohorts...',
}: ChatInputProps) {
const [text, setText] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSend = () => {
const trimmed = text.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setText('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleTextChange = (val: string) => {
setText(val);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 140)}px`;
}
};
return (
<div className="bg-white border border-slate-200/80 rounded-2xl p-2 shadow-md transition-all focus-within:border-[#1B9869] focus-within:ring-2 focus-within:ring-[#1B9869]/10">
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={text}
disabled={disabled}
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={1}
className="flex-1 resize-none bg-transparent px-3 py-2 text-[14px] text-slate-800 placeholder-slate-400 focus:outline-none max-h-[140px] scrollbar-thin"
/>
<CustomButton
variant="primary"
onClick={handleSend}
disabled={disabled || !text.trim()}
className="!bg-[#1B9869] hover:!bg-[#14704E] !rounded-xl !p-2.5 !h-[42px] !w-[42px] flex items-center justify-center shrink-0 disabled:opacity-40 transition-all"
>
<PaperPlaneRightIcon size={18} weight="bold" />
</CustomButton>
</div>
<div className="flex items-center justify-between px-3 pt-1.5 pb-0.5 text-[11px] text-slate-400">
<span>Press <kbd className="font-semibold text-slate-500">Enter</kbd> to send, <kbd className="font-semibold text-slate-500">Shift + Enter</kbd> for new line</span>
<span>{text.length} / 4000</span>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { useState } from 'react';
import { UserIcon, CopyIcon, CheckIcon } from '@phosphor-icons/react';
import { SparkleIcon } from 'lucide-react';
import type { ChatMessage } from '../types/ChatTypes';
import { formatDate } from '../../../utils/formatDate';
interface ChatMessageItemProps {
message: ChatMessage;
}
export function ChatMessageItem({ message }: ChatMessageItemProps) {
const isUser = message.role === 'user';
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const formattedTime = (() => {
try {
return formatDate(message.createdAt);
} catch {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
})();
return (
<div
className={`flex gap-3 my-4 group ${
isUser ? 'flex-row-reverse' : 'flex-row'
}`}
>
{/* Avatar */}
<div
className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm ${
isUser
? 'bg-[#1B9869] text-white'
: 'bg-emerald-50 text-[#14704E] border border-emerald-200/60'
}`}
>
{isUser ? <UserIcon size={18} weight="bold" /> : <SparkleIcon size={18} />}
</div>
{/* Message Body & Actions */}
<div
className={`flex flex-col max-w-[78%] ${
isUser ? 'items-end' : 'items-start'
}`}
>
{/* Name and Time Header */}
<div className="flex items-center gap-2 mb-1 px-1">
<span className="text-[11px] font-bold text-slate-500 tracking-wide uppercase">
{isUser ? 'You' : 'Aero Resolve AI'}
</span>
<span className="text-[10px] text-slate-400 font-medium">
{formattedTime}
</span>
</div>
{/* Message Bubble */}
<div
className={`relative px-4 py-3 rounded-2xl text-[14px] leading-relaxed shadow-sm transition-all ${
isUser
? 'bg-[#1B9869] text-white rounded-tr-none'
: 'bg-white text-slate-800 border border-slate-100 rounded-tl-none font-normal'
}`}
>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
{/* Copy Action Button for Assistant */}
{!isUser && (
<button
onClick={handleCopy}
title="Copy response"
className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg"
>
{copied ? (
<CheckIcon size={14} className="text-emerald-600" />
) : (
<CopyIcon size={14} />
)}
</button>
)}
</div>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useRef } from 'react';
import { SparkleIcon } from 'lucide-react';
import { ChatMessageItem } from './ChatMessageItem';
import { ChatInput } from './ChatInput';
import { CustomAlertBanner, Skeleton } from '../../../components/custom';
import type { ChatMessage, Conversation } from '../types/ChatTypes';
interface ChatWindowProps {
activeConversation: Conversation | null;
messages: ChatMessage[];
loadingMessages: boolean;
sending: boolean;
error: string | null;
onClearError: () => void;
onSendMessage: (text: string) => void;
}
export function ChatWindow({
activeConversation,
messages,
loadingMessages,
sending,
error,
onClearError,
onSendMessage,
}: ChatWindowProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, sending]);
return (
<div className="flex-1 flex flex-col h-full bg-white relative overflow-hidden font-sans">
{/* Top Header */}
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-white z-10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-[#1B9869] to-[#14704E] text-white flex items-center justify-center shadow-sm">
<SparkleIcon size={20} />
</div>
<div className="flex flex-col">
<h2 className="text-[15px] font-bold text-slate-900 leading-tight">
{activeConversation?.title || 'Aero Resolve Assistant'}
</h2>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span className="text-[11px] font-medium text-slate-500">
AI Active & Ready
</span>
</div>
</div>
</div>
</div>
{/* Alert Error Banner */}
{error && (
<div className="p-4">
<CustomAlertBanner
message={error}
type="error"
onClose={onClearError}
/>
</div>
)}
{/* Messages Stream Container */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2 scrollbar-thin">
{loadingMessages ? (
<div className="space-y-4 max-w-xl py-6">
<Skeleton height={60} className="rounded-2xl" />
<Skeleton height={80} className="rounded-2xl w-3/4 ml-auto" />
<Skeleton height={70} className="rounded-2xl" />
</div>
) : messages.length === 0 ? (
/* Empty State Suggestions */
<div className="h-full flex flex-col items-center justify-center max-w-xl mx-auto text-center px-4 py-8">
<div className="w-16 h-16 rounded-2xl bg-emerald-50 text-[#1B9869] flex items-center justify-center mb-4 border border-emerald-100 shadow-sm">
<SparkleIcon size={32} />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">
How can I help with Aero Resolve today?
</h3>
</div>
) : (
/* Messages List */
messages.map((m) => <ChatMessageItem key={m.id} message={m} />)
)}
{/* Typing Indicator */}
{sending && (
<div className="flex gap-3 my-4 items-start">
<div className="w-9 h-9 rounded-xl bg-emerald-50 text-[#14704E] border border-emerald-200/60 flex items-center justify-center shrink-0">
<SparkleIcon size={18} />
</div>
<div className="bg-slate-100 text-slate-500 px-4 py-3 rounded-2xl rounded-tl-none flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce"></span>
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce [animation-delay:0.2s]"></span>
<span className="w-2 h-2 rounded-full bg-slate-400 animate-bounce [animation-delay:0.4s]"></span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input Container */}
<div className="p-4 border-t border-slate-100 bg-white">
<ChatInput onSend={onSendMessage} disabled={sending} />
</div>
</div>
);
}
@@ -0,0 +1,128 @@
import { useState } from 'react';
import {
PlusIcon,
ChatTeardropTextIcon,
TrashIcon,
MagnifyingGlassIcon,
} from '@phosphor-icons/react';
import { CustomButton, CustomInput, Skeleton } from '../../../components/custom';
import type { Conversation } from '../types/ChatTypes';
import { formatDate } from '../../../utils/formatDate';
interface ConversationListProps {
conversations: Conversation[];
activeId: string | null;
loading: boolean;
onSelect: (id: string) => void;
onNew: () => void;
onDelete: (id: string) => void;
}
export function ConversationList({
conversations,
activeId,
loading,
onSelect,
onNew,
onDelete,
}: ConversationListProps) {
const [search, setSearch] = useState('');
const filteredConversations = conversations.filter((c) =>
(c.title || '').toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="w-[300px] flex-shrink-0 bg-[#F8FAFC] border-r border-slate-200/80 h-full flex flex-col p-4 font-sans">
{/* Action Header */}
<div className="flex flex-col gap-3 mb-4">
<CustomButton
variant="primary"
onClick={onNew}
leftIcon={<PlusIcon size={16} weight="bold" />}
className="w-full !bg-[#1B9869] hover:!bg-[#14704E] !text-white !font-semibold !rounded-xl !py-2.5 shadow-sm"
>
New Conversation
</CustomButton>
{/* Search Bar */}
<CustomInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search chats..."
leftIcon={<MagnifyingGlassIcon size={16} className="text-slate-400" />}
className="!bg-white !rounded-xl !h-[38px] !border-slate-200 !text-[13px]"
containerClassName="!gap-0"
/>
</div>
{/* Conversations List */}
<div className="flex-1 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
{loading ? (
<div className="space-y-2 pt-2">
<Skeleton height={48} className="rounded-xl" />
<Skeleton height={48} className="rounded-xl" />
<Skeleton height={48} className="rounded-xl" />
</div>
) : filteredConversations.length === 0 ? (
<div className="py-12 text-center text-slate-400 text-xs flex flex-col items-center gap-2">
<ChatTeardropTextIcon size={24} className="text-slate-300" />
<span>No conversations found</span>
</div>
) : (
filteredConversations.map((c) => {
const isActive = c.id === activeId;
const displayTime = (() => {
try {
return formatDate(c.updatedAt);
} catch {
return '';
}
})();
return (
<div
key={c.id}
onClick={() => onSelect(c.id)}
className={`group flex items-center justify-between p-3 rounded-xl cursor-pointer transition-all border ${
isActive
? 'bg-white border-[#1B9869]/30 text-slate-900 shadow-sm font-semibold'
: 'bg-transparent border-transparent text-slate-600 hover:bg-slate-200/50 hover:text-slate-900'
}`}
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<ChatTeardropTextIcon
size={18}
className={isActive ? 'text-[#1B9869]' : 'text-slate-400'}
weight={isActive ? 'bold' : 'regular'}
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13px] truncate leading-tight">
{c.title || 'New Conversation'}
</span>
{displayTime && (
<span className="text-[10px] text-slate-400 font-normal mt-0.5">
{displayTime}
</span>
)}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(c.id);
}}
title="Delete conversation"
className="opacity-0 group-hover:opacity-100 p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
>
<TrashIcon size={14} />
</button>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,427 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
ChatTeardropTextIcon,
XIcon,
PlusCircleIcon,
ArrowSquareOutIcon,
PaperPlaneRightIcon,
ArrowsClockwiseIcon,
} from '@phosphor-icons/react';
import { SparkleIcon } from 'lucide-react';
import { ChatMessageItem } from './ChatMessageItem';
import { CustomAlertBanner, Skeleton } from '../../../components/custom';
import {
getConversations,
createConversation,
getConversationMessages,
sendMessage as sendChatMessage,
} from '../services/chatApi';
import type { Conversation, ChatMessage } from '../types/ChatTypes';
const SUGGESTIONS = [
{
title: 'What is IROPS?',
desc: 'Understand Irregular Operations & flight disruption policies.',
},
{
title: 'Policy Engine Overview',
desc: 'How business rules, compensation, and gates evaluate.',
},
{
title: 'Cohort Targeting Rules',
desc: 'Passenger segmentation by tier, cabin class, & route.',
},
];
export function FloatingChatWidget() {
const location = useLocation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loadingConversations, setLoadingConversations] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inputText, setInputText] = useState('');
const [hasUnread, setHasUnread] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// If we're on the dedicated /chatbot page, hide the floating widget to avoid duplication
const isChatbotPage = location.pathname === '/chatbot';
// Load conversations
const loadConversations = useCallback(async (autoSelectFirst = true) => {
setLoadingConversations(true);
try {
const list = await getConversations();
setConversations(list);
if (autoSelectFirst && list.length > 0 && !activeConversationId) {
setActiveConversationId(list[0].id);
}
} catch (err) {
console.error('Failed to load conversations in widget:', err);
} finally {
setLoadingConversations(false);
}
}, [activeConversationId]);
// When widget opens for the first time, load conversations
useEffect(() => {
if (isOpen) {
setHasUnread(false);
loadConversations();
}
}, [isOpen, loadConversations]);
// Fetch messages when active conversation changes
useEffect(() => {
if (!isOpen || !activeConversationId) {
setMessages([]);
return;
}
setLoadingMessages(true);
getConversationMessages(activeConversationId)
.then((msgs) => setMessages(msgs))
.catch((err) => {
console.error('Failed to load messages in widget:', err);
setError('Failed to retrieve message history.');
})
.finally(() => setLoadingMessages(false));
}, [isOpen, activeConversationId]);
// Auto-scroll to bottom on new messages or sending
useEffect(() => {
if (isOpen) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, sending, isOpen]);
// Handle "+ New Conversation"
const handleNewConversation = async () => {
setError(null);
try {
const newConv = await createConversation('New Conversation');
setConversations((prev) => [newConv, ...prev]);
setActiveConversationId(newConv.id);
setMessages([]);
} catch (err) {
console.error('Failed to create new conversation:', err);
setError('Failed to create new conversation.');
}
};
// Handle sending message
const handleSendMessage = async (text: string) => {
const trimmed = text.trim();
if (!trimmed || sending) return;
setError(null);
let convId = activeConversationId;
// Auto-create conversation if none selected
if (!convId) {
try {
const title = trimmed.length > 30 ? trimmed.substring(0, 30) + '...' : trimmed;
const newConv = await createConversation(title);
setConversations((prev) => [newConv, ...prev]);
convId = newConv.id;
setActiveConversationId(convId);
} catch (err) {
console.error('Failed to create conversation:', err);
setError('Failed to start conversation.');
return;
}
}
// Optimistically add user message
const tempUserMsg: ChatMessage = {
id: crypto.randomUUID(),
conversationId: convId,
role: 'user',
content: trimmed,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUserMsg]);
setInputText('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
setSending(true);
try {
const response = await sendChatMessage({
conversationId: convId,
message: trimmed,
});
const assistantMsg: ChatMessage = {
id: response.messageId,
conversationId: response.conversationId,
role: response.role,
content: response.content,
createdAt: response.createdAt,
};
setMessages((prev) => [...prev, assistantMsg]);
loadConversations(false);
} catch (err: any) {
console.error('Failed to send message in widget:', err);
setError(
err.response?.data?.message ||
'Unable to connect to the chatbot service. Please try again.',
);
} finally {
setSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(inputText);
}
};
const handleTextChange = (val: string) => {
setInputText(val);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
};
const activeConversation = conversations.find((c) => c.id === activeConversationId);
if (isChatbotPage) {
return null;
}
return (
<>
{/* Floating Chat Modal Window */}
{isOpen && (
<div className="fixed bottom-24 right-6 z-50 w-[420px] max-w-[calc(100vw-32px)] h-[620px] max-h-[calc(100vh-120px)] bg-white rounded-[24px] shadow-[0_20px_60px_-15px_rgba(0,0,0,0.25)] border border-slate-200/90 flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200 font-sans">
{/* Header */}
<div className="px-5 py-3.5 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 text-white flex items-center justify-between shadow-sm shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-[#1B9869] to-[#14704E] text-white flex items-center justify-center shadow-sm shrink-0">
<SparkleIcon size={18} />
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<h3 className="text-[14px] font-bold text-white leading-tight">
Aero Resolve Assistant
</h3>
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
AI
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-[11px] text-slate-300 font-medium">
Online & Ready
</span>
</div>
</div>
</div>
{/* Header Actions */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleNewConversation}
title="New Chat"
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
>
<PlusCircleIcon size={18} weight="bold" />
</button>
<button
type="button"
onClick={() => {
setIsOpen(false);
navigate('/chatbot');
}}
title="Open in Full Page"
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
>
<ArrowSquareOutIcon size={18} weight="bold" />
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
title="Close Window"
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-xl transition-colors"
>
<XIcon size={18} weight="bold" />
</button>
</div>
</div>
{/* Conversation Bar (if multiple conversations exist) */}
{conversations.length > 1 && (
<div className="px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between text-xs text-slate-600">
<span className="truncate font-medium max-w-[220px]">
{activeConversation?.title || 'Active Conversation'}
</span>
<select
value={activeConversationId || ''}
onChange={(e) => setActiveConversationId(e.target.value)}
className="text-[11px] bg-white border border-slate-200 rounded-lg px-2 py-1 text-slate-700 outline-none focus:border-[#1B9869]"
>
{conversations.map((c) => (
<option key={c.id} value={c.id}>
{c.title.length > 25 ? c.title.substring(0, 25) + '...' : c.title}
</option>
))}
</select>
</div>
)}
{/* Error Banner */}
{error && (
<div className="px-4 pt-3 pb-1 shrink-0">
<CustomAlertBanner
message={error}
type="error"
onClose={() => setError(null)}
/>
</div>
)}
{/* Messages Stream Container */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2 bg-[#FAFBFB] scrollbar-thin">
{loadingMessages ? (
<div className="space-y-3 max-w-sm py-4">
<Skeleton height={50} className="rounded-2xl" />
<Skeleton height={65} className="rounded-2xl w-3/4 ml-auto" />
<Skeleton height={55} className="rounded-2xl" />
</div>
) : messages.length === 0 ? (
/* Empty State Suggestions */
<div className="h-full flex flex-col items-center justify-center text-center px-2 py-4">
<div className="w-12 h-12 rounded-2xl bg-emerald-50 text-[#1B9869] flex items-center justify-center mb-3 border border-emerald-100 shadow-sm">
<SparkleIcon size={24} />
</div>
<h4 className="text-[15px] font-bold text-slate-800 mb-1">
How can I assist you?
</h4>
<p className="text-xs text-slate-500 mb-4 max-w-[280px]">
Ask questions about IROPS, flight policies, compensation rules, or cohorts.
</p>
<div className="flex flex-col gap-2 w-full">
{SUGGESTIONS.map((s, idx) => (
<button
key={idx}
onClick={() => handleSendMessage(s.title)}
className="text-left p-2.5 rounded-xl border border-slate-200/80 bg-white hover:border-[#1B9869] hover:bg-emerald-50/30 transition-all group shadow-2xs"
>
<div className="text-xs font-bold text-slate-800 group-hover:text-[#1B9869]">
{s.title}
</div>
<div className="text-[11px] text-slate-500 leading-tight mt-0.5">
{s.desc}
</div>
</button>
))}
</div>
</div>
) : (
/* Message List */
messages.map((m) => <ChatMessageItem key={m.id} message={m} />)
)}
{/* Typing Indicator */}
{sending && (
<div className="flex gap-2.5 my-3 items-start">
<div className="w-8 h-8 rounded-xl bg-emerald-50 text-[#14704E] border border-emerald-200/60 flex items-center justify-center shrink-0">
<SparkleIcon size={15} />
</div>
<div className="bg-white text-slate-500 px-3.5 py-2.5 rounded-2xl rounded-tl-none border border-slate-100 flex items-center gap-1.5 shadow-2xs">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce" />
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce [animation-delay:0.2s]" />
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce [animation-delay:0.4s]" />
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Footer Input Area */}
<div className="p-3 bg-white border-t border-slate-100 shrink-0">
<div className="bg-slate-50 border border-slate-200/80 rounded-2xl p-1.5 transition-all focus-within:border-[#1B9869] focus-within:bg-white focus-within:ring-2 focus-within:ring-[#1B9869]/10 shadow-2xs">
<div className="flex items-end gap-1.5">
<textarea
ref={textareaRef}
value={inputText}
disabled={sending}
onChange={(e) => handleTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask Aero AI anything..."
rows={1}
className="flex-1 resize-none bg-transparent px-2.5 py-1.5 text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none max-h-[120px] scrollbar-thin"
/>
<button
type="button"
onClick={() => handleSendMessage(inputText)}
disabled={sending || !inputText.trim()}
className="bg-[#1B9869] hover:bg-[#14704E] text-white rounded-xl w-9 h-9 flex items-center justify-center shrink-0 disabled:opacity-30 disabled:hover:bg-[#1B9869] transition-all shadow-sm active:scale-95"
title="Send message"
>
<PaperPlaneRightIcon size={16} weight="bold" />
</button>
</div>
</div>
<div className="flex items-center justify-between px-2 pt-1 text-[10px] text-slate-400">
<span>Press <kbd className="font-semibold text-slate-500">Enter</kbd> to send</span>
<span>Aero Resolve AI</span>
</div>
</div>
</div>
)}
{/* Floating Action Button (FAB) */}
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
aria-label={isOpen ? 'Close chat assistant' : 'Open chat assistant'}
className={`fixed bottom-6 right-6 z-50 group flex items-center justify-center w-14 h-14 rounded-full bg-gradient-to-tr from-[#1B9869] to-[#14704E] text-white shadow-[0_8px_25px_rgba(27,152,105,0.45)] hover:shadow-[0_12px_30px_rgba(27,152,105,0.6)] hover:scale-105 active:scale-95 transition-all duration-300 focus:outline-none focus:ring-4 focus:ring-[#1B9869]/30 ${
isOpen ? 'rotate-90' : ''
}`}
>
{isOpen ? (
<XIcon size={24} weight="bold" className="transition-transform duration-200" />
) : (
<div className="relative flex items-center justify-center">
<ChatTeardropTextIcon size={26} weight="fill" />
{/* Sparkle badge */}
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-400 text-slate-900 rounded-full flex items-center justify-center shadow-sm">
<SparkleIcon size={10} className="fill-slate-900" />
</span>
{/* Active pulse dot */}
<span className="absolute -bottom-1 -left-1 w-2.5 h-2.5 rounded-full bg-emerald-300 border-2 border-[#14704E] animate-pulse" />
</div>
)}
{/* Hover Tooltip (when closed) */}
{!isOpen && (
<span className="absolute right-16 px-3 py-1.5 bg-slate-900 text-white text-xs font-semibold rounded-xl shadow-lg opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
Ask Aero AI
</span>
)}
</button>
</>
);
}
export default FloatingChatWidget;
+5
View File
@@ -0,0 +1,5 @@
import ChatbotPage from './ChatbotPage';
export { FloatingChatWidget } from './components/FloatingChatWidget';
export default ChatbotPage;
+42
View File
@@ -0,0 +1,42 @@
import { ApiClient } from '../../api/ApiClient';
import type {
Conversation,
ChatMessage,
SendMessagePayload,
SendMessageResponse,
} from '../types/ChatTypes';
export async function createConversation(title?: string): Promise<Conversation> {
return ApiClient.post<any, Conversation>('/chat/conversations', { title });
}
export async function getConversations(): Promise<Conversation[]> {
const res = await ApiClient.get<any, { conversations: Conversation[] }>(
'/chat/conversations',
);
return res.conversations || [];
}
export async function getConversationMessages(
conversationId: string,
): Promise<ChatMessage[]> {
const res = await ApiClient.get<
any,
{ conversationId: string; messages: ChatMessage[] }
>(`/chat/conversations/${conversationId}/messages`);
return res.messages || [];
}
export async function sendMessage(
payload: SendMessagePayload,
): Promise<SendMessageResponse> {
return ApiClient.post<any, SendMessageResponse>('/chat/messages', payload);
}
export async function deleteConversation(
conversationId: string,
): Promise<{ success: boolean }> {
return ApiClient.delete<any, { success: boolean }>(
`/chat/conversations/${conversationId}`,
);
}
+28
View File
@@ -0,0 +1,28 @@
export interface ChatMessage {
id: string;
conversationId: string;
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: string;
}
export interface Conversation {
id: string;
userId?: string;
title: string;
createdAt: string;
updatedAt: string;
}
export interface SendMessagePayload {
conversationId: string;
message: string;
}
export interface SendMessageResponse {
messageId: string;
conversationId: string;
role: 'assistant';
content: string;
createdAt: string;
}
@@ -402,8 +402,8 @@ export default function SimulationTerminal() {
{/* Strategic Category Tabs */}
<div>
<label className="block text-xs font-bold text-gray-700 uppercase tracking-wider mb-1.5">
STRATEGIC CATEGORY
<label className="block text-xs font-bold text-gray-700 tracking-wider mb-1.5">
Strategic Category
</label>
<CustomTabs
tabs={categoryTabs}
@@ -422,7 +422,7 @@ export default function SimulationTerminal() {
<div>
<CustomDropdown
label="JURISDICTION"
label="Jurisdiction"
options={jurisdictionOptions}
value={formData.jurisdiction}
onChange={(val) => handleInputChange('jurisdiction', val)}
@@ -436,7 +436,7 @@ export default function SimulationTerminal() {
<div>
<CustomDropdown
label="SCENARIO"
label="Scenario"
options={categoryConfig.scenarios}
value={formData.scenario}
onChange={(val) => handleScenarioChange(val)}
+4
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import type { ReactNode } from 'react';
import AppSidebar from './AppSidebar';
import AppHeader from './AppHeader';
import { FloatingChatWidget } from '../app/chatbot';
interface LayoutProps {
children: ReactNode;
@@ -21,6 +22,9 @@ function Layout({ children }: LayoutProps) {
{children}
</main>
</div>
{/* Global Floating Chatbot FAB & Modal Window */}
<FloatingChatWidget />
</div>
);
}
+2
View File
@@ -11,11 +11,13 @@ import {
GearIcon,
ClockCounterClockwiseIcon,
CaretDoubleRightIcon,
ChatTeardropTextIcon,
} from "@phosphor-icons/react";
import { ShieldCheckIcon } from "lucide-react";
const NAV_ITEMS = [
{ label: "Dashboard", path: "/", icon: SquaresFourIcon },
{ label: "AI Assistant", path: "/chatbot", icon: ChatTeardropTextIcon },
{ label: "Simulation Engine", path: "/simulation", icon: FadersIcon },
{ label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon },
{ label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon },