Adds AI-driven conversation tracking to debt negotiation

Introduces comprehensive conversation history with a new table and UI for tracking all negotiation emails, AI analysis, and financial outcomes. Enhances real-time updates, manages negotiation rounds, and supports new statuses for negotiation lifecycle. Integrates AI-powered extraction and response analysis to automate intent detection and outcome calculations, improving transparency and automation of debt resolution.
This commit is contained in:
2025-06-08 00:32:04 -03:00
parent 0c6f72761d
commit bddc3a344d
14 changed files with 2568 additions and 135 deletions

View File

@@ -0,0 +1,586 @@
import React, { useEffect, useState } from "react";
import {
CheckCircle,
Clock,
AlertCircle,
XCircle,
StopCircle,
Mail,
MailOpen,
MessageSquare,
TrendingUp,
TrendingDown,
Calendar,
User,
Building2,
} from "lucide-react";
import { supabase, type Debt } from "../lib/supabase";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Badge } from "./ui/badge";
import { Separator } from "./ui/separator";
import { toast } from "sonner";
import { formatCurrency } from "../lib/utils";
interface ConversationMessage {
id: string;
debt_id: string;
message_type: string;
direction: "inbound" | "outbound";
subject?: string;
body: string;
from_email?: string;
to_email?: string;
message_id?: string;
ai_analysis?: any;
created_at: string;
updated_at: string;
}
interface ConversationTimelineProps {
debt: Debt;
onDebtUpdate?: (updatedDebt: Debt) => void;
}
const messageTypeLabels = {
initial_debt: "Initial Debt Notice",
negotiation_sent: "Negotiation Sent",
response_received: "Response Received",
counter_offer: "Counter Offer",
acceptance: "Offer Accepted",
rejection: "Offer Rejected",
};
const statusColors = {
received: "text-blue-600 dark:text-blue-400",
negotiating: "text-yellow-600 dark:text-yellow-400",
approved: "text-purple-600 dark:text-purple-400",
sent: "text-orange-600 dark:text-orange-400",
awaiting_response: "text-blue-600 dark:text-blue-400",
counter_negotiating: "text-yellow-600 dark:text-yellow-400",
accepted: "text-green-600 dark:text-green-400",
rejected: "text-red-600 dark:text-red-400",
settled: "text-green-600 dark:text-green-400",
failed: "text-red-600 dark:text-red-400",
opted_out: "text-gray-600 dark:text-gray-400",
};
const statusLabels = {
received: "Received",
negotiating: "Negotiating",
approved: "Approved",
sent: "Sent",
awaiting_response: "Awaiting Response",
counter_negotiating: "Counter Negotiating",
accepted: "Accepted",
rejected: "Rejected",
settled: "Settled",
failed: "Failed",
opted_out: "Opted Out",
};
export function ConversationTimeline({
debt,
onDebtUpdate,
}: ConversationTimelineProps) {
const [messages, setMessages] = useState<ConversationMessage[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchConversationMessages();
// Set up real-time subscription for conversation messages
const channel = supabase
.channel(`conversation_messages:debt_id=eq.${debt.id}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "conversation_messages",
filter: `debt_id=eq.${debt.id}`,
},
(payload) => {
console.log("Real-time conversation message update:", payload);
if (payload.eventType === "INSERT") {
// Add new message to the list
const newMessage = payload.new as ConversationMessage;
setMessages((prev) => [...prev, newMessage]);
// Show toast notification for new messages
if (newMessage.direction === "inbound") {
const isAcceptance = newMessage.message_type === "acceptance";
toast.success(
isAcceptance ? "🎉 Offer Accepted!" : "New Response Received",
{
description: isAcceptance
? "Creditor accepted your negotiation offer!"
: `${
messageTypeLabels[newMessage.message_type] ||
"New message"
} from creditor`,
}
);
}
} else if (payload.eventType === "UPDATE") {
// Update existing message
setMessages((prev) =>
prev.map((msg) =>
msg.id === payload.new.id
? (payload.new as ConversationMessage)
: msg
)
);
} else if (payload.eventType === "DELETE") {
// Remove deleted message
setMessages((prev) =>
prev.filter((msg) => msg.id !== payload.old.id)
);
}
}
)
.subscribe();
// Set up real-time subscription for debt status changes
const debtChannel = supabase
.channel(`debt_status:id=eq.${debt.id}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "debts",
filter: `id=eq.${debt.id}`,
},
(payload) => {
console.log("Real-time debt status update:", payload);
// Notify parent component about debt update
if (onDebtUpdate) {
onDebtUpdate(payload.new as Debt);
}
}
)
.subscribe();
// Cleanup subscriptions on unmount
return () => {
supabase.removeChannel(channel);
supabase.removeChannel(debtChannel);
};
}, [debt.id]);
const fetchConversationMessages = async () => {
try {
const { data, error } = await supabase
.from("conversation_messages")
.select("*")
.eq("debt_id", debt.id)
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching conversation messages:", error);
return;
}
setMessages(data || []);
} catch (error) {
console.error("Error fetching conversation messages:", error);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getMessageIcon = (message: ConversationMessage) => {
if (message.direction === "outbound") {
return <Mail className="h-4 w-4" />;
} else {
switch (message.message_type) {
case "acceptance":
return <CheckCircle className="h-4 w-4" />;
case "rejection":
return <XCircle className="h-4 w-4" />;
case "counter_offer":
return <MessageSquare className="h-4 w-4" />;
default:
return <MailOpen className="h-4 w-4" />;
}
}
};
const getMessageColor = (message: ConversationMessage) => {
if (message.direction === "outbound") {
return "text-blue-600 dark:text-blue-400";
} else {
switch (message.message_type) {
case "acceptance":
return "text-green-600 dark:text-green-400";
case "rejection":
return "text-red-600 dark:text-red-400";
case "counter_offer":
return "text-yellow-600 dark:text-yellow-400";
default:
return "text-gray-600 dark:text-gray-400";
}
}
};
const getSentimentIcon = (sentiment?: string) => {
switch (sentiment) {
case "positive":
return <TrendingUp className="h-3 w-3 text-green-500" />;
case "negative":
return <TrendingDown className="h-3 w-3 text-red-500" />;
default:
return null;
}
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5 animate-spin" />
Loading Conversation...
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Conversation Timeline
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className={statusColors[debt.status]}>
{statusLabels[debt.status]}
</Badge>
<span className="text-sm text-gray-500">
Round {debt.negotiation_round || 1}
</span>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{messages.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No conversation messages yet</p>
<p className="text-sm">
Messages will appear here as the negotiation progresses
</p>
</div>
) : (
<div className="space-y-4">
{messages.map((message, index) => (
<div key={message.id} className="relative">
{/* Timeline line */}
{index < messages.length - 1 && (
<div className="absolute left-4 top-8 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
)}
<div className="flex gap-4">
{/* Icon */}
<div
className={`
flex items-center justify-center w-8 h-8 rounded-full border-2 bg-white dark:bg-gray-800
${getMessageColor(message)} border-current
`}
>
{getMessageIcon(message)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-foreground">
{messageTypeLabels[message.message_type] ||
message.message_type}
</h4>
{message.ai_analysis?.sentiment &&
getSentimentIcon(message.ai_analysis.sentiment)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Calendar className="h-3 w-3" />
{formatDate(message.created_at)}
</div>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 mb-2">
{message.direction === "outbound" ? (
<>
<User className="h-3 w-3" />
<span>You {message.to_email}</span>
</>
) : (
<>
<Building2 className="h-3 w-3" />
<span>{message.from_email} You</span>
</>
)}
</div>
{message.subject && (
<p className="text-sm font-medium text-gray-800 dark:text-gray-200 mb-2">
Subject: {message.subject}
</p>
)}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-sm">
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
{message.body.length > 200
? `${message.body.substring(0, 200)}...`
: message.body}
</p>
</div>
{/* AI Analysis */}
{message.ai_analysis && (
<div className="mt-2 space-y-2">
{message.ai_analysis.intent && (
<div className="flex items-center gap-2 my-2">
<Badge
variant={
message.ai_analysis.intent === "acceptance"
? "default"
: "secondary"
}
className={`text-xs ${
message.ai_analysis.intent === "acceptance"
? "bg-green-600 text-white"
: ""
}`}
>
Intent: {message.ai_analysis.intent}
</Badge>
{message.ai_analysis.confidence && (
<span className="text-xs text-gray-500">
{Math.round(
message.ai_analysis.confidence * 100
)}
% confidence
</span>
)}
</div>
)}
{!!message.ai_analysis.extractedTerms
?.proposedAmount && (
<div className="text-xs text-gray-600 dark:text-gray-400">
Proposed Amount: $
{message.ai_analysis.extractedTerms.proposedAmount}
</div>
)}
{/* Show financial outcome for accepted offers */}
{message.ai_analysis.intent === "acceptance" &&
debt.metadata?.financialOutcome && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 mt-2">
{debt.metadata.financialOutcome.financialBenefit
?.type === "principal_reduction" ? (
<>
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-4 w-4 text-green-600" />
<span className="font-medium text-green-800 dark:text-green-200">
Principal Reduction Achieved
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-600 dark:text-gray-400">
Original Debt:
</span>
<div className="font-medium">
$
{
debt.metadata.financialOutcome
.originalAmount
}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Settlement Amount:
</span>
<div className="font-medium">
$
{
debt.metadata.financialOutcome
.acceptedAmount
}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Total Savings:
</span>
<div className="font-medium text-green-600">
$
{
debt.metadata.financialOutcome
.actualSavings
}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Reduction:
</span>
<div className="font-medium text-green-600">
{
debt.metadata.financialOutcome
.financialBenefit.percentage
}
%
</div>
</div>
</div>
</>
) : debt.metadata.financialOutcome
.financialBenefit?.type ===
"payment_restructuring" ? (
<>
<div className="flex items-center gap-2 mb-2">
<Calendar className="h-4 w-4 text-blue-600" />
<span className="font-medium text-blue-800 dark:text-blue-200">
Payment Plan Restructured
</span>
</div>
<div className="space-y-2 text-xs">
<div className="bg-blue-50 dark:bg-blue-900/30 rounded p-2">
<div className="font-medium text-blue-800 dark:text-blue-200 mb-1">
{
debt.metadata.financialOutcome
.financialBenefit.description
}
</div>
{debt.metadata.financialOutcome
.financialBenefit.cashFlowBenefit && (
<div className="text-blue-600 dark:text-blue-300">
💰{" "}
{
debt.metadata.financialOutcome
.financialBenefit.cashFlowBenefit
}
</div>
)}
</div>
{debt.metadata.financialOutcome
.paymentStructure && (
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-gray-600 dark:text-gray-400">
Monthly Payment:
</span>
<div className="font-medium">
$
{
debt.metadata.financialOutcome
.paymentStructure.monthlyAmount
}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Term Length:
</span>
<div className="font-medium">
{
debt.metadata.financialOutcome
.paymentStructure
.numberOfPayments
}{" "}
months
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Total Amount:
</span>
<div className="font-medium">
$
{formatCurrency(
debt.metadata.financialOutcome
.paymentStructure.totalAmount
)}
</div>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">
Frequency:
</span>
<div className="font-medium capitalize">
{
debt.metadata.financialOutcome
.paymentStructure.frequency
}
</div>
</div>
</div>
)}
</div>
</>
) : (
<>
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-600" />
<span className="font-medium text-green-800 dark:text-green-200">
Offer Accepted
</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
Settlement terms have been agreed upon.
</div>
</>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
<Separator className="my-4" />
{/* Summary */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="text-gray-600 dark:text-gray-300">
Total Messages: {messages.length}
</span>
<span className="text-gray-600 dark:text-gray-300">
Negotiation Round: {debt.negotiation_round || 1}
</span>
</div>
{debt.last_message_at && (
<span className="text-gray-500 dark:text-gray-400">
Last Activity: {formatDate(debt.last_message_at)}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,16 +1,10 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { supabase, type Debt, type UserProfile } from "../lib/supabase";
import { Button } from "./ui/button";
import { DebtCard } from "./DebtCard";
import { DebtTimeline } from "./DebtTimeline";
import { ConversationTimeline } from "./ConversationTimeline";
import { OnboardingDialog } from "./OnboardingDialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { Badge } from "./ui/badge";
import { Separator } from "./ui/separator";
@@ -21,10 +15,9 @@ import {
CheckCircle,
AlertTriangle,
RefreshCw,
BarChart3,
LogOut,
Settings,
} from "lucide-react";
import { formatCurrency } from "../lib/utils";
export function Dashboard() {
const [debts, setDebts] = useState<Debt[]>([]);
@@ -130,10 +123,13 @@ export function Dashboard() {
const calculateStats = () => {
const totalDebts = debts.length;
const totalAmount = debts.reduce((sum, debt) => sum + debt.amount, 0);
const projectedSavings = debts.reduce(
(sum, debt) => sum + debt.projected_savings,
0
);
const projectedSavings = debts.reduce((sum, debt) => {
// Use actual savings for accepted debts, projected for others
if (debt.status === "accepted" && debt.metadata?.actualSavings?.amount) {
return sum + debt.metadata.actualSavings.amount;
}
return sum + debt.projected_savings;
}, 0);
const settledCount = debts.filter(
(debt) => debt.status === "settled"
).length;
@@ -157,23 +153,22 @@ export function Dashboard() {
window.location.href = "/";
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const groupedDebts = {
all: debts,
active: debts.filter((debt) =>
["received", "negotiating"].includes(debt.status)
[
"received",
"negotiating",
"approved",
"awaiting_response",
"counter_negotiating",
].includes(debt.status)
),
settled: debts.filter((debt) =>
["settled", "approved", "sent"].includes(debt.status)
["settled", "accepted", "sent"].includes(debt.status)
),
failed: debts.filter((debt) =>
["failed", "opted_out"].includes(debt.status)
["failed", "rejected", "opted_out"].includes(debt.status)
),
};
@@ -318,11 +313,14 @@ export function Dashboard() {
{debtList.map((debt) => (
<div key={debt.id} className="space-y-4">
<DebtCard debt={debt} onUpdate={fetchDebts} />
<Card className="bg-gray-50 dark:bg-gray-800/50">
<CardContent className="p-4">
<DebtTimeline debt={debt} />
</CardContent>
</Card>
<ConversationTimeline
debt={debt}
onDebtUpdate={(debt) => {
setDebts(
debts.map((d) => (d.id === debt.id ? debt : d))
);
}}
/>
</div>
))}
</div>

View File

@@ -13,6 +13,7 @@ import { Textarea } from "./ui/textarea";
import { Label } from "./ui/label";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -45,6 +46,7 @@ import {
} from "lucide-react";
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
import { toast } from "sonner";
import { formatCurrency } from "../lib/utils";
interface DebtCardProps {
debt: Debt;
@@ -59,6 +61,14 @@ const statusColors = {
approved:
"bg-teal-100 text-teal-800 border-teal-200 dark:bg-teal-900/20 dark:text-teal-300 dark:border-teal-800",
sent: "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800",
awaiting_response:
"bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800",
counter_negotiating:
"bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800",
accepted:
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
rejected:
"bg-red-100 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800",
settled:
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
failed:
@@ -72,6 +82,10 @@ const statusLabels = {
negotiating: "Negotiating",
approved: "Approved",
sent: "Sent",
awaiting_response: "Awaiting Response",
counter_negotiating: "Counter Negotiating",
accepted: "Accepted",
rejected: "Rejected",
settled: "Settled",
failed: "Failed",
opted_out: "Opted Out",
@@ -81,21 +95,20 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
const [isApproving, setIsApproving] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const [userProfile, setUserProfile] = useState<any>(null);
const [hasServerToken, setHasServerToken] = useState(false);
const [hasServerToken, setHasServerToken] = useState<boolean | undefined>(
undefined
);
const isReadOnly =
debt.status === "approved" ||
debt.status === "sent" ||
debt.status === "awaiting_response" ||
debt.status === "accepted" ||
debt.status === "rejected" ||
debt.status === "settled" ||
debt.status === "failed" ||
debt.status === "opted_out";
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
@@ -466,9 +479,11 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
{/* Action Buttons */}
{!isReadOnly && (
<div className="flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<DialogClose>
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
</DialogClose>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
@@ -663,8 +678,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-gray-500 dark:text-gray-400" />
<div className="flex items-center gap-0.5">
<DollarSign className="h-5 w-5 text-gray-500 dark:text-gray-400 my-auto" />
<span className="text-2xl font-bold text-gray-900 dark:text-foreground">
{formatCurrency(debt.amount)}
</span>
@@ -710,7 +725,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
{/* Approve/Reject Buttons */}
{showApproveRejectButtons() && (
<div className="space-y-2">
{!hasServerToken && (
{hasServerToken === false && (
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-700 dark:text-amber-300 flex-1">
@@ -721,7 +736,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
variant="outline"
size="sm"
onClick={() => (window.location.href = "/configuration")}
className="text-amber-700 border-amber-300 hover:bg-amber-100"
className="text-amber-700 dark:text-amber-500 border-amber-300"
>
<ExternalLink className="h-3 w-3 mr-1" />
Settings
@@ -757,7 +772,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleApprove()}
className="bg-green-600 hover:bg-green-700"
className="bg-green-600 hover:bg-green-700 text-white"
>
Send Email
</AlertDialogAction>

View File

@@ -1,26 +1,83 @@
import React from "react";
import React, { useState, useEffect } from "react";
import {
CheckCircle,
Clock,
AlertCircle,
XCircle,
StopCircle,
Send,
MessageSquare,
ThumbsUp,
ThumbsDown,
RefreshCw,
} from "lucide-react";
import type { Debt } from "../lib/supabase";
import { createClient } from "@supabase/supabase-js";
interface DebtTimelineProps {
debt: Debt;
}
const timelineSteps = [
{ key: "received", label: "Email Received", icon: CheckCircle },
{ key: "negotiating", label: "Negotiating", icon: Clock },
{ key: "settled", label: "Settled", icon: CheckCircle },
];
interface ConversationMessage {
id: string;
message_type: string;
direction: "inbound" | "outbound";
subject: string;
body: string;
from_email: string;
to_email: string;
ai_analysis?: any;
created_at: string;
}
const getTimelineSteps = (debt: Debt) => {
const baseSteps = [
{ key: "received", label: "Debt Email Received", icon: MessageSquare },
{ key: "negotiating", label: "AI Response Generated", icon: Clock },
{ key: "approved", label: "Response Approved", icon: CheckCircle },
{ key: "sent", label: "Negotiation Email Sent", icon: Send },
];
// Add dynamic steps based on conversation
if (debt.status === "counter_negotiating" || debt.negotiation_round > 1) {
baseSteps.push({
key: "counter_negotiating",
label: "Counter-Negotiating",
icon: RefreshCw,
});
}
if (debt.status === "accepted" || debt.status === "settled") {
baseSteps.push({
key: "accepted",
label: "Offer Accepted",
icon: ThumbsUp,
});
baseSteps.push({
key: "settled",
label: "Debt Settled",
icon: CheckCircle,
});
} else if (debt.status === "rejected") {
baseSteps.push({
key: "rejected",
label: "Offer Rejected",
icon: ThumbsDown,
});
}
return baseSteps;
};
const statusIcons = {
received: CheckCircle,
received: MessageSquare,
negotiating: Clock,
approved: CheckCircle,
sent: Send,
awaiting_response: Clock,
counter_negotiating: RefreshCw,
accepted: ThumbsUp,
rejected: ThumbsDown,
settled: CheckCircle,
failed: XCircle,
opted_out: StopCircle,
@@ -29,20 +86,139 @@ const statusIcons = {
const statusColors = {
received: "text-blue-600 dark:text-blue-400",
negotiating: "text-yellow-600 dark:text-yellow-400",
approved: "text-green-600 dark:text-green-400",
sent: "text-purple-600 dark:text-purple-400",
awaiting_response: "text-orange-600 dark:text-orange-400",
counter_negotiating: "text-indigo-600 dark:text-indigo-400",
accepted: "text-green-600 dark:text-green-400",
rejected: "text-red-600 dark:text-red-400",
settled: "text-green-600 dark:text-green-400",
failed: "text-red-600 dark:text-red-400",
opted_out: "text-gray-600 dark:text-gray-400",
};
export function DebtTimeline({ debt }: DebtTimelineProps) {
const [conversationMessages, setConversationMessages] = useState<
ConversationMessage[]
>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchConversationMessages = async () => {
try {
const supabase = createClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
);
const { data, error } = await supabase
.from("conversation_messages")
.select("*")
.eq("debt_id", debt.id)
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching conversation messages:", error);
} else {
setConversationMessages(data || []);
}
} catch (error) {
console.error("Error:", error);
} finally {
setLoading(false);
}
};
fetchConversationMessages();
}, [debt.id]);
const timelineSteps = getTimelineSteps(debt);
const currentStepIndex = timelineSteps.findIndex(
(step) => step.key === debt.status
);
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Progress Timeline</h3>
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return {
date: date.toLocaleDateString(),
time: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
};
};
const getStatusDescription = (status: string, round?: number) => {
switch (status) {
case "received":
return "Debt collection email received and parsed";
case "negotiating":
return "AI analyzing debt and generating negotiation strategy";
case "approved":
return "Negotiation response approved and ready to send";
case "sent":
return "Negotiation email sent to creditor";
case "awaiting_response":
return "Waiting for creditor's response";
case "counter_negotiating":
return `Round ${round || 1} - Analyzing creditor's counter-offer`;
case "accepted":
return "Creditor accepted the negotiation terms";
case "rejected":
return "Creditor rejected the offer - manual review needed";
case "settled":
return "Debt successfully settled with agreed terms";
case "failed":
return "Negotiation failed - escalated for manual handling";
default:
return "Processing...";
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Negotiation Timeline</h3>
<div className="text-sm text-gray-500 dark:text-gray-400">
Round {debt.negotiation_round || 1} {debt.conversation_count || 0}{" "}
messages
</div>
</div>
{/* Current Status Overview */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-full ${statusColors[
debt.status as keyof typeof statusColors
]
?.replace("text-", "bg-")
.replace("dark:text-", "dark:bg-")} bg-opacity-20`}
>
{React.createElement(
statusIcons[debt.status as keyof typeof statusIcons] || Clock,
{
className: `h-5 w-5 ${
statusColors[debt.status as keyof typeof statusColors]
}`,
}
)}
</div>
<div>
<div className="font-medium capitalize">
{debt.status.replace("_", " ")}
</div>
<div className="text-sm text-gray-600 dark:text-gray-300">
{getStatusDescription(debt.status, debt.negotiation_round)}
</div>
{debt.last_message_at && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Last updated: {formatDateTime(debt.last_message_at).date} at{" "}
{formatDateTime(debt.last_message_at).time}
</div>
)}
</div>
</div>
</div>
{/* Timeline Steps */}
<div className="space-y-4">
{timelineSteps.map((step, index) => {
const isCompleted = index <= currentStepIndex;
@@ -50,90 +226,138 @@ export function DebtTimeline({ debt }: DebtTimelineProps) {
const Icon = step.icon;
return (
<div key={step.key} className="flex items-center gap-3">
<div key={step.key} className="flex items-start gap-3">
<div
className={`
flex items-center justify-center w-8 h-8 rounded-full border-2
${
isCompleted
? "bg-primary border-primary text-white"
: "border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600"
}
${isActive ? "ring-2 ring-primary/20" : ""}
`}
flex items-center justify-center w-8 h-8 rounded-full border-2 mt-1
${
isCompleted
? "bg-primary border-primary text-white"
: "border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600"
}
${isActive ? "ring-2 ring-primary/20" : ""}
`}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div
className={`
font-medium
${
isCompleted
? "text-gray-900 dark:text-foreground"
: "text-gray-400 dark:text-gray-500"
}
`}
font-medium
${
isCompleted
? "text-gray-900 dark:text-foreground"
: "text-gray-400 dark:text-gray-500"
}
`}
>
{step.label}
</div>
{isActive && (
<div className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{debt.status === "received" &&
"Processing email and generating response..."}
{debt.status === "negotiating" &&
"Response generated, waiting for creditor reply"}
{debt.status === "settled" && "Payment plan agreed upon"}
{getStatusDescription(debt.status, debt.negotiation_round)}
</div>
)}
</div>
{isActive && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(debt.updated_at).toLocaleDateString()}
{isCompleted && debt.updated_at && (
<div className="text-xs text-gray-500 dark:text-gray-400 text-right">
<div>{formatDateTime(debt.updated_at).date}</div>
<div>{formatDateTime(debt.updated_at).time}</div>
</div>
)}
</div>
);
})}
{/* Special cases for failed and opted_out */}
{(debt.status === "failed" || debt.status === "opted_out") && (
<div className="flex items-center gap-3">
<div
className={`
flex items-center justify-center w-8 h-8 rounded-full border-2
${
debt.status === "failed"
? "border-red-500 text-red-500 dark:border-red-400 dark:text-red-400"
: "border-gray-500 text-gray-500 dark:border-gray-400 dark:text-gray-400"
}
`}
>
{React.createElement(statusIcons[debt.status], {
className: "h-4 w-4",
})}
</div>
<div className="flex-1">
<div className={`font-medium ${statusColors[debt.status]}`}>
{debt.status === "failed" ? "Negotiation Failed" : "Opted Out"}
</div>
<div className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{debt.status === "failed"
? "Creditor declined the negotiation proposal"
: "User requested to stop communication"}
</div>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(debt.updated_at).toLocaleDateString()}
</div>
</div>
)}
</div>
{/* Conversation Messages */}
{!loading && conversationMessages.length > 0 && (
<div className="space-y-4">
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300">
Conversation History
</h4>
<div className="space-y-3">
{conversationMessages.map((message) => {
const isOutbound = message.direction === "outbound";
const datetime = formatDateTime(message.created_at);
return (
<div
key={message.id}
className={`flex ${
isOutbound ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
isOutbound
? "bg-primary text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100"
}`}
>
<div className="text-xs opacity-75 mb-1">
{isOutbound ? "You" : "Creditor"} {datetime.date}{" "}
{datetime.time}
</div>
<div className="text-sm font-medium mb-1">
{message.subject}
</div>
<div className="text-sm opacity-90">
{message.body.length > 100
? `${message.body.substring(0, 100)}...`
: message.body}
</div>
{message.ai_analysis && (
<div className="text-xs mt-2 opacity-75">
AI: {message.ai_analysis.intent || "Analyzed"}
{message.ai_analysis.confidence &&
` (${Math.round(
message.ai_analysis.confidence * 100
)}% confidence)`}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Savings Summary */}
{(debt.projected_savings > 0 ||
(debt.actual_savings && debt.actual_savings > 0)) && (
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<h4 className="text-md font-medium text-green-800 dark:text-green-200 mb-2">
Savings Summary
</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
{debt.projected_savings > 0 && (
<div>
<div className="text-green-600 dark:text-green-400 font-medium">
Projected Savings
</div>
<div className="text-green-800 dark:text-green-200">
${debt.projected_savings.toFixed(2)}
</div>
</div>
)}
{debt.actual_savings && debt.actual_savings > 0 && (
<div>
<div className="text-green-600 dark:text-green-400 font-medium">
Actual Savings
</div>
<div className="text-green-800 dark:text-green-200">
${debt.actual_savings.toFixed(2)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,212 @@
import { useState } from "react";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Badge } from "./ui/badge";
import { toast } from "sonner";
import { TestTube, Loader2 } from "lucide-react";
export function ExtractionTester() {
const [testEmail, setTestEmail] = useState(`Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments.`);
const [result, setResult] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false);
const testExtraction = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/supabase/functions/test-extraction', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ testEmail }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setResult(data);
if (data.success) {
toast.success("Extraction Test Complete", {
description: `Found ${Object.keys(data.extractedTerms || {}).length} extracted terms`,
});
} else {
toast.error("Extraction Test Failed", {
description: data.error || "Unknown error",
});
}
} catch (error) {
console.error("Error testing extraction:", error);
toast.error("Test Failed", {
description: "Failed to test extraction",
});
} finally {
setIsLoading(false);
}
};
const presetEmails = [
{
name: "Payment Plan",
content: "Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments."
},
{
name: "Settlement Offer",
content: "We are pleased to accept your proposed settlement offer of $3,200. We will process the settlement as discussed and consider this matter resolved upon receipt of payment."
},
{
name: "Counter Offer",
content: "Thank you for your proposal. However, we can only accept a settlement of $4,000 or a payment plan of $300 per month for 15 months. Please let us know which option works better for you."
},
{
name: "Rejection",
content: "Thank you for your payment proposal. Unfortunately, we cannot accept the terms you have proposed. We require the full amount of $5,000 to be paid within 30 days."
}
];
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TestTube className="h-5 w-5" />
AI Extraction Tester
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Preset Emails */}
<div>
<label className="text-sm font-medium mb-2 block">Preset Test Emails:</label>
<div className="flex flex-wrap gap-2">
{presetEmails.map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
onClick={() => setTestEmail(preset.content)}
>
{preset.name}
</Button>
))}
</div>
</div>
{/* Email Input */}
<div>
<label className="text-sm font-medium mb-2 block">Test Email Content:</label>
<Textarea
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="Enter email content to test extraction..."
rows={4}
className="w-full"
/>
</div>
{/* Test Button */}
<Button
onClick={testExtraction}
disabled={isLoading || !testEmail.trim()}
className="w-full"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Testing Extraction...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test AI Extraction
</>
)}
</Button>
{/* Results */}
{result && (
<div className="space-y-4">
<div className="border-t pt-4">
<h3 className="font-medium mb-2">Extraction Results:</h3>
{/* Intent & Confidence */}
<div className="flex items-center gap-2 mb-3">
<Badge variant={result.analysis?.intent === "acceptance" ? "default" : "secondary"}>
Intent: {result.analysis?.intent}
</Badge>
<Badge variant="outline">
Confidence: {Math.round((result.analysis?.confidence || 0) * 100)}%
</Badge>
<Badge variant="outline">
Sentiment: {result.analysis?.sentiment}
</Badge>
</div>
{/* Extracted Terms */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h4 className="font-medium mb-2">Extracted Terms:</h4>
<pre className="text-sm overflow-auto">
{JSON.stringify(result.extractedTerms, null, 2)}
</pre>
</div>
{/* Payment Terms Detail */}
{result.extractedTerms?.paymentTerms && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h4 className="font-medium mb-2">Payment Plan Details:</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
{result.extractedTerms.paymentTerms.monthlyAmount && (
<div>
<span className="text-gray-600 dark:text-gray-400">Monthly:</span>
<div className="font-medium">${result.extractedTerms.paymentTerms.monthlyAmount}</div>
</div>
)}
{result.extractedTerms.paymentTerms.numberOfPayments && (
<div>
<span className="text-gray-600 dark:text-gray-400">Payments:</span>
<div className="font-medium">{result.extractedTerms.paymentTerms.numberOfPayments}</div>
</div>
)}
{result.extractedTerms.paymentTerms.totalAmount && (
<div>
<span className="text-gray-600 dark:text-gray-400">Total:</span>
<div className="font-medium">${result.extractedTerms.paymentTerms.totalAmount}</div>
</div>
)}
{result.extractedTerms.paymentTerms.paymentFrequency && (
<div>
<span className="text-gray-600 dark:text-gray-400">Frequency:</span>
<div className="font-medium">{result.extractedTerms.paymentTerms.paymentFrequency}</div>
</div>
)}
</div>
</div>
)}
{/* Proposed Amount */}
{result.extractedTerms?.proposedAmount && (
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<h4 className="font-medium mb-2">Settlement Amount:</h4>
<div className="text-lg font-bold text-green-600">
${result.extractedTerms.proposedAmount}
</div>
</div>
)}
{/* Reasoning */}
{result.analysis?.reasoning && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h4 className="font-medium mb-2">AI Reasoning:</h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
{result.analysis.reasoning}
</p>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import { supabase } from "../lib/supabase";
import { Button } from "./ui/button";
import { toast } from "sonner";
import { MessageSquare } from "lucide-react";
interface RealtimeTestButtonProps {
debtId: string;
}
export function RealtimeTestButton({ debtId }: RealtimeTestButtonProps) {
const [isSimulating, setIsSimulating] = useState(false);
const simulateIncomingMessage = async () => {
setIsSimulating(true);
try {
// Randomly choose between principal reduction and payment restructuring
const isPaymentPlan = Math.random() > 0.5;
const testMessage = isPaymentPlan ? {
// Payment restructuring scenario
debt_id: debtId,
message_type: "acceptance",
direction: "inbound",
subject: "Re: Payment Arrangement Request - Payment Plan Approved",
body: "Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments while providing you with extended terms.",
from_email: "collections@testcreditor.com",
to_email: "user@example.com",
ai_analysis: {
intent: "acceptance",
sentiment: "positive",
confidence: 0.92,
extractedTerms: {
proposedAmount: 4500,
proposedPaymentPlan: "monthly payment plan",
paymentTerms: {
monthlyAmount: 250,
numberOfPayments: 18,
totalAmount: 4500,
paymentFrequency: "monthly",
interestRate: 0
},
deadline: null,
conditions: [
"structured payment plan",
"manageable monthly payments",
"extended terms"
],
},
reasoning: "Creditor has accepted a payment restructuring plan with extended terms",
suggestedNextAction: "mark_settled",
requiresUserReview: false,
},
} : {
// Principal reduction scenario
debt_id: debtId,
message_type: "acceptance",
direction: "inbound",
subject: "Re: Payment Arrangement Request - Settlement Accepted",
body: "Thank you for your payment arrangement request. We are pleased to accept your proposed settlement offer of $3,200. We will process the settlement as discussed and consider this matter resolved upon receipt of payment.",
from_email: "collections@testcreditor.com",
to_email: "user@example.com",
ai_analysis: {
intent: "acceptance",
sentiment: "positive",
confidence: 0.95,
extractedTerms: {
proposedAmount: 3200,
proposedPaymentPlan: "lump sum settlement",
deadline: null,
conditions: [
"settlement accepted",
"matter resolved upon payment",
],
},
reasoning: "Creditor has accepted the proposed settlement offer with principal reduction",
suggestedNextAction: "mark_settled",
requiresUserReview: false,
},
};
const { error } = await supabase
.from("conversation_messages")
.insert(testMessage);
if (error) {
throw error;
}
toast.success(
isPaymentPlan ? "🏦 Payment Plan Simulated" : "🎉 Settlement Simulated",
{
description: isPaymentPlan
? "A payment restructuring scenario has been simulated"
: "A principal reduction scenario has been simulated",
}
);
} catch (error) {
console.error("Error simulating message:", error);
toast.error("Simulation Failed", {
description: "Failed to simulate incoming message",
});
} finally {
setIsSimulating(false);
}
};
return (
<Button
variant="outline"
size="sm"
onClick={simulateIncomingMessage}
disabled={isSimulating}
className="flex items-center gap-2"
>
<MessageSquare className="h-4 w-4" />
{isSimulating ? "Simulating..." : "Test Real-time"}
</Button>
);
}