Files
LexiChain/features/contracts/components/list/contracts-list.tsx

1713 lines
62 KiB
TypeScript
Raw Normal View History

2026-03-28 23:46:45 +01:00
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import type { ReactNode } from "react";
2026-04-12 19:24:24 +01:00
import type { Prisma } from "@prisma/client";
2026-03-28 23:46:45 +01:00
import {
Download,
Trash2,
Eye,
MoreVertical,
Loader2,
FileText,
2026-04-12 19:24:24 +01:00
FileSpreadsheet,
2026-03-28 23:46:45 +01:00
MessageSquare,
AlertTriangle,
X,
Search,
Info,
2026-04-12 19:24:24 +01:00
Network,
2026-05-03 13:26:31 +01:00
Shield,
Sparkles,
FileIcon,
ChevronRight,
Calendar,
HardDrive,
Tag,
FileType,
2026-03-28 23:46:45 +01:00
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
deleteContract,
getContracts,
2026-04-12 19:24:24 +01:00
deleteAllContractsAction,
2026-03-28 23:46:45 +01:00
} 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";
2026-04-19 01:42:00 +01:00
import {
stripMarkdown,
exportToCSV,
exportToPDF,
} from "@/features/contracts/utils/export.utils";
2026-05-03 13:26:31 +01:00
import { motion, AnimatePresence } from "motion/react";
2026-03-28 23:46:45 +01:00
interface Contract {
id: string;
fileName: string;
fileSize: number;
mimeType: string;
status: string;
createdAt: string; // ISO string
fileUrl: string;
title?: string | null;
type?: string | null;
provider?: string | null;
policyNumber?: string | null;
startDate?: string | null;
endDate?: string | null;
premium?: number | null;
summary?: string | null;
2026-04-12 19:24:24 +01:00
keyPoints?: Prisma.JsonValue | null;
2026-03-28 23:46:45 +01:00
extractedText?: string | null;
2026-04-12 19:24:24 +01:00
ragChunkCount?: number;
isRagged?: boolean;
2026-03-28 23:46:45 +01:00
}
interface ExplainabilityEntry {
field: string;
why: string;
sourceSnippet: string;
sourceHints?: {
page?: string | null;
section?: string | null;
confidence?: number | null;
};
}
2026-04-12 19:24:24 +01:00
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);
};
2026-03-28 23:46:45 +01:00
export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
const emitNotificationRefresh = () => {
window.dispatchEvent(new Event("notifications:refresh"));
const channel = new BroadcastChannel("notifications-channel");
channel.postMessage({ type: "notifications:refresh" });
channel.close();
};
const [contracts, setContracts] = useState<Contract[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
2026-04-12 19:24:24 +01:00
const [isDeletingAll, setIsDeletingAll] = useState(false);
2026-03-28 23:46:45 +01:00
const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedContract, setSelectedContract] = useState<Contract | null>(
null,
);
const [askOpen, setAskOpen] = useState(false);
const [chatContract, setChatContract] = useState<Contract | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [contractToDelete, setContractToDelete] = useState<Contract | null>(
null,
);
2026-04-12 19:24:24 +01:00
const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false);
2026-03-28 23:46:45 +01:00
const [invalidContractDialogOpen, setInvalidContractDialogOpen] =
useState(false);
const [invalidContractReason, setInvalidContractReason] = useState("");
const [invalidContractFileName, setInvalidContractFileName] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [proofModalOpen, setProofModalOpen] = useState(false);
const [proofData, setProofData] = useState<{
fieldKey: string;
field: string;
sourceSnippet: string;
confidence: number | null;
page: string | null;
section: string | null;
lineNumber: number | null;
contextStartLine: number | null;
context: string[];
resolutionMode: "exact" | "fuzzy" | "fallback";
} | null>(null);
const ENTITY_REGEX =
/(\*\*[^*]+\*\*)|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})|(\+?\d[\d\s().-]{7,}\d)|(\b\d{4}-\d{2}-\d{2}\b)|(\b\d{1,2}[\/.-]\d{1,2}[\/.-]\d{2,4}\b)|((?:€|\$|£)\s?\d[\d\s.,]*\d|\b\d[\d\s.,]*\d\s?(?:EUR|USD|TND|MAD|DZD|GBP)\b)|(\b\d+(?:[.,]\d+)?\s?%\b)|(\b\d{2,}(?:[.,]\d+)?\b)/g;
const renderHighlightedText = (
text: string,
keyPrefix: string,
): ReactNode[] => {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let index = 0;
while ((match = ENTITY_REGEX.exec(text)) !== null) {
const [token] = match;
const start = match.index;
if (start > lastIndex) {
nodes.push(text.slice(lastIndex, start));
}
const tokenKey = `${keyPrefix}-token-${index}`;
index += 1;
if (token.startsWith("**") && token.endsWith("**")) {
nodes.push(
<strong key={tokenKey} className="font-semibold text-foreground">
{token.slice(2, -2)}
</strong>,
);
} else if (match[2]) {
nodes.push(
<span
key={tokenKey}
className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1 py-0.5 font-medium text-emerald-700 dark:text-emerald-300"
>
{token}
</span>,
);
} else if (match[3]) {
nodes.push(
<span
key={tokenKey}
className="rounded-md border border-cyan-500/30 bg-cyan-500/10 px-1 py-0.5 font-medium text-cyan-700 dark:text-cyan-300"
>
{token}
</span>,
);
} else if (match[4] || match[5]) {
nodes.push(
<span
key={tokenKey}
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-1 py-0.5 font-medium text-amber-700 dark:text-amber-300"
>
{token}
</span>,
);
} else if (match[6] || match[7]) {
nodes.push(
<span
key={tokenKey}
className="rounded-md border border-fuchsia-500/30 bg-fuchsia-500/10 px-1 py-0.5 font-medium text-fuchsia-700 dark:text-fuchsia-300"
>
{token}
</span>,
);
} else {
nodes.push(
<span key={tokenKey} className="font-medium text-foreground">
{token}
</span>,
);
}
lastIndex = start + token.length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
};
const renderRichParagraphs = (
text: string,
keyPrefix: string,
): ReactNode[] => {
const cleaned = text.replace(/\u2022/g, "•").trim();
if (!cleaned) return [];
const lines = cleaned
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
return lines.map((line, idx) => {
const isList = /^[-*•]\s+/.test(line);
const content = line.replace(/^[-*•]\s+/, "");
return (
<div
key={`${keyPrefix}-line-${idx}`}
className={isList ? "flex items-start gap-2" : ""}
>
{isList && (
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-primary/80" />
)}
<p className="leading-7 text-muted-foreground">
{renderHighlightedText(content, `${keyPrefix}-${idx}`)}
</p>
</div>
);
});
};
// Helper: Find source snippet in extracted text and get line number + context
const findSourceSnippetInText = (
sourceSnippet: string,
extractedText: string | null | undefined,
fieldKey?: string,
fallbackNeedles?: string[],
): {
lineNumber: number | null;
contextStartLine: number | null;
context: string[];
resolutionMode: "exact" | "fuzzy" | "fallback";
} => {
if (!extractedText || !sourceSnippet) {
return {
lineNumber: null,
contextStartLine: null,
context: [],
resolutionMode: "fallback",
};
}
const normalizeForMatch = (value: string) =>
value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, " ");
const buildDateNeedles = (isoDate: string): string[] => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) return [isoDate];
const [year, month, day] = isoDate.split("-");
const monthIndex = Number(month) - 1;
const frMonths = [
"janvier",
"fevrier",
"mars",
"avril",
"mai",
"juin",
"juillet",
"aout",
"septembre",
"octobre",
"novembre",
"decembre",
];
const enMonths = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
const numericDay = String(Number(day));
const numericMonth = String(Number(month));
return [
isoDate,
`${day}/${month}/${year}`,
`${day}-${month}-${year}`,
`${numericDay}/${numericMonth}/${year}`,
`${numericDay}-${numericMonth}-${year}`,
`${numericDay} ${frMonths[monthIndex]} ${year}`,
`${numericDay} ${enMonths[monthIndex]} ${year}`,
];
};
const tokenize = (value: string) =>
normalizeForMatch(value)
.split(" ")
.filter((token) => token.length >= 3 || /^\d+$/.test(token));
const lines = extractedText.split(/\r?\n/);
const normalizedLines = lines.map((line) => normalizeForMatch(line));
const snippetNormalized = normalizeForMatch(sourceSnippet);
const snippetTokens = tokenize(sourceSnippet);
const resolvedFallbackNeedles = [...(fallbackNeedles ?? [])];
if (
(fieldKey === "startDate" || fieldKey === "endDate") &&
fallbackNeedles?.[0]
) {
resolvedFallbackNeedles.push(...buildDateNeedles(fallbackNeedles[0]));
}
const needleTokens = resolvedFallbackNeedles.flatMap((needle) =>
tokenize(needle),
);
const snippetNeedle = snippetNormalized.slice(0, 90);
const isDateField = fieldKey === "startDate" || fieldKey === "endDate";
// Stage 1: strict exact matching to avoid false positives.
const exactIndex = normalizedLines.findIndex((line) =>
snippetNeedle.length >= 25 ? line.includes(snippetNeedle) : false,
);
if (exactIndex >= 0) {
const contextStart = Math.max(0, exactIndex - 3);
const contextEnd = Math.min(lines.length, exactIndex + 4);
return {
lineNumber: exactIndex + 1,
contextStartLine: contextStart + 1,
context: lines.slice(contextStart, contextEnd),
resolutionMode: "exact",
};
}
// Stage 2: deterministic date/value lookup for date fields.
if (isDateField && resolvedFallbackNeedles.length > 0) {
const normalizedNeedles = resolvedFallbackNeedles
.map((value) => normalizeForMatch(value))
.filter((value) => value.length >= 6);
const dateCandidates = normalizedLines
.map((line, idx) => {
const needleHits = normalizedNeedles.filter((needle) =>
line.includes(needle),
).length;
if (needleHits === 0) return null;
const genericTemporalBoost =
/(effet|debut|start|commence|expiration|expire|echeance|fin|periode|garantie|validite|prend effet)/.test(
line,
)
? 15
: 0;
const fieldSpecificBoost =
fieldKey === "endDate"
? /(expiration|expire|echeance|fin|expire le|date d expiration)/.test(
line,
)
? 28
: 0
: /(prend effet|debut|commence|start date|date d effet)/.test(
line,
)
? 24
: 0;
const score =
needleHits * 40 + genericTemporalBoost + fieldSpecificBoost;
return { idx, score };
})
.filter((item): item is { idx: number; score: number } => item !== null)
.sort((a, b) => b.score - a.score);
const bestDateCandidate = dateCandidates[0];
if (bestDateCandidate) {
const contextStart = Math.max(0, bestDateCandidate.idx - 3);
const contextEnd = Math.min(lines.length, bestDateCandidate.idx + 4);
return {
lineNumber: bestDateCandidate.idx + 1,
contextStartLine: contextStart + 1,
context: lines.slice(contextStart, contextEnd),
resolutionMode: "exact",
};
}
}
const windows: { start: number; end: number; text: string }[] = [];
for (let start = 0; start < normalizedLines.length; start++) {
for (let length = 1; length <= 4; length++) {
const end = start + length;
if (end > normalizedLines.length) break;
windows.push({
start,
end,
text: normalizedLines.slice(start, end).join(" "),
});
}
}
let bestMatch: { start: number; score: number } | null = null;
const hasDateHint = isDateField;
const hasPremiumHint = fieldKey === "premium";
for (const window of windows) {
const windowTokens = window.text.split(" ").filter(Boolean);
const tokenSet = new Set(windowTokens);
let score = 0;
if (
snippetNormalized &&
window.text.includes(snippetNormalized.slice(0, 60))
) {
score += 45;
}
if (snippetTokens.length > 0) {
const overlap = snippetTokens.filter((token) =>
tokenSet.has(token),
).length;
score += (overlap / snippetTokens.length) * 40;
}
if (needleTokens.length > 0) {
const overlap = needleTokens.filter((token) =>
tokenSet.has(token),
).length;
score += (overlap / needleTokens.length) * 35;
}
if (hasDateHint) {
const containsYear = /\b(19|20)\d{2}\b/.test(window.text);
const temporalKeywords =
/(effet|debut|start|commence|expiration|expire|echeance|fin|periode|garantie|validite|prend effet)/.test(
window.text,
);
if (containsYear) score += 12;
if (temporalKeywords) score += 20;
if (
fieldKey === "endDate" &&
/(expiration|expire|echeance|fin|date d effet|expire le)/.test(
window.text,
)
) {
score += 22;
}
}
if (hasPremiumHint) {
if (
/(prime|cotisation|premium|montant|cout|cost|total)/.test(window.text)
) {
score += 18;
}
if (/(eur|usd|tnd|mad|dzd|gbp|€|\$|£)/.test(window.text)) {
score += 12;
}
}
if (!bestMatch || score > bestMatch.score) {
bestMatch = { start: window.start, score };
}
}
if (bestMatch && bestMatch.score >= 32) {
const contextStart = Math.max(0, bestMatch.start - 3);
const contextEnd = Math.min(lines.length, bestMatch.start + 4);
const context = lines.slice(contextStart, contextEnd);
return {
lineNumber: bestMatch.start + 1,
contextStartLine: contextStart + 1,
context,
resolutionMode: "fuzzy",
};
}
return {
lineNumber: null,
contextStartLine: null,
context: [],
resolutionMode: "fallback",
};
};
// Handler: Open proof modal when clicking info icon
const handleProofClick = (
fieldKey: string,
entry: ExplainabilityEntry,
fieldValueFallback?: string,
sectionHint?: string | null,
) => {
const proofHints = [fieldValueFallback, sectionHint]
.filter((hint): hint is string => Boolean(hint && hint.trim()))
.map((hint) => hint.trim());
const { lineNumber, contextStartLine, context, resolutionMode } =
findSourceSnippetInText(
entry.sourceSnippet,
selectedContract?.extractedText,
fieldKey,
proofHints,
);
setProofData({
fieldKey,
field: entry.field,
sourceSnippet: entry.sourceSnippet,
confidence: entry.sourceHints?.confidence ?? null,
page: entry.sourceHints?.page ?? null,
section: entry.sourceHints?.section ?? null,
lineNumber,
contextStartLine,
context,
resolutionMode,
});
setProofModalOpen(true);
};
const getExplainabilityItems = (
contract: Contract | null,
): ExplainabilityEntry[] => {
2026-04-12 19:24:24 +01:00
const raw = isContractKeyPoints(contract?.keyPoints)
? contract.keyPoints.explainability
: undefined;
2026-03-28 23:46:45 +01:00
if (!Array.isArray(raw)) return [];
return raw
2026-04-12 19:24:24 +01:00
.map((item) => ({
2026-03-28 23:46:45 +01:00
field: String(item?.field ?? "").trim(),
why: String(item?.why ?? "").trim(),
sourceSnippet: String(item?.sourceSnippet ?? "").trim(),
sourceHints: {
page:
item?.sourceHints?.page === null ||
item?.sourceHints?.page === undefined
? null
: String(item?.sourceHints?.page),
section:
item?.sourceHints?.section === null ||
item?.sourceHints?.section === undefined
? null
: String(item?.sourceHints?.section),
confidence:
typeof item?.sourceHints?.confidence === "number"
? item.sourceHints.confidence
: null,
},
}))
.filter((item: ExplainabilityEntry) => {
return item.field && item.why && item.sourceSnippet;
})
.slice(0, 24);
};
const explainabilityItems = useMemo(
() => getExplainabilityItems(selectedContract),
[selectedContract],
);
const getProofForField = useCallback(
(fieldKey: string): ExplainabilityEntry | null => {
const normalize = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9]/g, "");
const aliases: Record<string, string[]> = {
title: ["title", "contracttitle"],
provider: ["provider", "institution", "insurer", "company"],
policyNumber: [
"policynumber",
"policyno",
"contractnumber",
"reference",
],
startDate: ["startdate", "effectivedate", "start"],
endDate: ["enddate", "expirationdate", "expirydate", "maturitydate"],
premium: ["premium", "amount", "totalcost", "fee", "price"],
};
const normalizedTargets = new Set(
(aliases[fieldKey] ?? [fieldKey]).map((item) => normalize(item)),
);
const ranked = explainabilityItems
.map((entry) => {
const normalizedField = normalize(entry.field);
const isExact = normalizedTargets.has(normalizedField);
const isPartial = Array.from(normalizedTargets).some(
(target) =>
normalizedField.includes(target) ||
target.includes(normalizedField),
);
const confidence = entry.sourceHints?.confidence ?? 0;
const score = (isExact ? 100 : 0) + (isPartial ? 40 : 0) + confidence;
return { entry, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
const match = ranked[0]?.entry;
return match ?? null;
},
[explainabilityItems],
);
const getFieldValueForProof = useCallback(
(fieldKey: string): string => {
if (!selectedContract) return "";
switch (fieldKey) {
case "title":
return selectedContract.title ?? "";
case "provider":
return selectedContract.provider ?? "";
case "policyNumber":
return selectedContract.policyNumber ?? "";
case "startDate":
return selectedContract.startDate ?? "";
case "endDate":
return selectedContract.endDate ?? "";
case "premium":
return selectedContract.premium
? selectedContract.premium.toString()
: "";
default:
return "";
}
},
[selectedContract],
);
const resolvePremiumCurrency = useCallback((contract: Contract | null) => {
if (!contract) return null;
const fromMeta = String(
2026-04-12 19:24:24 +01:00
(isContractKeyPoints(contract.keyPoints)
? contract.keyPoints.aiMeta?.premiumCurrency
: null) ?? "",
2026-03-28 23:46:45 +01:00
).trim();
if (fromMeta) return fromMeta;
const explainability = getExplainabilityItems(contract);
const premiumEvidence = explainability.find((item) => {
const normalized = item.field.toLowerCase();
return normalized.includes("premium") || normalized.includes("prime");
});
const currencyRegex = /(EUR|USD|TND|MAD|DZD|GBP|CHF|CAD|AUD|€|\$|£)/i;
const snippetCurrency =
premiumEvidence?.sourceSnippet.match(currencyRegex)?.[0];
if (snippetCurrency) return snippetCurrency.toUpperCase();
const textCurrency = contract.extractedText?.match(currencyRegex)?.[0];
if (textCurrency) return textCurrency.toUpperCase();
return null;
}, []);
const formatPremiumWithSourceCurrency = useCallback(
(contract: Contract | null) => {
if (
!contract ||
contract.premium === null ||
contract.premium === undefined
) {
return "N/A";
}
const currency = resolvePremiumCurrency(contract) ?? "EUR";
const numeric = new Intl.NumberFormat("fr-FR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(contract.premium);
if (currency === "€" || currency === "$" || currency === "£") {
return `${currency}${numeric}`;
}
return `${numeric} ${currency}`;
},
[resolvePremiumCurrency],
);
const handleOpenFieldProof = (fieldKey: string, label: string) => {
const fieldValueFallback = getFieldValueForProof(fieldKey);
const proof = getProofForField(fieldKey);
if (!proof) {
if (fieldValueFallback) {
handleProofClick(
fieldKey,
{
field: label,
why: "Resolved using extracted field value fallback when explicit explainability evidence is missing.",
sourceSnippet: fieldValueFallback,
sourceHints: {
confidence: null,
page: null,
section: null,
},
},
fieldValueFallback,
null,
);
return;
}
toast.error(`No proof snippet is available for ${label}`);
return;
}
handleProofClick(
fieldKey,
proof,
fieldValueFallback,
proof.sourceHints?.section ?? null,
);
};
const loadContracts = useCallback(
async (options?: { silent?: boolean; search?: string }) => {
const isSilentRefresh = options?.silent ?? false;
if (!isSilentRefresh) {
setIsLoading(true);
}
try {
const result = await getContracts({
search: options?.search?.trim() || undefined,
});
if (result.success && Array.isArray(result.contracts)) {
setContracts(result.contracts);
}
} catch (error) {
console.error("Failed to load contracts:", error);
toast.error("Failed to load contracts");
} finally {
if (!isSilentRefresh) {
setIsLoading(false);
}
}
},
[],
);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedSearchQuery(searchQuery.trim());
}, 300);
return () => window.clearTimeout(timeoutId);
}, [searchQuery]);
useEffect(() => {
void loadContracts({ search: debouncedSearchQuery });
}, [loadContracts, refreshTrigger, debouncedSearchQuery]);
useEffect(() => {
const hasPendingContracts = contracts.some(
(contract) =>
contract.status === "PROCESSING" || contract.status === "UPLOADED",
);
if (!hasPendingContracts) {
return;
}
const intervalId = window.setInterval(() => {
void loadContracts({
silent: true,
search: debouncedSearchQuery,
});
}, 7000);
return () => window.clearInterval(intervalId);
}, [contracts, loadContracts, debouncedSearchQuery]);
const handleDelete = async (id: string) => {
setDeletingId(id);
try {
const result = await deleteContract(id);
if (result.success) {
setContracts(contracts.filter((c) => c.id !== id));
toast.success("Contract deleted successfully");
emitNotificationRefresh();
} else {
toast.error(result.error || "Failed to delete contract");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Unknown error occurred",
);
} finally {
setDeletingId(null);
}
};
const requestDeleteContract = (contract: Contract) => {
setContractToDelete(contract);
setDeleteDialogOpen(true);
};
const confirmDeleteContract = async () => {
if (!contractToDelete) return;
await handleDelete(contractToDelete.id);
setDeleteDialogOpen(false);
setContractToDelete(null);
};
2026-04-12 19:24:24 +01:00
const handleDeleteAll = async () => {
setIsDeletingAll(true);
2026-03-28 23:46:45 +01:00
try {
2026-04-12 19:24:24 +01:00
const result = await deleteAllContractsAction();
2026-03-28 23:46:45 +01:00
if (result.success) {
2026-04-12 19:24:24 +01:00
setContracts([]);
toast.success(
`Deleted ${result.deletedCount ?? 0} contract${(result.deletedCount ?? 0) === 1 ? "" : "s"}.`,
);
2026-03-28 23:46:45 +01:00
emitNotificationRefresh();
} else {
2026-04-12 19:24:24 +01:00
toast.error(result.error || "Failed to delete all contracts");
2026-03-28 23:46:45 +01:00
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Unknown error occurred",
);
} finally {
2026-04-12 19:24:24 +01:00
setIsDeletingAll(false);
setDeleteAllDialogOpen(false);
2026-03-28 23:46:45 +01:00
}
};
const handleOpenDetails = (contract: Contract) => {
setSelectedContract(contract);
setDetailsOpen(true);
};
const handleOpenAsk = (contract: Contract) => {
setChatContract(contract);
setAskOpen(true);
};
const formatDate = (date: Date | string) => {
const dateObj = typeof date === "string" ? new Date(date) : date;
return dateObj.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith("image/")) {
2026-05-03 13:26:31 +01:00
return <FileIcon className="w-5 h-5 text-violet-500" />;
2026-03-28 23:46:45 +01:00
}
if (mimeType === "application/pdf") {
2026-05-03 13:26:31 +01:00
return <FileText className="w-5 h-5 text-red-500" />;
2026-03-28 23:46:45 +01:00
}
2026-05-03 13:26:31 +01:00
return <FileIcon className="w-5 h-5 text-blue-500" />;
2026-03-28 23:46:45 +01:00
};
2026-05-03 13:26:31 +01:00
const getFileIconBg = (mimeType: string) => {
if (mimeType.startsWith("image/")) {
return "bg-violet-500/10 border-violet-500/20";
}
if (mimeType === "application/pdf") {
return "bg-red-500/10 border-red-500/20";
}
return "bg-blue-500/10 border-blue-500/20";
};
const getStatusConfig = (status: string) => {
2026-03-28 23:46:45 +01:00
switch (status) {
case "COMPLETED":
2026-05-03 13:26:31 +01:00
return {
dot: "bg-emerald-500",
bg: "bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-300",
label: "Completed",
};
2026-03-28 23:46:45 +01:00
case "PROCESSING":
2026-05-03 13:26:31 +01:00
return {
dot: "bg-blue-500",
bg: "bg-blue-500/10 border-blue-500/20 text-blue-700 dark:text-blue-300",
label: "Processing",
};
2026-03-28 23:46:45 +01:00
case "UPLOADED":
2026-05-03 13:26:31 +01:00
return {
dot: "bg-amber-500",
bg: "bg-amber-500/10 border-amber-500/20 text-amber-700 dark:text-amber-300",
label: "Uploaded",
};
2026-03-28 23:46:45 +01:00
case "FAILED":
2026-05-03 13:26:31 +01:00
return {
dot: "bg-red-500",
bg: "bg-red-500/10 border-red-500/20 text-red-700 dark:text-red-300",
label: "Failed",
};
2026-03-28 23:46:45 +01:00
default:
2026-05-03 13:26:31 +01:00
return {
dot: "bg-gray-500",
bg: "bg-gray-500/10 border-gray-500/20 text-gray-700 dark:text-gray-300",
label: status,
};
2026-03-28 23:46:45 +01:00
}
};
if (isLoading) {
return (
2026-05-03 13:26:31 +01:00
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-5 animate-pulse"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded-lg w-1/3" />
<div className="h-3 bg-muted rounded-lg w-1/4" />
</div>
</div>
</div>
))}
</div>
2026-03-28 23:46:45 +01:00
);
}
if (contracts.length === 0 && !debouncedSearchQuery) {
return null;
}
return (
<>
{invalidContractReason && (
2026-05-03 13:26:31 +01:00
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-5 rounded-2xl border border-red-500/20 bg-gradient-to-r from-red-500/10 via-red-500/5 to-transparent p-4 backdrop-blur-xl"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="p-1.5 rounded-lg bg-red-500/20 mt-0.5">
<AlertTriangle className="h-4 w-4 text-red-500" />
</div>
2026-03-28 23:46:45 +01:00
<div>
<p className="text-sm font-semibold text-foreground">
Invalid contract upload detected
</p>
2026-05-03 13:26:31 +01:00
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
2026-03-28 23:46:45 +01:00
{invalidContractFileName
? `${invalidContractFileName}: `
: ""}
{invalidContractReason}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
2026-05-03 13:26:31 +01:00
className="h-8 w-8 rounded-lg hover:bg-red-500/10 hover:text-red-500"
2026-03-28 23:46:45 +01:00
onClick={() => {
setInvalidContractReason("");
setInvalidContractFileName("");
}}
>
<X className="h-4 w-4" />
</Button>
</div>
2026-05-03 13:26:31 +01:00
</motion.div>
2026-03-28 23:46:45 +01:00
)}
2026-05-03 13:26:31 +01:00
{/* Toolbar */}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-md group">
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 via-violet-500/20 to-primary/20 rounded-2xl blur opacity-0 group-focus-within:opacity-100 transition duration-500" />
<div className="relative flex items-center">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search by contract title or provider..."
className="pl-10 pr-4 h-11 rounded-xl border-border/60 bg-background/60 backdrop-blur-xl focus:bg-background/80 focus:ring-2 focus:ring-primary/20 transition-all"
/>
</div>
2026-03-28 23:46:45 +01:00
</div>
2026-05-03 13:26:31 +01:00
<div className="flex items-center gap-3">
2026-04-12 19:24:24 +01:00
{debouncedSearchQuery && (
2026-05-03 13:26:31 +01:00
<p className="text-xs text-muted-foreground hidden sm:block">
{contracts.length} result{contracts.length !== 1 ? "s" : ""} for
&quot;{debouncedSearchQuery}&quot;
2026-04-12 19:24:24 +01:00
</p>
)}
<Button
type="button"
2026-05-03 13:26:31 +01:00
variant="outline"
2026-04-12 19:24:24 +01:00
size="sm"
disabled={contracts.length === 0 || isDeletingAll}
onClick={() => setDeleteAllDialogOpen(true)}
2026-05-03 13:26:31 +01:00
className="gap-2 rounded-xl border-border/60 hover:border-red-500/30 hover:bg-red-500/5 hover:text-red-600 transition-all"
2026-04-12 19:24:24 +01:00
>
{isDeletingAll ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Delete All
</Button>
</div>
2026-03-28 23:46:45 +01:00
</div>
2026-05-03 13:26:31 +01:00
{/* Contract Cards */}
<div className="space-y-3">
<AnimatePresence mode="popLayout">
{contracts.map((contract, idx) => {
const status = getStatusConfig(contract.status);
return (
<motion.div
key={contract.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ delay: idx * 0.03 }}
className="group relative rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-4 md:p-5 hover:bg-background/60 hover:border-primary/20 hover:shadow-lg hover:shadow-primary/5 transition-all duration-300"
>
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-4 flex-1 min-w-0 w-full">
<div
className={`p-2.5 rounded-xl border ${getFileIconBg(contract.mimeType)} shrink-0`}
2026-03-28 23:46:45 +01:00
>
2026-05-03 13:26:31 +01:00
{getFileIcon(contract.mimeType)}
</div>
2026-03-28 23:46:45 +01:00
2026-05-03 13:26:31 +01:00
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-foreground truncate text-sm">
{contract.fileName}
</h3>
<span
className={`inline-flex items-center gap-1.5 text-[10px] font-bold px-2.5 py-1 rounded-full border uppercase tracking-wider ${status.bg}`}
>
<span
className={`relative flex h-1.5 w-1.5 rounded-full ${status.dot} ${contract.status === "PROCESSING" || contract.status === "UPLOADED" ? "animate-pulse" : ""}`}
/>
{status.label}
</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-bold uppercase tracking-wider 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 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
{formatFileSize(contract.fileSize)}
</span>
<span className="w-px h-3 bg-border" />
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(contract.createdAt)}
</span>
</div>
</div>
2026-03-28 23:46:45 +01:00
</div>
2026-05-03 13:26:31 +01:00
<div className="flex items-center gap-1 flex-shrink-0 w-full sm:w-auto justify-end">
2026-03-28 23:46:45 +01:00
<Button
variant="ghost"
size="icon"
2026-05-03 13:26:31 +01:00
className="h-9 w-9 rounded-xl hover:bg-primary/10 hover:text-primary transition-colors"
title="View contract"
onClick={() => {
if (contract.fileUrl) {
window.open(contract.fileUrl, "_blank");
}
}}
2026-03-28 23:46:45 +01:00
>
2026-05-03 13:26:31 +01:00
<Eye className="w-4 h-4" />
2026-03-28 23:46:45 +01:00
</Button>
2026-05-03 13:26:31 +01:00
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-xl hover:bg-primary/10 hover:text-primary transition-colors"
title="Download contract"
onClick={() => {
if (contract.id) {
const link = document.createElement("a");
link.href = `/api/contracts/${contract.id}/download`;
link.setAttribute(
"download",
contract.fileName || "contract",
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}}
2026-03-28 23:46:45 +01:00
>
2026-05-03 13:26:31 +01:00
<Download className="w-4 h-4" />
</Button>
2026-03-28 23:46:45 +01:00
2026-05-03 13:26:31 +01:00
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-xl hover:bg-primary/10 hover:text-primary transition-colors"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="rounded-xl border-border/60 backdrop-blur-xl"
>
<DropdownMenuItem
onClick={() => handleOpenAsk(contract)}
className="cursor-pointer rounded-lg focus:bg-primary/10"
>
<MessageSquare className="w-4 h-4 mr-2 text-primary" />
Ask about this file
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleOpenDetails(contract)}
className="cursor-pointer rounded-lg focus:bg-primary/10"
>
<FileText className="w-4 h-4 mr-2 text-primary" />
Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => exportToPDF(contract as any)}
className="cursor-pointer rounded-lg focus:bg-primary/10"
>
<FileText className="w-4 h-4 mr-2 text-primary" />
Export Analysis (PDF)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => exportToCSV(contract as any)}
className="cursor-pointer rounded-lg focus:bg-primary/10"
>
<FileSpreadsheet className="w-4 h-4 mr-2 text-primary" />
Export Analysis (CSV)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => requestDeleteContract(contract)}
disabled={deletingId === contract.id}
className="text-destructive focus:text-destructive cursor-pointer rounded-lg focus:bg-destructive/10"
>
{deletingId === contract.id ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</motion.div>
);
})}
</AnimatePresence>
{contracts.length === 0 && debouncedSearchQuery && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-12 text-center"
>
<div className="relative inline-flex mb-4">
<div className="absolute inset-0 bg-primary/20 blur-xl rounded-full" />
<Search className="w-10 h-10 text-muted-foreground relative z-10" />
2026-03-28 23:46:45 +01:00
</div>
2026-05-03 13:26:31 +01:00
<p className="text-sm font-semibold text-foreground">
No contracts found
</p>
<p className="mt-1 text-xs text-muted-foreground">
Try different keywords from the title or provider name.
</p>
</motion.div>
)}
</div>
2026-03-28 23:46:45 +01:00
{/* Details Modal */}
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
2026-05-03 13:26:31 +01:00
<DialogContent className="max-h-[92vh] max-w-6xl overflow-y-auto border-border/40 bg-background/80 backdrop-blur-2xl shadow-2xl">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
<DialogHeader className="pb-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
<FileText className="w-5 h-5 text-primary" />
</div>
2026-03-28 23:46:45 +01:00
Contract Details
</DialogTitle>
<DialogClose />
</DialogHeader>
{selectedContract && (
<div className="space-y-6 py-4">
2026-05-03 13:26:31 +01:00
{/* Document Profile */}
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-br from-primary/5 via-background to-violet-500/5 p-6 backdrop-blur-xl">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-primary/10 to-transparent rounded-full blur-2xl" />
<div className="relative z-10 flex flex-wrap items-start justify-between gap-3 mb-5">
2026-03-28 23:46:45 +01:00
<div className="min-w-0">
2026-05-03 13:26:31 +01:00
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground mb-1">
2026-03-28 23:46:45 +01:00
Document Profile
</p>
2026-05-03 13:26:31 +01:00
<p className="truncate text-lg font-bold text-foreground">
2026-03-28 23:46:45 +01:00
{selectedContract.fileName}
</p>
</div>
<span
2026-05-03 13:26:31 +01:00
className={`inline-flex items-center gap-1.5 text-[10px] font-bold px-3 py-1.5 rounded-full border uppercase tracking-wider ${getStatusConfig(selectedContract.status).bg}`}
2026-03-28 23:46:45 +01:00
>
2026-05-03 13:26:31 +01:00
<span
className={`h-1.5 w-1.5 rounded-full ${getStatusConfig(selectedContract.status).dot}`}
/>
2026-03-28 23:46:45 +01:00
{selectedContract.status}
</span>
</div>
2026-05-03 13:26:31 +01:00
<div className="relative z-10 grid auto-rows-fr gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
{[
{
label: "File Size",
value: formatFileSize(selectedContract.fileSize),
icon: <HardDrive className="w-3.5 h-3.5" />,
},
{
label: "Mime Type",
value: selectedContract.mimeType,
icon: <FileType className="w-3.5 h-3.5" />,
},
{
label: "Uploaded",
value: formatDate(selectedContract.createdAt),
icon: <Calendar className="w-3.5 h-3.5" />,
},
{
label: "Category",
value: selectedContract.type || "Pending analysis",
icon: <Tag className="w-3.5 h-3.5" />,
},
].map((item) => (
<div
key={item.label}
className="min-h-[90px] rounded-xl border border-border/30 bg-background/50 px-4 py-3 backdrop-blur-md transition-all duration-300 hover:bg-background/80 hover:shadow-md hover:-translate-y-0.5 group"
>
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<span className="text-primary/70">{item.icon}</span>
<p className="text-[10px] font-bold uppercase tracking-wider">
{item.label}
</p>
</div>
<p className="font-semibold text-foreground text-sm truncate">
{item.value}
</p>
</div>
))}
2026-03-28 23:46:45 +01:00
</div>
</div>
{/* AI Analysis Results */}
{selectedContract.status === "COMPLETED" && (
<>
2026-05-03 13:26:31 +01:00
<div className="space-y-4 rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-6">
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-4 h-4 text-primary" />
<h3 className="text-base font-bold text-foreground">
Extracted Contract Information
</h3>
</div>
2026-03-28 23:46:45 +01:00
<div className="grid auto-rows-fr gap-3 text-sm sm:grid-cols-2 xl:grid-cols-3">
2026-05-03 13:26:31 +01:00
{[
{
key: "title",
label: "Title",
value: stripMarkdown(selectedContract.title) || "N/A",
},
{
key: "provider",
label: "Provider",
value:
stripMarkdown(selectedContract.provider) || "N/A",
},
{
key: "policyNumber",
label: "Policy Number",
value:
stripMarkdown(selectedContract.policyNumber) ||
"N/A",
},
{
key: "startDate",
label: "Start Date",
value: selectedContract.startDate
2026-03-28 23:46:45 +01:00
? formatDate(selectedContract.startDate)
2026-05-03 13:26:31 +01:00
: "N/A",
},
{
key: "endDate",
label: "End Date",
value: selectedContract.endDate
2026-03-28 23:46:45 +01:00
? formatDate(selectedContract.endDate)
2026-05-03 13:26:31 +01:00
: "N/A",
},
{
key: "premium",
label: "Premium",
value:
formatPremiumWithSourceCurrency(selectedContract),
},
].map((field) => (
<div
key={field.key}
className="flex min-h-[120px] flex-col rounded-xl border border-border/30 bg-background/50 px-4 py-3 backdrop-blur-md transition-all duration-300 hover:bg-background/80 hover:shadow-lg hover:-translate-y-1 hover:border-primary/20 group"
>
<div className="flex items-center justify-between gap-2 mb-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{field.label}
</p>
<button
type="button"
onClick={() =>
handleOpenFieldProof(field.key, field.label)
}
className="rounded-lg border border-transparent p-1.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-all hover:border-primary/20 hover:bg-primary/10 hover:text-primary"
aria-label={`Show ${field.label.toLowerCase()} proof`}
title="Show proof"
>
<Info className="h-3.5 w-3.5" />
</button>
</div>
<p className="mt-auto rounded-lg border border-white/10 dark:border-white/5 bg-muted/30 px-3 py-2.5 font-semibold text-foreground whitespace-pre-wrap break-words text-sm">
{field.value}
2026-03-28 23:46:45 +01:00
</p>
</div>
2026-05-03 13:26:31 +01:00
))}
2026-03-28 23:46:45 +01:00
</div>
</div>
{selectedContract.summary && (
2026-05-03 13:26:31 +01:00
<div className="space-y-3 rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-6">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
<h3 className="text-base font-bold text-foreground">
Summary
</h3>
</div>
<div className="space-y-2 rounded-xl border border-border/30 bg-background/50 p-4 text-sm whitespace-pre-wrap break-words">
2026-03-28 23:46:45 +01:00
{renderRichParagraphs(
selectedContract.summary,
`summary-${selectedContract.id}`,
)}
</div>
</div>
)}
{selectedContract.keyPoints && (
2026-05-03 13:26:31 +01:00
<div className="space-y-3 rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-6">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-primary" />
<h3 className="text-base font-bold text-foreground">
Key Points
</h3>
</div>
<div className="space-y-4 text-sm">
2026-04-12 19:24:24 +01:00
{isContractKeyPoints(selectedContract.keyPoints) &&
selectedContract.keyPoints.guarantees &&
2026-03-28 23:46:45 +01:00
Array.isArray(
2026-04-12 19:24:24 +01:00
selectedContract.keyPoints.guarantees,
2026-03-28 23:46:45 +01:00
) && (
<div>
2026-05-03 13:26:31 +01:00
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
Guarantees
2026-03-28 23:46:45 +01:00
</p>
2026-05-03 13:26:31 +01:00
<div className="space-y-2">
2026-03-28 23:46:45 +01:00
{(
2026-04-12 19:24:24 +01:00
selectedContract.keyPoints.guarantees ?? []
).map((guarantee, idx: number) => (
2026-05-03 13:26:31 +01:00
<div
2026-03-28 23:46:45 +01:00
key={idx}
2026-05-03 13:26:31 +01:00
className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-4 py-3"
2026-03-28 23:46:45 +01:00
>
{renderRichParagraphs(
guarantee,
`guarantee-${selectedContract.id}-${idx}`,
)}
2026-05-03 13:26:31 +01:00
</div>
2026-03-28 23:46:45 +01:00
))}
2026-05-03 13:26:31 +01:00
</div>
2026-03-28 23:46:45 +01:00
</div>
)}
2026-04-12 19:24:24 +01:00
{isContractKeyPoints(selectedContract.keyPoints) &&
selectedContract.keyPoints.exclusions &&
2026-03-28 23:46:45 +01:00
Array.isArray(
2026-04-12 19:24:24 +01:00
selectedContract.keyPoints.exclusions,
2026-03-28 23:46:45 +01:00
) && (
<div>
2026-05-03 13:26:31 +01:00
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
Exclusions
2026-03-28 23:46:45 +01:00
</p>
2026-05-03 13:26:31 +01:00
<div className="space-y-2">
2026-03-28 23:46:45 +01:00
{(
2026-04-12 19:24:24 +01:00
selectedContract.keyPoints.exclusions ?? []
).map((exclusion, idx: number) => (
2026-05-03 13:26:31 +01:00
<div
2026-03-28 23:46:45 +01:00
key={idx}
2026-05-03 13:26:31 +01:00
className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3"
2026-03-28 23:46:45 +01:00
>
{renderRichParagraphs(
exclusion,
`exclusion-${selectedContract.id}-${idx}`,
)}
2026-05-03 13:26:31 +01:00
</div>
2026-03-28 23:46:45 +01:00
))}
2026-05-03 13:26:31 +01:00
</div>
2026-03-28 23:46:45 +01:00
</div>
)}
2026-04-12 19:24:24 +01:00
{isContractKeyPoints(selectedContract.keyPoints) &&
selectedContract.keyPoints.franchise && (
<div>
2026-05-03 13:26:31 +01:00
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
Deductible
2026-04-12 19:24:24 +01:00
</p>
2026-05-03 13:26:31 +01:00
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 whitespace-pre-wrap break-words">
2026-04-12 19:24:24 +01:00
{renderRichParagraphs(
String(selectedContract.keyPoints.franchise),
`franchise-${selectedContract.id}`,
)}
</div>
2026-03-28 23:46:45 +01:00
</div>
2026-04-12 19:24:24 +01:00
)}
2026-03-28 23:46:45 +01:00
</div>
</div>
)}
</>
)}
{selectedContract.status === "PROCESSING" && (
2026-05-03 13:26:31 +01:00
<div className="flex items-center gap-3 rounded-xl border border-blue-500/20 bg-blue-500/5 p-5 backdrop-blur-xl">
<div className="p-2 rounded-lg bg-blue-500/20">
<Loader2 className="w-5 h-5 animate-spin text-blue-500" />
</div>
<div>
<p className="text-sm font-semibold text-blue-700 dark:text-blue-300">
AI analysis is in progress
</p>
<p className="text-xs text-blue-600/70 dark:text-blue-400/70">
Extracting entities, clauses, and generating insights...
</p>
</div>
2026-03-28 23:46:45 +01:00
</div>
)}
{selectedContract.status === "UPLOADED" && (
2026-05-03 13:26:31 +01:00
<div className="flex items-center gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-5 backdrop-blur-xl">
<div className="p-2 rounded-lg bg-amber-500/20">
<Loader2 className="w-5 h-5 text-amber-500 animate-spin" />
</div>
<div>
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">
Contract uploaded successfully
</p>
<p className="text-xs text-amber-600/70 dark:text-amber-400/70">
AI analysis will begin automatically momentarily
</p>
</div>
2026-03-28 23:46:45 +01:00
</div>
)}
{selectedContract.status === "FAILED" && (
2026-05-03 13:26:31 +01:00
<div className="space-y-2 rounded-xl border border-red-500/20 bg-red-500/5 p-5 backdrop-blur-xl">
<div className="flex items-center gap-2 mb-1">
<AlertTriangle className="w-4 h-4 text-red-500" />
<p className="text-sm font-bold text-red-700 dark:text-red-300">
Analysis failed
</p>
</div>
<p className="text-sm text-red-700/80 dark:text-red-300/80 leading-relaxed">
2026-03-28 23:46:45 +01:00
{selectedContract.summary ||
"The uploaded file could not be processed as a valid contract. Please upload a clearer contract document and try again."}
</p>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
<ContractProofModal
isOpen={proofModalOpen}
onOpenChange={setProofModalOpen}
proofData={proofData}
/>
<ContractChatModal
isOpen={askOpen}
onOpenChange={setAskOpen}
contract={chatContract}
renderRichParagraphs={renderRichParagraphs}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
2026-05-03 13:26:31 +01:00
<AlertDialogContent className="border-border/40 bg-background/80 backdrop-blur-2xl">
2026-03-28 23:46:45 +01:00
<AlertDialogHeader>
2026-05-03 13:26:31 +01:00
<AlertDialogTitle className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-destructive" />
Delete this contract?
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
2026-03-28 23:46:45 +01:00
This action permanently removes the selected contract and its
associated file.
2026-05-03 13:26:31 +01:00
{contractToDelete ? (
<span className="block mt-2 p-3 rounded-lg bg-muted/50 border border-border/30 font-mono text-xs">
{contractToDelete.fileName}
</span>
) : (
""
)}
2026-03-28 23:46:45 +01:00
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setContractToDelete(null);
}}
2026-05-03 13:26:31 +01:00
className="rounded-xl"
2026-03-28 23:46:45 +01:00
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => void confirmDeleteContract()}
2026-05-03 13:26:31 +01:00
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-xl"
2026-03-28 23:46:45 +01:00
>
{contractToDelete && deletingId === contractToDelete.id
? "Deleting..."
: "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
2026-04-12 19:24:24 +01:00
<AlertDialog
open={deleteAllDialogOpen}
onOpenChange={setDeleteAllDialogOpen}
>
2026-05-03 13:26:31 +01:00
<AlertDialogContent className="border-border/40 bg-background/80 backdrop-blur-2xl">
2026-04-12 19:24:24 +01:00
<AlertDialogHeader>
2026-05-03 13:26:31 +01:00
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
Delete all contracts?
</AlertDialogTitle>
2026-04-12 19:24:24 +01:00
<AlertDialogDescription>
This action permanently removes all contracts and related files
for your account. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
2026-05-03 13:26:31 +01:00
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
2026-04-12 19:24:24 +01:00
<AlertDialogAction
onClick={() => void handleDeleteAll()}
2026-05-03 13:26:31 +01:00
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-xl"
2026-04-12 19:24:24 +01:00
>
{isDeletingAll ? "Deleting..." : "Delete All"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
2026-03-28 23:46:45 +01:00
<Dialog
open={invalidContractDialogOpen}
onOpenChange={setInvalidContractDialogOpen}
>
2026-05-03 13:26:31 +01:00
<DialogContent className="max-w-md border-border/40 bg-background/80 backdrop-blur-2xl">
2026-03-28 23:46:45 +01:00
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Invalid Contract File
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
The AI could not validate this file as a real contract.
</p>
2026-05-03 13:26:31 +01:00
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-4">
<p className="text-xs font-bold uppercase tracking-wider text-destructive mb-1">
Reason
</p>
<p className="text-sm text-muted-foreground">
2026-03-28 23:46:45 +01:00
{invalidContractReason ||
"This uploaded file does not appear to be a valid contract."}
</p>
</div>
{invalidContractFileName && (
<p className="text-xs text-muted-foreground">
File:{" "}
2026-05-03 13:26:31 +01:00
<span className="font-medium text-foreground font-mono">
2026-03-28 23:46:45 +01:00
{invalidContractFileName}
</span>
</p>
)}
2026-05-03 13:26:31 +01:00
<p className="text-xs text-muted-foreground leading-relaxed">
2026-03-28 23:46:45 +01:00
Please upload a contract or policy document with readable legal
terms and agreement details.
</p>
</div>
<div className="mt-2 flex justify-end">
<Button
onClick={() => setInvalidContractDialogOpen(false)}
className="rounded-xl"
>
Got it
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}