Release (Stable version)
This commit is contained in:
@@ -26,7 +26,52 @@ import {
|
||||
saveContract as savePendingContract,
|
||||
} from "@/lib/services/contract.service";
|
||||
import { AIService } from "@/lib/services/ai.service";
|
||||
import { RAGService } from "@/lib/services/rag.service";
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types";
|
||||
|
||||
type ContractListItem = Awaited<
|
||||
ReturnType<typeof ContractService.getAll>
|
||||
>[number] & {
|
||||
_count?: { ragChunks?: number | null };
|
||||
};
|
||||
|
||||
type AnalysisWithMeta = NormalizedAnalysis & {
|
||||
language?: string | null;
|
||||
keyPeople?: Array<{
|
||||
name: string;
|
||||
role?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
}>;
|
||||
contactInfo?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
role?: string | null;
|
||||
};
|
||||
importantContacts?: Array<{
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
role?: string | null;
|
||||
}>;
|
||||
relevantDates?: Array<{
|
||||
date: string;
|
||||
description: string;
|
||||
type: "EXPIRATION" | "RENEWAL" | "PAYMENT" | "REVIEW" | "OTHER";
|
||||
}>;
|
||||
premiumCurrency?: string | null;
|
||||
};
|
||||
|
||||
type ContractKeyPoints = {
|
||||
aiMeta?: {
|
||||
language?: string | null;
|
||||
premiumCurrency?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a new contract after UploadThing upload
|
||||
@@ -70,7 +115,7 @@ export async function saveContract(data: {
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "📄 Contract Uploaded",
|
||||
message: `"${data.fileName}" has been uploaded successfully. Click "Analyze" to extract contract details.`,
|
||||
message: `"${data.fileName}" has been uploaded successfully. AI analysis started automatically.`,
|
||||
contractId: result.contract.id,
|
||||
actionType: "UPLOAD_SUCCESS",
|
||||
icon: "FileCheck",
|
||||
@@ -78,6 +123,20 @@ export async function saveContract(data: {
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-run AI analysis immediately after upload.
|
||||
const autoAnalysis = await analyzeContractAction(result.contract.id);
|
||||
|
||||
if (!autoAnalysis.success) {
|
||||
return {
|
||||
success: true,
|
||||
contract: result.contract,
|
||||
analysisSuccess: false,
|
||||
analysisError:
|
||||
autoAnalysis.error || "Contract uploaded but AI analysis failed.",
|
||||
errorCode: (autoAnalysis as { errorCode?: string }).errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
@@ -114,7 +173,7 @@ export async function getContracts(filters?: Record<string, unknown>) {
|
||||
const contracts = await ContractService.getAll(filters);
|
||||
|
||||
// Serialize contracts: convert Decimal to number, dates to ISO strings
|
||||
const serializedContracts = contracts.map((contract: any) => ({
|
||||
const serializedContracts = contracts.map((contract: ContractListItem) => ({
|
||||
id: contract.id,
|
||||
fileName: contract.fileName,
|
||||
fileSize: contract.fileSize,
|
||||
@@ -135,6 +194,8 @@ export async function getContracts(filters?: Record<string, unknown>) {
|
||||
summary: contract.summary || null,
|
||||
keyPoints: contract.keyPoints || null,
|
||||
extractedText: contract.extractedText || null,
|
||||
ragChunkCount: Number(contract?._count?.ragChunks ?? 0),
|
||||
isRagged: Number(contract?._count?.ragChunks ?? 0) > 0,
|
||||
}));
|
||||
|
||||
return { success: true, contracts: serializedContracts };
|
||||
@@ -237,6 +298,47 @@ export async function deleteContract(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllContractsAction() {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
if (!user) {
|
||||
return { success: false, error: "User not found" };
|
||||
}
|
||||
|
||||
const deletedCount = await ContractService.deleteAllForUser(user.id);
|
||||
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "🧹 Contracts Cleared",
|
||||
message: `All contracts were deleted successfully (${deletedCount}).`,
|
||||
actionType: "DELETE_ALL_SUCCESS",
|
||||
icon: "Trash2",
|
||||
expiresIn: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount,
|
||||
message: "All contracts deleted successfully",
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Delete all contracts error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves dashboard statistics for the authenticated user
|
||||
*
|
||||
@@ -361,15 +463,16 @@ export async function analyzeContractAction(id: string) {
|
||||
|
||||
// Persist AI learning metadata inside keyPoints JSON so future analyses can adapt
|
||||
// without requiring DB schema changes.
|
||||
const aiAnalysis = aiResults as AnalysisWithMeta;
|
||||
const keyPointsWithLearning = {
|
||||
...(aiResults.keyPoints ?? {}),
|
||||
aiMeta: {
|
||||
language: (aiResults as any).language ?? null,
|
||||
keyPeople: (aiResults as any).keyPeople ?? [],
|
||||
contactInfo: (aiResults as any).contactInfo ?? null,
|
||||
importantContacts: (aiResults as any).importantContacts ?? [],
|
||||
relevantDates: (aiResults as any).relevantDates ?? [],
|
||||
premiumCurrency: (aiResults as any).premiumCurrency ?? null,
|
||||
language: aiAnalysis.language ?? null,
|
||||
keyPeople: aiAnalysis.keyPeople ?? [],
|
||||
contactInfo: aiAnalysis.contactInfo ?? null,
|
||||
importantContacts: aiAnalysis.importantContacts ?? [],
|
||||
relevantDates: aiAnalysis.relevantDates ?? [],
|
||||
premiumCurrency: aiAnalysis.premiumCurrency ?? null,
|
||||
learnedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
@@ -385,6 +488,14 @@ export async function analyzeContractAction(id: string) {
|
||||
premium: aiResults.premium ?? undefined,
|
||||
});
|
||||
|
||||
// Build persistent RAG chunks for grounded contract Q&A.
|
||||
await RAGService.upsertContractChunks({
|
||||
contractId: id,
|
||||
extractedText: aiResults.extractedText,
|
||||
summary: aiResults.summary,
|
||||
keyPoints: keyPointsWithLearning,
|
||||
});
|
||||
|
||||
// Create success notification with extracted info
|
||||
const contractTitle = aiResults.title || "Contract";
|
||||
const contractProvider = aiResults.provider || "Unknown Provider";
|
||||
@@ -425,7 +536,6 @@ export async function analyzeContractAction(id: string) {
|
||||
|
||||
// Create error notification
|
||||
if (user) {
|
||||
const contract = await ContractService.getById(id);
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "ERROR",
|
||||
@@ -515,10 +625,18 @@ export async function askContractQuestionAction(id: string, question: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const ragDiagnostics = await RAGService.retrieveRelevantChunks({
|
||||
contractId: contract.id,
|
||||
question: trimmedQuestion,
|
||||
topK: 6,
|
||||
});
|
||||
|
||||
// Ask AI about contract with full context
|
||||
const answer = await AIService.askAboutContract({
|
||||
question: trimmedQuestion,
|
||||
ragChunks: ragDiagnostics,
|
||||
contract: {
|
||||
id: contract.id,
|
||||
fileName: contract.fileName,
|
||||
title: contract.title,
|
||||
type: contract.type,
|
||||
@@ -533,11 +651,21 @@ export async function askContractQuestionAction(id: string, question: string) {
|
||||
keyPoints:
|
||||
(contract.keyPoints as Record<string, unknown> | null) ?? null,
|
||||
extractedText: contract.extractedText,
|
||||
language: (contract.keyPoints as any)?.aiMeta?.language ?? null,
|
||||
language:
|
||||
(contract.keyPoints as ContractKeyPoints | null)?.aiMeta?.language ??
|
||||
null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, answer };
|
||||
return {
|
||||
success: true,
|
||||
answer,
|
||||
ragDiagnostics: ragDiagnostics.map((chunk) => ({
|
||||
chunkIndex: chunk.chunkIndex,
|
||||
score: Number(chunk.score.toFixed(4)),
|
||||
preview: chunk.content.slice(0, 280),
|
||||
})),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Ask contract question error:", error);
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UploadDropzone } from "@uploadthing/react";
|
||||
import { AlertCircle, Sparkles, Wand2, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Wand2,
|
||||
ShieldCheck,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { saveContract } from "@/features/contracts/api/contract.action";
|
||||
import { toast } from "sonner";
|
||||
@@ -14,6 +21,7 @@ export function ContractUploadForm({
|
||||
onUploadSuccess: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isAutoAnalyzing, setIsAutoAnalyzing] = useState(false);
|
||||
|
||||
const emitNotificationRefresh = () => {
|
||||
window.dispatchEvent(new Event("notifications:refresh"));
|
||||
@@ -77,25 +85,50 @@ export function ContractUploadForm({
|
||||
}
|
||||
|
||||
const file = res[0];
|
||||
setIsAutoAnalyzing(true);
|
||||
|
||||
// Save to database
|
||||
const result = await saveContract({
|
||||
fileName: file.name,
|
||||
fileUrl: file.url,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
});
|
||||
try {
|
||||
// Save to database
|
||||
const result = await saveContract({
|
||||
fileName: file.name,
|
||||
fileUrl: file.url,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Contract uploaded successfully!");
|
||||
emitNotificationRefresh();
|
||||
onUploadSuccess();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to save contract");
|
||||
if (result.success) {
|
||||
if (
|
||||
(result as { analysisSuccess?: boolean }).analysisSuccess ===
|
||||
false
|
||||
) {
|
||||
toast.warning(
|
||||
(result as { analysisError?: string }).analysisError ||
|
||||
"Contract uploaded, but analysis failed.",
|
||||
);
|
||||
} else {
|
||||
toast.success("Contract uploaded and analyzed successfully!");
|
||||
}
|
||||
|
||||
emitNotificationRefresh();
|
||||
onUploadSuccess();
|
||||
router.refresh();
|
||||
} else {
|
||||
const fallbackError =
|
||||
"error" in result ? result.error : "Failed to save contract";
|
||||
toast.error(fallbackError);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unexpected error during analysis",
|
||||
);
|
||||
} finally {
|
||||
setIsAutoAnalyzing(false);
|
||||
}
|
||||
}}
|
||||
onUploadError={(error: Error) => {
|
||||
setIsAutoAnalyzing(false);
|
||||
toast.error(`Upload failed: ${error.message}`);
|
||||
}}
|
||||
appearance={{
|
||||
@@ -126,7 +159,7 @@ export function ContractUploadForm({
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground">AI Flow</div>
|
||||
<div>Upload first, then click Analyze when ready</div>
|
||||
<div>Upload starts instant AI analysis + RAG indexing</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,6 +169,53 @@ export function ContractUploadForm({
|
||||
Extraction quality improves as more contracts are analyzed.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAutoAnalyzing && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="mx-4 max-w-md rounded-3xl border border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.22),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.16),transparent_45%),hsl(var(--background))] p-8 shadow-2xl md:p-10 zoom-in-95 animate-in duration-300">
|
||||
<div className="flex flex-col items-center text-center space-y-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-primary/30 blur-xl animate-pulse"></div>
|
||||
<div className="relative rounded-full bg-gradient-to-br from-primary to-accent p-4">
|
||||
<Sparkles className="h-8 w-8 animate-pulse text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Loader2 className="h-11 w-11 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
Analyzing And Building RAG
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your contract is being analyzed and indexed for chat...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Processing</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:-0.3s]"></span>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:-0.15s]"></span>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full w-full rounded-full bg-gradient-to-r from-primary to-accent animate-progress-loading origin-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
This may take up to 10 seconds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,24 +2,21 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Scale,
|
||||
Briefcase,
|
||||
User,
|
||||
Bot,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Search,
|
||||
Info,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -50,12 +47,12 @@ import {
|
||||
import {
|
||||
deleteContract,
|
||||
getContracts,
|
||||
analyzeContractAction,
|
||||
askContractQuestionAction,
|
||||
deleteAllContractsAction,
|
||||
} from "@/features/contracts/api/contract.action";
|
||||
import { toast } from "sonner";
|
||||
import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal";
|
||||
import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal";
|
||||
import { stripMarkdown, exportToCSV, exportToPDF } from "@/features/contracts/utils/export.utils";
|
||||
|
||||
interface Contract {
|
||||
id: string;
|
||||
@@ -73,13 +70,10 @@ interface Contract {
|
||||
endDate?: string | null;
|
||||
premium?: number | null;
|
||||
summary?: string | null;
|
||||
keyPoints?: Record<string, unknown> | null;
|
||||
keyPoints?: Prisma.JsonValue | null;
|
||||
extractedText?: string | null;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
ragChunkCount?: number;
|
||||
isRagged?: boolean;
|
||||
}
|
||||
|
||||
interface ExplainabilityEntry {
|
||||
@@ -93,6 +87,22 @@ interface ExplainabilityEntry {
|
||||
};
|
||||
}
|
||||
|
||||
interface ContractKeyPoints {
|
||||
guarantees?: string[];
|
||||
exclusions?: string[];
|
||||
franchise?: string | null;
|
||||
importantDates?: string[];
|
||||
explainability?: ExplainabilityEntry[];
|
||||
aiMeta?: {
|
||||
language?: string | null;
|
||||
premiumCurrency?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const isContractKeyPoints = (value: unknown): value is ContractKeyPoints => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
const emitNotificationRefresh = () => {
|
||||
window.dispatchEvent(new Event("notifications:refresh"));
|
||||
@@ -104,18 +114,18 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
const [contracts, setContracts] = useState<Contract[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [analyzingId, setAnalyzingId] = useState<string | null>(null);
|
||||
const [isDeletingAll, setIsDeletingAll] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [selectedContract, setSelectedContract] = useState<Contract | null>(
|
||||
null,
|
||||
);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [askOpen, setAskOpen] = useState(false);
|
||||
const [chatContract, setChatContract] = useState<Contract | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [contractToDelete, setContractToDelete] = useState<Contract | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false);
|
||||
const [invalidContractDialogOpen, setInvalidContractDialogOpen] =
|
||||
useState(false);
|
||||
const [invalidContractReason, setInvalidContractReason] = useState("");
|
||||
@@ -563,11 +573,13 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
const getExplainabilityItems = (
|
||||
contract: Contract | null,
|
||||
): ExplainabilityEntry[] => {
|
||||
const raw = (contract?.keyPoints as any)?.explainability;
|
||||
const raw = isContractKeyPoints(contract?.keyPoints)
|
||||
? contract.keyPoints.explainability
|
||||
: undefined;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.map((item: any) => ({
|
||||
.map((item) => ({
|
||||
field: String(item?.field ?? "").trim(),
|
||||
why: String(item?.why ?? "").trim(),
|
||||
sourceSnippet: String(item?.sourceSnippet ?? "").trim(),
|
||||
@@ -675,7 +687,9 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
if (!contract) return null;
|
||||
|
||||
const fromMeta = String(
|
||||
(contract.keyPoints as any)?.aiMeta?.premiumCurrency ?? "",
|
||||
(isContractKeyPoints(contract.keyPoints)
|
||||
? contract.keyPoints.aiMeta?.premiumCurrency
|
||||
: null) ?? "",
|
||||
).trim();
|
||||
if (fromMeta) return fromMeta;
|
||||
|
||||
@@ -848,38 +862,26 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
setContractToDelete(null);
|
||||
};
|
||||
|
||||
const handleAnalyze = async (id: string) => {
|
||||
const selected = contracts.find((contract) => contract.id === id);
|
||||
setAnalyzingId(id);
|
||||
setIsAnalyzing(true);
|
||||
const handleDeleteAll = async () => {
|
||||
setIsDeletingAll(true);
|
||||
try {
|
||||
const result = await analyzeContractAction(id);
|
||||
const result = await deleteAllContractsAction();
|
||||
if (result.success) {
|
||||
// Reload contracts to get all AI analysis data
|
||||
await loadContracts();
|
||||
toast.success("Contract analyzed successfully!");
|
||||
setContracts([]);
|
||||
toast.success(
|
||||
`Deleted ${result.deletedCount ?? 0} contract${(result.deletedCount ?? 0) === 1 ? "" : "s"}.`,
|
||||
);
|
||||
emitNotificationRefresh();
|
||||
} else {
|
||||
const errorCode = (result as { errorCode?: string }).errorCode;
|
||||
if (errorCode === "INVALID_CONTRACT") {
|
||||
const reason =
|
||||
result.error ||
|
||||
"This uploaded file is not recognized as a valid contract.";
|
||||
setInvalidContractReason(reason);
|
||||
setInvalidContractFileName(selected?.fileName || "Unknown file");
|
||||
setInvalidContractDialogOpen(true);
|
||||
toast.error("Invalid contract file detected");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to analyze contract");
|
||||
}
|
||||
toast.error(result.error || "Failed to delete all contracts");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
);
|
||||
} finally {
|
||||
setAnalyzingId(null);
|
||||
setIsAnalyzing(false);
|
||||
setIsDeletingAll(false);
|
||||
setDeleteAllDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -994,11 +996,28 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{debouncedSearchQuery && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing results for: "{debouncedSearchQuery}"
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{debouncedSearchQuery && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing results for: "{debouncedSearchQuery}"
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={contracts.length === 0 || isDeletingAll}
|
||||
onClick={() => setDeleteAllDialogOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeletingAll ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
Delete All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50 overflow-hidden">
|
||||
@@ -1023,6 +1042,12 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
>
|
||||
{contract.status}
|
||||
</span>
|
||||
{contract.isRagged && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-cyan-700 dark:text-cyan-300">
|
||||
<Network className="h-3 w-3" />
|
||||
RAG {contract.ragChunkCount ?? 0}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground flex-wrap">
|
||||
@@ -1070,21 +1095,6 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-primary/10"
|
||||
title="Analyze with AI"
|
||||
disabled={analyzingId === contract.id}
|
||||
onClick={() => handleAnalyze(contract.id)}
|
||||
>
|
||||
{analyzingId === contract.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -1110,6 +1120,20 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportToPDF(contract as any)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Export Analysis (PDF)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportToCSV(contract as any)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4 mr-2" />
|
||||
Export Analysis (CSV)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => requestDeleteContract(contract)}
|
||||
disabled={deletingId === contract.id}
|
||||
@@ -1238,7 +1262,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner">
|
||||
{selectedContract.title || "N/A"}
|
||||
{stripMarkdown(selectedContract.title) || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30">
|
||||
@@ -1259,7 +1283,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner">
|
||||
{selectedContract.provider || "N/A"}
|
||||
{stripMarkdown(selectedContract.provider) || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30">
|
||||
@@ -1283,7 +1307,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner">
|
||||
{selectedContract.policyNumber || "N/A"}
|
||||
{stripMarkdown(selectedContract.policyNumber) || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30">
|
||||
@@ -1376,9 +1400,10 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
Key Points
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{(selectedContract.keyPoints as any)?.guarantees &&
|
||||
{isContractKeyPoints(selectedContract.keyPoints) &&
|
||||
selectedContract.keyPoints.guarantees &&
|
||||
Array.isArray(
|
||||
(selectedContract.keyPoints as any).guarantees,
|
||||
selectedContract.keyPoints.guarantees,
|
||||
) && (
|
||||
<div>
|
||||
<p className="text-muted-foreground font-medium">
|
||||
@@ -1386,9 +1411,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</p>
|
||||
<ul className="ml-1 space-y-2">
|
||||
{(
|
||||
(selectedContract.keyPoints as any)
|
||||
.guarantees as string[]
|
||||
).map((guarantee: string, idx: number) => (
|
||||
selectedContract.keyPoints.guarantees ?? []
|
||||
).map((guarantee, idx: number) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
|
||||
@@ -1402,9 +1426,10 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(selectedContract.keyPoints as any)?.exclusions &&
|
||||
{isContractKeyPoints(selectedContract.keyPoints) &&
|
||||
selectedContract.keyPoints.exclusions &&
|
||||
Array.isArray(
|
||||
(selectedContract.keyPoints as any).exclusions,
|
||||
selectedContract.keyPoints.exclusions,
|
||||
) && (
|
||||
<div>
|
||||
<p className="text-muted-foreground font-medium">
|
||||
@@ -1412,9 +1437,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</p>
|
||||
<ul className="ml-1 space-y-2">
|
||||
{(
|
||||
(selectedContract.keyPoints as any)
|
||||
.exclusions as string[]
|
||||
).map((exclusion: string, idx: number) => (
|
||||
selectedContract.keyPoints.exclusions ?? []
|
||||
).map((exclusion, idx: number) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
|
||||
@@ -1428,21 +1452,20 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(selectedContract.keyPoints as any)?.franchise && (
|
||||
<div>
|
||||
<p className="text-muted-foreground font-medium">
|
||||
Deductible:
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 whitespace-pre-wrap break-words">
|
||||
{renderRichParagraphs(
|
||||
String(
|
||||
(selectedContract.keyPoints as any).franchise,
|
||||
),
|
||||
`franchise-${selectedContract.id}`,
|
||||
)}
|
||||
{isContractKeyPoints(selectedContract.keyPoints) &&
|
||||
selectedContract.keyPoints.franchise && (
|
||||
<div>
|
||||
<p className="text-muted-foreground font-medium">
|
||||
Deductible:
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 whitespace-pre-wrap break-words">
|
||||
{renderRichParagraphs(
|
||||
String(selectedContract.keyPoints.franchise),
|
||||
`franchise-${selectedContract.id}`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1460,9 +1483,9 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
|
||||
{selectedContract.status === "UPLOADED" && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-amber-200/40 bg-amber-50/60 p-4 dark:border-amber-800/40 dark:bg-amber-950/30">
|
||||
<Sparkles className="w-5 h-5 text-amber-500" />
|
||||
<Loader2 className="w-5 h-5 text-amber-500 animate-spin" />
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
Click the Sparkles button to analyze this contract
|
||||
Contract uploaded. AI analysis will start automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1526,6 +1549,30 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteAllDialogOpen}
|
||||
onOpenChange={setDeleteAllDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete all contracts?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action permanently removes all contracts and related files
|
||||
for your account. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => void handleDeleteAll()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeletingAll ? "Deleting..." : "Delete All"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog
|
||||
open={invalidContractDialogOpen}
|
||||
onOpenChange={setInvalidContractDialogOpen}
|
||||
@@ -1573,59 +1620,6 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* AI Analysis Loading Overlay */}
|
||||
{isAnalyzing && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<div className="mx-4 max-w-md rounded-3xl border border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.22),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.16),transparent_45%),hsl(var(--background))] p-8 shadow-2xl md:p-10 zoom-in-95 animate-in duration-300">
|
||||
<div className="flex flex-col items-center text-center space-y-6">
|
||||
{/* Glow Effect */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-primary/30 blur-xl animate-pulse"></div>
|
||||
<div className="relative rounded-full bg-gradient-to-br from-primary to-accent p-4">
|
||||
<Sparkles className="h-8 w-8 animate-pulse text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spinner */}
|
||||
<div className="relative">
|
||||
<Loader2 className="h-11 w-11 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
Analyzing Contract
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Our AI is carefully reviewing your document...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Processing</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* Use inline styles for delays if they aren't in your config */}
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:-0.3s]"></span>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:-0.15s]"></span>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Moving Progress Bar */}
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full w-full rounded-full bg-gradient-to-r from-primary to-accent animate-progress-loading origin-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
This may take up to 10 seconds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MessageSquare, Briefcase, Scale, Bot, User, Loader2, Send } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
MessageSquare,
|
||||
Briefcase,
|
||||
Scale,
|
||||
Bot,
|
||||
User,
|
||||
Loader2,
|
||||
Send,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { askContractQuestionAction } from "@/features/contracts/api/contract.action";
|
||||
@@ -17,6 +31,12 @@ interface ChatMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface RagDiagnosticEntry {
|
||||
chunkIndex: number;
|
||||
score: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
interface ContractChatModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -32,10 +52,14 @@ export function ContractChatModal({
|
||||
}: ContractChatModalProps) {
|
||||
const [question, setQuestion] = useState("");
|
||||
const [isAsking, setIsAsking] = useState(false);
|
||||
const [ragDiagnostics, setRagDiagnostics] = useState<RagDiagnosticEntry[]>(
|
||||
[],
|
||||
);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Ask me anything about this contract. I will answer based on the file analysis.",
|
||||
content:
|
||||
"Ask me anything about this contract. I will answer based on the file analysis.",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -51,22 +75,45 @@ export function ContractChatModal({
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) return;
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", content: trimmedQuestion }]);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "user", content: trimmedQuestion },
|
||||
]);
|
||||
setQuestion("");
|
||||
setIsAsking(true);
|
||||
|
||||
try {
|
||||
const result = await askContractQuestionAction(contract.id, trimmedQuestion);
|
||||
const result = await askContractQuestionAction(
|
||||
contract.id,
|
||||
trimmedQuestion,
|
||||
);
|
||||
|
||||
if (result.success && result.answer) {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: result.answer as string }]);
|
||||
const diagnostics = Array.isArray(
|
||||
(result as { ragDiagnostics?: RagDiagnosticEntry[] }).ragDiagnostics,
|
||||
)
|
||||
? ((result as { ragDiagnostics?: RagDiagnosticEntry[] })
|
||||
.ragDiagnostics ?? [])
|
||||
: [];
|
||||
setRagDiagnostics(diagnostics);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: result.answer as string },
|
||||
]);
|
||||
} else {
|
||||
const errorMessage = result.error || "Failed to get AI response";
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${errorMessage}` }]);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: `Error: ${errorMessage}` },
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
const fallbackMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${fallbackMessage}` }]);
|
||||
const fallbackMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: `Error: ${fallbackMessage}` },
|
||||
]);
|
||||
} finally {
|
||||
setIsAsking(false);
|
||||
}
|
||||
@@ -90,7 +137,9 @@ export function ContractChatModal({
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Contract Intelligence Assistant
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate mt-1">{contract.fileName}</p>
|
||||
<p className="text-sm font-medium truncate mt-1">
|
||||
{contract.fileName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/30 px-2 py-1">
|
||||
@@ -124,6 +173,43 @@ export function ContractChatModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-cyan-500/20 bg-cyan-500/5 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Network className="h-4 w-4 text-cyan-600 dark:text-cyan-300" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-700 dark:text-cyan-300">
|
||||
RAG Diagnostics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{ragDiagnostics.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask a question to inspect top retrieved chunks and relevance
|
||||
scores.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ragDiagnostics.map((item) => (
|
||||
<div
|
||||
key={`${item.chunkIndex}-${item.score}`}
|
||||
className="rounded-xl border border-border/50 bg-background/70 p-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between text-[11px]">
|
||||
<span className="font-medium text-foreground">
|
||||
Chunk {item.chunkIndex}
|
||||
</span>
|
||||
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-700 dark:text-cyan-300">
|
||||
score {item.score.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{item.preview}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-80 space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-black/5 dark:bg-white/5 p-4 shadow-inner backdrop-blur-md">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
@@ -144,7 +230,10 @@ export function ContractChatModal({
|
||||
}`}
|
||||
>
|
||||
{message.role === "assistant"
|
||||
? renderRichParagraphs(message.content, `chat-assistant-${index}`)
|
||||
? renderRichParagraphs(
|
||||
message.content,
|
||||
`chat-assistant-${index}`,
|
||||
)
|
||||
: message.content}
|
||||
</div>
|
||||
{message.role === "user" && (
|
||||
@@ -177,7 +266,12 @@ export function ContractChatModal({
|
||||
disabled={isAsking}
|
||||
className="rounded-2xl border-white/20 dark:border-white/10 bg-background/50 backdrop-blur-md focus:bg-background/80 transition-all duration-300 shadow-inner"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !isAsking && question.trim()) {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!isAsking &&
|
||||
question.trim()
|
||||
) {
|
||||
event.preventDefault();
|
||||
void handleAskQuestion();
|
||||
}
|
||||
|
||||
163
features/contracts/utils/export.utils.ts
Normal file
163
features/contracts/utils/export.utils.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { type Contract, type Prisma } from "@prisma/client";
|
||||
|
||||
interface ContractKeyPoints {
|
||||
guarantees?: string[];
|
||||
exclusions?: string[];
|
||||
franchise?: string | number | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const isContractKeyPoints = (
|
||||
val: Prisma.JsonValue | null | undefined,
|
||||
): val is ContractKeyPoints => {
|
||||
if (!val || typeof val !== "object" || Array.isArray(val)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const stripMarkdown = (text: string | null | undefined): string => {
|
||||
if (!text) return "";
|
||||
// Strip ** bold tags, __ italic tags, # headers, • bullets
|
||||
return text
|
||||
.replace(/\*\*/g, "")
|
||||
.replace(/__/g, "")
|
||||
.replace(/^#+\s+/gm, "")
|
||||
.replace(/•\s+/g, "- ")
|
||||
// replace any remaining markdown stars
|
||||
.replace(/\*/g, "");
|
||||
};
|
||||
|
||||
const formatValue = (val: any): string => {
|
||||
if (val === null || val === undefined) return "N/A";
|
||||
if (val instanceof Date) return val.toLocaleDateString();
|
||||
if (Array.isArray(val)) {
|
||||
return val.map((v) => stripMarkdown(String(v))).join("\n");
|
||||
}
|
||||
return stripMarkdown(String(val));
|
||||
};
|
||||
|
||||
export const exportToCSV = (contract: Contract) => {
|
||||
let guarantees = "N/A";
|
||||
let exclusions = "N/A";
|
||||
let franchise = "N/A";
|
||||
|
||||
if (isContractKeyPoints(contract.keyPoints)) {
|
||||
if (Array.isArray(contract.keyPoints.guarantees)) {
|
||||
guarantees = contract.keyPoints.guarantees.map(stripMarkdown).join("; ");
|
||||
}
|
||||
if (Array.isArray(contract.keyPoints.exclusions)) {
|
||||
exclusions = contract.keyPoints.exclusions.map(stripMarkdown).join("; ");
|
||||
}
|
||||
if (contract.keyPoints.franchise) {
|
||||
franchise = stripMarkdown(String(contract.keyPoints.franchise));
|
||||
}
|
||||
}
|
||||
|
||||
const exportData = [
|
||||
["Field", "Value"],
|
||||
["Title", formatValue(contract.title)],
|
||||
["Provider", formatValue(contract.provider)],
|
||||
["Policy Number", formatValue(contract.policyNumber)],
|
||||
["Start Date", formatValue(contract.startDate)],
|
||||
["End Date", formatValue(contract.endDate)],
|
||||
["Status", formatValue(contract.status)],
|
||||
["Summary", formatValue(contract.summary).replace(/\n/g, " ")],
|
||||
["Guarantees", guarantees],
|
||||
["Exclusions", exclusions],
|
||||
["Deductible", franchise],
|
||||
];
|
||||
|
||||
const csvContent = exportData
|
||||
.map((row) =>
|
||||
row
|
||||
.map((cell) => {
|
||||
const stringCell = String(cell);
|
||||
if (stringCell.includes(",") || stringCell.includes("\"") || stringCell.includes("\n")) {
|
||||
return `"${stringCell.replace(/"/g, "\"\"")}"`;
|
||||
}
|
||||
return stringCell;
|
||||
})
|
||||
.join(","),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = `Analysis_${contract.fileName || "Contract"}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
};
|
||||
|
||||
export const exportToPDF = (contract: Contract) => {
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Title
|
||||
doc.setFontSize(18);
|
||||
doc.setTextColor(33, 43, 54);
|
||||
doc.text("AI Contract Analysis", 14, 22);
|
||||
|
||||
// Subtitle
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(100);
|
||||
doc.text(`Filename: ${contract.fileName}`, 14, 30);
|
||||
doc.text(`Exported: ${new Date().toLocaleDateString()}`, 14, 36);
|
||||
|
||||
let guarantees = "N/A";
|
||||
let exclusions = "N/A";
|
||||
let franchise = "N/A";
|
||||
|
||||
if (isContractKeyPoints(contract.keyPoints)) {
|
||||
if (Array.isArray(contract.keyPoints.guarantees)) {
|
||||
guarantees = contract.keyPoints.guarantees.map(stripMarkdown).join("\n• ");
|
||||
if (guarantees) guarantees = "• " + guarantees;
|
||||
}
|
||||
if (Array.isArray(contract.keyPoints.exclusions)) {
|
||||
exclusions = contract.keyPoints.exclusions.map(stripMarkdown).join("\n• ");
|
||||
if (exclusions) exclusions = "• " + exclusions;
|
||||
}
|
||||
if (contract.keyPoints.franchise) {
|
||||
franchise = stripMarkdown(String(contract.keyPoints.franchise));
|
||||
}
|
||||
}
|
||||
|
||||
const tableData = [
|
||||
["Title", formatValue(contract.title)],
|
||||
["Provider", formatValue(contract.provider)],
|
||||
["Policy Number", formatValue(contract.policyNumber)],
|
||||
["Start Date", formatValue(contract.startDate)],
|
||||
["End Date", formatValue(contract.endDate)],
|
||||
["Summary", formatValue(contract.summary)],
|
||||
["Guarantees", guarantees],
|
||||
["Exclusions", exclusions],
|
||||
["Deductible", franchise],
|
||||
];
|
||||
|
||||
autoTable(doc, {
|
||||
startY: 45,
|
||||
head: [["Information Field", "Extracted Detail"]],
|
||||
body: tableData,
|
||||
theme: "grid",
|
||||
headStyles: {
|
||||
fillColor: [30, 41, 59],
|
||||
textColor: 255,
|
||||
fontStyle: "bold",
|
||||
},
|
||||
styles: {
|
||||
fontSize: 10,
|
||||
cellPadding: 6,
|
||||
overflow: "linebreak",
|
||||
cellWidth: "wrap"
|
||||
},
|
||||
columnStyles: {
|
||||
0: { cellWidth: 40, fontStyle: "bold", textColor: [50, 50, 50] },
|
||||
1: { cellWidth: 140 }
|
||||
},
|
||||
});
|
||||
|
||||
doc.save(`Analysis_${contract.fileName || "Contract"}.pdf`);
|
||||
};
|
||||
Reference in New Issue
Block a user