|
|
@@ -1,11 +1,19 @@
|
|
|
import { Card } from "@/components/ui/card";
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
-import { ChevronRight, Loader2 } from "lucide-react";
|
|
|
-import { useState, useEffect } from "react";
|
|
|
+import { ChevronRight, Loader2, Clock, CheckCircle, XCircle } from "lucide-react";
|
|
|
+import { useState, useEffect, useCallback, useRef } from "react";
|
|
|
import { useNavigate } from "react-router-dom";
|
|
|
import { API_URL } from "@/lib/config";
|
|
|
import { supabase } from "@/lib/supabase";
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
+import { RealtimeChannel } from "@supabase/supabase-js";
|
|
|
+import { AnalyticsStatus, ANALYTICS_STATUS } from "@/types/database.types";
|
|
|
+import {
|
|
|
+ Tooltip,
|
|
|
+ TooltipContent,
|
|
|
+ TooltipProvider,
|
|
|
+ TooltipTrigger,
|
|
|
+} from "@/components/ui/tooltip";
|
|
|
|
|
|
interface CallLog {
|
|
|
id: string;
|
|
|
@@ -15,6 +23,8 @@ interface CallLog {
|
|
|
ended_at: string | null;
|
|
|
duration: number | null;
|
|
|
caller: string | null;
|
|
|
+ outcome: string | null;
|
|
|
+ analytics_status: AnalyticsStatus | null;
|
|
|
}
|
|
|
|
|
|
const formatDuration = (seconds: number | null): string => {
|
|
|
@@ -30,11 +40,90 @@ const formatDateTime = (dateStr: string | null): string => {
|
|
|
return date.toLocaleString();
|
|
|
};
|
|
|
|
|
|
-const maskPhoneNumber = (phone: string | null): string => {
|
|
|
- if (!phone) return "-";
|
|
|
- if (phone.length <= 4) return phone;
|
|
|
- const last4 = phone.slice(-4);
|
|
|
- return `***${last4}`;
|
|
|
+const getOutcomeColor = (outcome: string | null): string => {
|
|
|
+ if (!outcome) return "bg-slate-600";
|
|
|
+
|
|
|
+ const colorMap: Record<string, string> = {
|
|
|
+ resolved: "bg-green-600",
|
|
|
+ order_placed: "bg-green-600",
|
|
|
+ order_inquiry: "bg-blue-600",
|
|
|
+ product_inquiry: "bg-blue-600",
|
|
|
+ general_question: "bg-blue-600",
|
|
|
+ complaint: "bg-red-600",
|
|
|
+ return_request: "bg-orange-600",
|
|
|
+ callback_requested: "bg-purple-600",
|
|
|
+ escalated: "bg-yellow-600",
|
|
|
+ no_answer: "bg-slate-500",
|
|
|
+ voicemail: "bg-slate-500",
|
|
|
+ wrong_number: "bg-slate-500",
|
|
|
+ abandoned: "bg-slate-500",
|
|
|
+ };
|
|
|
+
|
|
|
+ return colorMap[outcome] || "bg-slate-600";
|
|
|
+};
|
|
|
+
|
|
|
+const formatOutcome = (outcome: string | null): string => {
|
|
|
+ if (!outcome) return "-";
|
|
|
+ return outcome.split('_').map(word =>
|
|
|
+ word.charAt(0).toUpperCase() + word.slice(1)
|
|
|
+ ).join(' ');
|
|
|
+};
|
|
|
+
|
|
|
+// Analytics status indicator component
|
|
|
+const AnalyticsStatusIndicator = ({ status, t }: { status: AnalyticsStatus | null; t: (key: string) => string }) => {
|
|
|
+ if (!status || status === ANALYTICS_STATUS.PENDING) {
|
|
|
+ return (
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger>
|
|
|
+ <Clock className="w-4 h-4 text-slate-400" />
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent>
|
|
|
+ <p>{t('callLogs.analyticsStatus.pending')}</p>
|
|
|
+ </TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (status === ANALYTICS_STATUS.RUNNING) {
|
|
|
+ return (
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger>
|
|
|
+ <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent>
|
|
|
+ <p>{t('callLogs.analyticsStatus.running')}</p>
|
|
|
+ </TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (status === ANALYTICS_STATUS.COMPLETED) {
|
|
|
+ return (
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger>
|
|
|
+ <CheckCircle className="w-4 h-4 text-green-400" />
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent>
|
|
|
+ <p>{t('callLogs.analyticsStatus.completed')}</p>
|
|
|
+ </TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (status === ANALYTICS_STATUS.FAILED) {
|
|
|
+ return (
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger>
|
|
|
+ <XCircle className="w-4 h-4 text-red-400" />
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent>
|
|
|
+ <p>{t('callLogs.analyticsStatus.failed')}</p>
|
|
|
+ </TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
};
|
|
|
|
|
|
export function RecentCallsTable() {
|
|
|
@@ -43,41 +132,138 @@ export function RecentCallsTable() {
|
|
|
const [recentCalls, setRecentCalls] = useState<CallLog[]>([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
+ const [isRealtimeConnected, setIsRealtimeConnected] = useState(false);
|
|
|
+ const channelRef = useRef<RealtimeChannel | null>(null);
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- const fetchRecentCalls = async () => {
|
|
|
- try {
|
|
|
- const { data: { session } } = await supabase.auth.getSession();
|
|
|
- if (!session) {
|
|
|
- throw new Error('Not authenticated');
|
|
|
+ const fetchRecentCalls = useCallback(async (showLoading = true) => {
|
|
|
+ try {
|
|
|
+ if (showLoading) setLoading(true);
|
|
|
+ const { data: { session } } = await supabase.auth.getSession();
|
|
|
+ if (!session) {
|
|
|
+ throw new Error('Not authenticated');
|
|
|
+ }
|
|
|
+
|
|
|
+ const response = await fetch(`${API_URL}/api/call-logs`, {
|
|
|
+ headers: {
|
|
|
+ 'Authorization': `Bearer ${session.access_token}`,
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
}
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error('Failed to fetch call logs');
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = await response.json();
|
|
|
+ if (data.success && data.call_logs) {
|
|
|
+ // Take only the 5 most recent calls
|
|
|
+ setRecentCalls(data.call_logs.slice(0, 5));
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Error fetching call logs:', err);
|
|
|
+ setError(err instanceof Error ? err.message : 'Failed to load call logs');
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // Fetch user's store IDs for realtime filtering
|
|
|
+ const fetchUserStoreIds = useCallback(async () => {
|
|
|
+ try {
|
|
|
+ const { data: { session } } = await supabase.auth.getSession();
|
|
|
+ if (!session) return [];
|
|
|
+
|
|
|
+ const { data: stores } = await supabase
|
|
|
+ .from('stores')
|
|
|
+ .select('id')
|
|
|
+ .eq('user_id', session.user.id);
|
|
|
+
|
|
|
+ return stores?.map(s => s.id) || [];
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to fetch store IDs:', err);
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
|
|
|
- const response = await fetch(`${API_URL}/api/call-logs`, {
|
|
|
- headers: {
|
|
|
- 'Authorization': `Bearer ${session.access_token}`,
|
|
|
- 'Content-Type': 'application/json'
|
|
|
- }
|
|
|
- });
|
|
|
+ // Handle realtime updates
|
|
|
+ const handleRealtimeUpdate = useCallback((payload: { eventType: string; new: CallLog; old: { id: string } }) => {
|
|
|
+ console.log('[RecentCalls Realtime] Received event:', payload.eventType);
|
|
|
|
|
|
- if (!response.ok) {
|
|
|
- throw new Error('Failed to fetch call logs');
|
|
|
+ if (payload.eventType === 'INSERT') {
|
|
|
+ // Add new call at the beginning and keep only 5
|
|
|
+ setRecentCalls(prev => {
|
|
|
+ if (prev.some(call => call.id === payload.new.id)) {
|
|
|
+ return prev;
|
|
|
}
|
|
|
+ return [payload.new, ...prev].slice(0, 5);
|
|
|
+ });
|
|
|
+ } else if (payload.eventType === 'UPDATE') {
|
|
|
+ // Update existing call
|
|
|
+ setRecentCalls(prev =>
|
|
|
+ prev.map(call => call.id === payload.new.id ? payload.new : call)
|
|
|
+ );
|
|
|
+ } else if (payload.eventType === 'DELETE') {
|
|
|
+ // Remove deleted call and refetch to fill the gap
|
|
|
+ setRecentCalls(prev => prev.filter(call => call.id !== payload.old.id));
|
|
|
+ fetchRecentCalls(false);
|
|
|
+ }
|
|
|
+ }, [fetchRecentCalls]);
|
|
|
|
|
|
- const data = await response.json();
|
|
|
- if (data.success && data.call_logs) {
|
|
|
- // Take only the 5 most recent calls
|
|
|
- setRecentCalls(data.call_logs.slice(0, 5));
|
|
|
+ // Setup realtime subscription
|
|
|
+ const setupRealtimeSubscription = useCallback(async (storeIds: string[]) => {
|
|
|
+ if (channelRef.current) {
|
|
|
+ await supabase.removeChannel(channelRef.current);
|
|
|
+ channelRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (storeIds.length === 0) {
|
|
|
+ console.log('[RecentCalls Realtime] No stores to subscribe to');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[RecentCalls Realtime] Setting up subscription for stores:', storeIds);
|
|
|
+
|
|
|
+ const channel = supabase
|
|
|
+ .channel(`recent_calls_realtime_${Date.now()}`)
|
|
|
+ .on(
|
|
|
+ 'postgres_changes',
|
|
|
+ {
|
|
|
+ event: '*',
|
|
|
+ schema: 'public',
|
|
|
+ table: 'call_logs',
|
|
|
+ filter: storeIds.length === 1
|
|
|
+ ? `store_id=eq.${storeIds[0]}`
|
|
|
+ : `store_id=in.(${storeIds.join(',')})`
|
|
|
+ },
|
|
|
+ (payload) => {
|
|
|
+ handleRealtimeUpdate(payload as { eventType: string; new: CallLog; old: { id: string } });
|
|
|
}
|
|
|
- } catch (err) {
|
|
|
- console.error('Error fetching call logs:', err);
|
|
|
- setError(err instanceof Error ? err.message : 'Failed to load call logs');
|
|
|
- } finally {
|
|
|
- setLoading(false);
|
|
|
- }
|
|
|
+ )
|
|
|
+ .subscribe((status) => {
|
|
|
+ console.log('[RecentCalls Realtime] Subscription status:', status);
|
|
|
+ setIsRealtimeConnected(status === 'SUBSCRIBED');
|
|
|
+ });
|
|
|
+
|
|
|
+ channelRef.current = channel;
|
|
|
+ }, [handleRealtimeUpdate]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const initialize = async () => {
|
|
|
+ await fetchRecentCalls();
|
|
|
+ const storeIds = await fetchUserStoreIds();
|
|
|
+ await setupRealtimeSubscription(storeIds);
|
|
|
};
|
|
|
|
|
|
- fetchRecentCalls();
|
|
|
- }, []);
|
|
|
+ initialize();
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (channelRef.current) {
|
|
|
+ console.log('[RecentCalls Realtime] Cleaning up subscription');
|
|
|
+ supabase.removeChannel(channelRef.current);
|
|
|
+ channelRef.current = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [fetchRecentCalls, fetchUserStoreIds, setupRealtimeSubscription]);
|
|
|
|
|
|
const handleRowClick = (callId: string) => {
|
|
|
navigate(`/call-logs/${callId}`);
|
|
|
@@ -87,7 +273,21 @@ export function RecentCallsTable() {
|
|
|
<Card className="bg-slate-800 border-slate-700">
|
|
|
<div className="p-6">
|
|
|
<div className="flex items-center justify-between mb-6">
|
|
|
- <h3 className="text-lg font-semibold text-white">{t('dashboard.recentCalls.title')}</h3>
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <h3 className="text-lg font-semibold text-white">{t('dashboard.recentCalls.title')}</h3>
|
|
|
+ {/* Realtime indicator */}
|
|
|
+ <div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-slate-700/50" title={isRealtimeConnected ? t('callLogs.realtime.liveDescription') : t('callLogs.realtime.disconnectedDescription')}>
|
|
|
+ <span className="relative flex h-2 w-2">
|
|
|
+ {isRealtimeConnected && (
|
|
|
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
|
|
+ )}
|
|
|
+ <span className={`relative inline-flex rounded-full h-2 w-2 ${isRealtimeConnected ? 'bg-green-500' : 'bg-slate-500'}`}></span>
|
|
|
+ </span>
|
|
|
+ <span className="text-xs text-slate-400">
|
|
|
+ {isRealtimeConnected ? t('callLogs.realtime.live') : t('callLogs.realtime.disconnected')}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
<Button
|
|
|
variant="ghost"
|
|
|
size="sm"
|
|
|
@@ -112,34 +312,50 @@ export function RecentCallsTable() {
|
|
|
{t('dashboard.recentCalls.noCalls')}
|
|
|
</div>
|
|
|
) : (
|
|
|
- <div className="overflow-x-auto">
|
|
|
- <table className="w-full">
|
|
|
- <thead>
|
|
|
- <tr className="border-b border-slate-700">
|
|
|
- <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('dashboard.recentCalls.headers.time')}</th>
|
|
|
- <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('dashboard.recentCalls.headers.caller')}</th>
|
|
|
- <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('dashboard.recentCalls.headers.duration')}</th>
|
|
|
- <th className="text-right py-3 px-4 text-sm font-medium text-slate-400"></th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody>
|
|
|
- {recentCalls.map((call) => (
|
|
|
- <tr
|
|
|
- key={call.id}
|
|
|
- className="border-b border-slate-700/50 hover:bg-slate-700/30 cursor-pointer transition-colors"
|
|
|
- onClick={() => handleRowClick(call.id)}
|
|
|
- >
|
|
|
- <td className="py-3 px-4 text-sm text-slate-300">{formatDateTime(call.started_at)}</td>
|
|
|
- <td className="py-3 px-4 text-sm text-white">{maskPhoneNumber(call.caller)}</td>
|
|
|
- <td className="py-3 px-4 text-sm text-slate-300">{formatDuration(call.duration)}</td>
|
|
|
- <td className="py-3 px-4 text-right">
|
|
|
- <ChevronRight className="w-4 h-4 text-slate-400 inline-block" />
|
|
|
- </td>
|
|
|
+ <TooltipProvider>
|
|
|
+ <div className="overflow-x-auto">
|
|
|
+ <table className="w-full">
|
|
|
+ <thead>
|
|
|
+ <tr className="border-b border-slate-700">
|
|
|
+ <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('dashboard.recentCalls.headers.time')}</th>
|
|
|
+ <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('dashboard.recentCalls.headers.caller')}</th>
|
|
|
+ <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('dashboard.recentCalls.headers.duration')}</th>
|
|
|
+ <th className="text-left py-3 px-4 text-sm font-medium text-slate-400">{t('callLogs.table.outcome')}</th>
|
|
|
+ <th className="text-center py-3 px-4 text-sm font-medium text-slate-400">{t('callLogs.table.analytics')}</th>
|
|
|
+ <th className="text-right py-3 px-4 text-sm font-medium text-slate-400"></th>
|
|
|
</tr>
|
|
|
- ))}
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
- </div>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {recentCalls.map((call) => (
|
|
|
+ <tr
|
|
|
+ key={call.id}
|
|
|
+ className="border-b border-slate-700/50 hover:bg-slate-700/30 cursor-pointer transition-colors"
|
|
|
+ onClick={() => handleRowClick(call.id)}
|
|
|
+ >
|
|
|
+ <td className="py-3 px-4 text-sm text-slate-300">{formatDateTime(call.started_at)}</td>
|
|
|
+ <td className="py-3 px-4 text-sm text-white">{call.caller || '-'}</td>
|
|
|
+ <td className="py-3 px-4 text-sm text-slate-300">{formatDuration(call.duration)}</td>
|
|
|
+ <td className="py-3 px-4 text-sm">
|
|
|
+ {call.outcome ? (
|
|
|
+ <span className={`px-2 py-0.5 rounded text-xs font-medium text-white ${getOutcomeColor(call.outcome)}`}>
|
|
|
+ {formatOutcome(call.outcome)}
|
|
|
+ </span>
|
|
|
+ ) : (
|
|
|
+ <span className="text-slate-400">-</span>
|
|
|
+ )}
|
|
|
+ </td>
|
|
|
+ <td className="py-3 px-4 text-center">
|
|
|
+ <AnalyticsStatusIndicator status={call.analytics_status} t={t} />
|
|
|
+ </td>
|
|
|
+ <td className="py-3 px-4 text-right">
|
|
|
+ <ChevronRight className="w-4 h-4 text-slate-400 inline-block" />
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </TooltipProvider>
|
|
|
)}
|
|
|
</div>
|
|
|
</Card>
|