InteractionAnalytics.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import * as React from 'react';
  2. import { Conversation, AnalyticsData, ChatMessage } from '../types';
  3. import { subDays, format, eachDayOfInterval, startOfDay } from 'date-fns';
  4. import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
  5. import { Icon } from './ui/Icon';
  6. import { useTranslation } from '../hooks/useI18n';
  7. const STOP_WORDS_LIST = ['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your', 'yours', 'yourself', 'yourselves', 'he', 'him', 'his', 'himself', 'she', 'her', 'hers', 'herself', 'it', 'its', 'itself', 'they', 'them', 'their', 'theirs', 'themselves', 'what', 'which', 'who', 'whom', 'this', 'that', 'these', 'those', 'am', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'having', 'do', 'does', 'did', 'doing', 'a', 'an', 'the', 'and', 'but', 'if', 'or', 'because', 'as', 'until', 'while', 'of', 'at', 'by', 'for', 'with', 'about', 'against', 'between', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'to', 'from', 'up', 'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 's', 't', 'can', 'will', 'just', 'don', 'should', 'now'];
  8. const STOP_WORDS = new Set(STOP_WORDS_LIST);
  9. const EditResponseModal: React.FC<{
  10. message: ChatMessage;
  11. onClose: () => void;
  12. onSave: (newText: string) => void;
  13. }> = ({ message, onClose, onSave }) => {
  14. const { t } = useTranslation();
  15. const [text, setText] = React.useState(message.text);
  16. return (
  17. <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
  18. <div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md" onClick={e => e.stopPropagation()}>
  19. <div className="p-6 border-b border-gray-200 dark:border-gray-700">
  20. <h3 className="text-lg font-bold text-gray-900 dark:text-white">{t('analytics.interactions.edit_response_title')}</h3>
  21. </div>
  22. <div className="p-6">
  23. <textarea value={text} onChange={e => setText(e.target.value)} rows={5} className="w-full bg-gray-100 dark:bg-gray-700 p-2 rounded-md border border-gray-300 dark:border-gray-600" />
  24. </div>
  25. <div className="p-4 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
  26. <button onClick={onClose} className="px-4 py-2 rounded-md text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">{t('cancel')}</button>
  27. <button onClick={() => onSave(text)} className="px-4 py-2 rounded-md text-sm font-semibold text-white bg-brand-primary hover:bg-brand-secondary">{t('analytics.interactions.save_and_update')}</button>
  28. </div>
  29. </div>
  30. </div>
  31. );
  32. };
  33. interface InteractionAnalyticsProps {
  34. analyticsData: AnalyticsData;
  35. onUpdateConversations: (conversations: Conversation[]) => void;
  36. }
  37. const InteractionAnalytics: React.FC<InteractionAnalyticsProps> = ({ analyticsData, onUpdateConversations }) => {
  38. // FIX: Destructure 'language' from useTranslation hook to make it available for 'toLocaleString'.
  39. const { t, dateLocale, language } = useTranslation();
  40. const { conversations } = analyticsData;
  41. const [dateFilter, setDateFilter] = React.useState<string>('28');
  42. const [statusFilter, setStatusFilter] = React.useState<'all' | 'open' | 'resolved'>('all');
  43. const [searchTerm, setSearchTerm] = React.useState('');
  44. const [isDarkMode, setIsDarkMode] = React.useState(document.documentElement.classList.contains('dark'));
  45. const [editingMessage, setEditingMessage] = React.useState<{convId: string, msg: ChatMessage} | null>(null);
  46. React.useEffect(() => {
  47. const observer = new MutationObserver(() => setIsDarkMode(document.documentElement.classList.contains('dark')));
  48. observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
  49. return () => observer.disconnect();
  50. }, []);
  51. const chartColors = {
  52. grid: isDarkMode ? '#374151' : '#e5e7eb',
  53. text: isDarkMode ? '#9CA3AF' : '#6b7280',
  54. tooltipBg: isDarkMode ? '#1F2937' : '#FFFFFF',
  55. tooltipBorder: isDarkMode ? '#4B5563' : '#d1d5db',
  56. bar1: isDarkMode ? '#10b981' : '#10b981',
  57. bar2: isDarkMode ? '#3b82f6' : '#3b82f6',
  58. };
  59. const filteredConversations = React.useMemo(() => {
  60. const dateLimit = subDays(new Date(), parseInt(dateFilter, 10));
  61. return conversations.filter(c => {
  62. const conversationDate = new Date(c.timestamp);
  63. if (conversationDate < dateLimit) return false;
  64. if (statusFilter !== 'all' && c.status !== statusFilter) return false;
  65. if (searchTerm && !c.interactions.some(i => i.text.toLowerCase().includes(searchTerm.toLowerCase()))) return false;
  66. return true;
  67. });
  68. }, [conversations, dateFilter, statusFilter, searchTerm]);
  69. const analyticsSummary = React.useMemo(() => {
  70. const total = filteredConversations.length;
  71. if (total === 0) return { total: 0, resolved: 0, resolutionRate: '0%', avgInteractions: 0, avgResponseTime: 'N/A', conversationsByDay: [], commonTopics: [], visitorBreakdown: [] };
  72. const resolved = filteredConversations.filter(c => c.status === 'resolved').length;
  73. const resolutionRate = `${((resolved / total) * 100).toFixed(0)}%`;
  74. const totalInteractions = filteredConversations.reduce((sum, c) => sum + c.interactions.length, 0);
  75. const avgInteractions = parseFloat((totalInteractions / total).toFixed(1));
  76. const responseTimes = filteredConversations.map(c => c.firstResponseTime || 0).filter(t => t > 0);
  77. const avgResponseTimeSec = responseTimes.length > 0 ? responseTimes.reduce((sum, t) => sum + t, 0) / responseTimes.length : 0;
  78. const avgResponseTime = avgResponseTimeSec > 0 ? `${Math.round(avgResponseTimeSec)}s` : 'N/A';
  79. const daysInRange = eachDayOfInterval({ start: subDays(new Date(), parseInt(dateFilter) - 1), end: new Date() });
  80. const conversationsByDay = daysInRange.map(day => ({
  81. date: format(day, 'MMM d', { locale: dateLocale }),
  82. conversations: filteredConversations.filter(c => format(startOfDay(new Date(c.timestamp)), 'MMM d', { locale: dateLocale }) === format(day, 'MMM d', { locale: dateLocale })).length
  83. }));
  84. const wordCounts: { [key: string]: number } = {};
  85. filteredConversations.forEach(c => c.interactions.forEach(i => { if(i.sender === 'user') { const words = i.text.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/); words.forEach(word => { if (word && !STOP_WORDS.has(word)) { wordCounts[word] = (wordCounts[word] || 0) + 1; } }); }}));
  86. const commonTopics = Object.entries(wordCounts).sort(([,a],[,b]) => b - a).slice(0, 7).map(([name, count]) => ({ name, count }));
  87. const visitorStats: {[key: string]: { visits: number, conversations: number}} = {};
  88. filteredConversations.forEach(c => {
  89. if(!visitorStats[c.visitorId]) visitorStats[c.visitorId] = { visits: c.visitCount, conversations: 0 };
  90. visitorStats[c.visitorId].conversations++;
  91. });
  92. const visitorBreakdown = Object.entries(visitorStats).map(([id, stats]) => ({ id, ...stats })).sort((a,b) => b.conversations - a.conversations);
  93. return { total, resolved, resolutionRate, avgInteractions, avgResponseTime, conversationsByDay, commonTopics, visitorBreakdown };
  94. }, [filteredConversations, dateFilter, dateLocale]);
  95. const handleMarkAsResolved = (id: string) => onUpdateConversations(conversations.map(c => (c.id === id ? { ...c, status: 'resolved' } : c)));
  96. const handleSaveResponse = (newText: string) => {
  97. if(!editingMessage) return;
  98. const { convId, msg } = editingMessage;
  99. const updatedConversations = conversations.map(c => {
  100. if (c.id === convId) {
  101. return { ...c, interactions: c.interactions.map(i => i.id === msg.id ? { ...i, text: newText } : i) };
  102. }
  103. return c;
  104. });
  105. onUpdateConversations(updatedConversations);
  106. setEditingMessage(null);
  107. alert(t('analytics.interactions.update_success'));
  108. };
  109. return (
  110. <div className="p-6 space-y-6">
  111. {editingMessage && <EditResponseModal message={editingMessage.msg} onClose={() => setEditingMessage(null)} onSave={handleSaveResponse} />}
  112. <h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('analytics.interactions.title')}</h2>
  113. <div className="bg-white dark:bg-gray-800 p-4 rounded-lg flex flex-wrap items-center gap-4">
  114. <div className="relative flex-1 min-w-[200px]">
  115. <input type="text" placeholder={t('analytics.interactions.search_placeholder')} value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-full bg-gray-100 dark:bg-gray-700 p-2 pl-10 rounded-md border border-gray-300 dark:border-gray-600 focus:ring-brand-primary" />
  116. <Icon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 dark:text-gray-400"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></Icon>
  117. </div>
  118. <div className="flex items-center gap-2">
  119. <label className="text-sm text-gray-500 dark:text-gray-400">{t('analytics.interactions.date_label')}</label>
  120. <select value={dateFilter} onChange={e => setDateFilter(e.target.value)} className="bg-gray-100 dark:bg-gray-700 p-2 rounded-md border border-gray-300 dark:border-gray-600 focus:ring-brand-primary">
  121. <option value="7">{t('analytics.interactions.last_7_days')}</option>
  122. <option value="28">{t('analytics.interactions.last_28_days')}</option>
  123. </select>
  124. </div>
  125. <div className="flex items-center gap-1 bg-gray-200 dark:bg-gray-700 p-1 rounded-lg">
  126. {(['all', 'open', 'resolved'] as const).map(status => (
  127. <button key={status} onClick={() => setStatusFilter(status)} className={`px-3 py-1 text-sm rounded-md capitalize ${statusFilter === status ? 'bg-brand-primary text-white' : 'hover:bg-gray-300 dark:hover:bg-gray-600'}`}>
  128. {t(status)}
  129. </button>
  130. ))}
  131. </div>
  132. </div>
  133. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
  134. <div className="bg-white dark:bg-gray-800 p-6 rounded-lg"><h3 className="text-gray-500 dark:text-gray-400 text-sm font-medium">{t('analytics.interactions.total_conversations')}</h3><p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{analyticsSummary.total}</p></div>
  135. <div className="bg-white dark:bg-gray-800 p-6 rounded-lg"><h3 className="text-gray-500 dark:text-gray-400 text-sm font-medium">{t('analytics.interactions.resolution_rate')}</h3><p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{analyticsSummary.resolutionRate}</p></div>
  136. <div className="bg-white dark:bg-gray-800 p-6 rounded-lg"><h3 className="text-gray-500 dark:text-gray-400 text-sm font-medium">{t('analytics.interactions.avg_interactions')}</h3><p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{analyticsSummary.avgInteractions}</p></div>
  137. <div className="bg-white dark:bg-gray-800 p-6 rounded-lg"><h3 className="text-gray-500 dark:text-gray-400 text-sm font-medium">{t('analytics.interactions.avg_first_response')}</h3><p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{analyticsSummary.avgResponseTime}</p></div>
  138. </div>
  139. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
  140. <div className="bg-white dark:bg-gray-800 p-6 rounded-lg">
  141. <h3 className="font-semibold text-gray-900 dark:text-white mb-4">{t('analytics.interactions.visitor_breakdown')}</h3>
  142. <div className="h-[300px] overflow-y-auto">
  143. <table className="min-w-full">
  144. <thead className="sticky top-0 bg-white dark:bg-gray-800"><tr><th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase py-2">{t('analytics.interactions.visitor')}</th><th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase py-2">{t('analytics.interactions.conversations')}</th></tr></thead>
  145. <tbody>{analyticsSummary.visitorBreakdown.map(v => <tr key={v.id} className="border-t border-gray-200 dark:border-gray-700"><td className="py-2 font-semibold text-sm text-brand-primary">{v.id}</td><td className="py-2 text-sm">{v.conversations}</td></tr>)}</tbody>
  146. </table>
  147. </div>
  148. </div>
  149. <div className="bg-white dark:bg-gray-800 p-6 rounded-lg">
  150. <h3 className="font-semibold text-gray-900 dark:text-white mb-4">{t('analytics.interactions.common_topics')}</h3>
  151. <ResponsiveContainer width="100%" height={300}>
  152. <BarChart data={analyticsSummary.commonTopics} layout="vertical" margin={{ top: 5, right: 20, left: 30, bottom: 5 }}>
  153. <CartesianGrid strokeDasharray="3 3" stroke={chartColors.grid} horizontal={false} />
  154. <XAxis type="number" stroke={chartColors.text} fontSize={12} />
  155. <YAxis type="category" dataKey="name" stroke={chartColors.text} fontSize={12} width={80} />
  156. <Tooltip contentStyle={{ backgroundColor: chartColors.tooltipBg, border: `1px solid ${chartColors.tooltipBorder}` }} cursor={{fill: isDarkMode ? '#374151' : '#f3f4f6'}}/>
  157. <Bar dataKey="count" fill={chartColors.bar2} />
  158. </BarChart>
  159. </ResponsiveContainer>
  160. </div>
  161. </div>
  162. <div className="bg-white dark:bg-gray-800 rounded-lg">
  163. <div className="p-4 space-y-4">
  164. {filteredConversations.length > 0 ? filteredConversations.map(conv => (
  165. <div key={conv.id} className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
  166. <div className="flex flex-wrap justify-between items-center mb-3 pb-3 border-b border-gray-200 dark:border-gray-700 gap-2">
  167. <p className="text-sm font-semibold text-gray-900 dark:text-white">{t('analytics.interactions.visitor')} <span className="text-brand-primary">{conv.visitorId}</span></p>
  168. <div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
  169. <span>{new Date(conv.timestamp).toLocaleString(language)}</span>
  170. <span>{conv.visitCount} visits</span>
  171. <span className={`px-2 py-1 rounded-full text-xs font-semibold ${conv.status === 'resolved' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{conv.status}</span>
  172. {conv.status === 'open' && ( <button onClick={() => handleMarkAsResolved(conv.id)} className="px-2 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white text-xs">{t('analytics.interactions.mark_resolved')}</button> )}
  173. </div>
  174. </div>
  175. <div className="space-y-3 max-h-48 overflow-y-auto pr-2">
  176. {conv.interactions.map(msg => (
  177. <div key={msg.id} className={`group flex items-start gap-3 text-sm ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
  178. {msg.sender === 'ai' && <div className="w-6 h-6 rounded-full bg-brand-primary flex items-center justify-center flex-shrink-0 text-xs">🤖</div>}
  179. <div className={`max-w-xl p-2 rounded-lg ${msg.sender === 'user' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200'}`}>{msg.text}</div>
  180. {msg.sender === 'ai' && <button onClick={() => setEditingMessage({convId: conv.id, msg: msg})} className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-gray-700 dark:hover:text-white"><Icon className="h-4 w-4"><path d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L16.732 3.732z" /></Icon></button>}
  181. {msg.sender === 'user' && <div className="w-6 h-6 rounded-full bg-gray-400 dark:bg-gray-600 flex items-center justify-center flex-shrink-0 text-xs">👤</div>}
  182. </div>
  183. ))}
  184. </div>
  185. </div>
  186. )) : (
  187. <div className="text-center py-12 text-gray-400 dark:text-gray-500">
  188. <p>{t('analytics.interactions.no_conversations')}</p>
  189. </div>
  190. )}
  191. </div>
  192. </div>
  193. </div>
  194. );
  195. };
  196. export default InteractionAnalytics;