mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
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:
586
src/components/ConversationTimeline.tsx
Normal file
586
src/components/ConversationTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
212
src/components/ExtractionTester.tsx
Normal file
212
src/components/ExtractionTester.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/components/RealtimeTestButton.tsx
Normal file
121
src/components/RealtimeTestButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user