PreRelease v2
This commit is contained in:
548
features/contracts/api/contract.action.ts
Normal file
548
features/contracts/api/contract.action.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Contract Server Actions
|
||||
*
|
||||
* Handles all contract-related operations including:
|
||||
* - Saving uploaded contracts
|
||||
* - Retrieving contracts
|
||||
* - Analyzing contracts with AI
|
||||
* - Deleting contracts
|
||||
* - Asking questions about contracts
|
||||
*
|
||||
* Each action integrates with:
|
||||
* - Clerk for authentication
|
||||
* - Contract service for database operations
|
||||
* - AI service for document analysis
|
||||
* - Notification service for user feedback
|
||||
*
|
||||
* All operations include comprehensive error handling and notification creation.
|
||||
*/
|
||||
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
ContractService,
|
||||
saveContract as savePendingContract,
|
||||
} from "@/lib/services/contract.service";
|
||||
import { AIService } from "@/lib/services/ai.service";
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
/**
|
||||
* Saves a new contract after UploadThing upload
|
||||
*
|
||||
* Steps:
|
||||
* 1. Get authenticated user from Clerk
|
||||
* 2. Get internal user ID from database
|
||||
* 3. Save contract to database with UPLOADED status
|
||||
* 4. Create success notification for the user
|
||||
* 5. Revalidate dashboard and contacts pages
|
||||
*
|
||||
* @param data - Contract file metadata from UploadThing
|
||||
* @returns Success status with contract data or error message
|
||||
*/
|
||||
export async function saveContract(data: {
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}) {
|
||||
try {
|
||||
// Get authenticated user
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
// Save contract
|
||||
const result = await savePendingContract(data);
|
||||
|
||||
if (result.success && result.contract) {
|
||||
// Get internal user ID for notification
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (user) {
|
||||
// Create success notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "📄 Contract Uploaded",
|
||||
message: `"${data.fileName}" has been uploaded successfully. Click "Analyze" to extract contract details.`,
|
||||
contractId: result.contract.id,
|
||||
actionType: "UPLOAD_SUCCESS",
|
||||
icon: "FileCheck",
|
||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
console.error("Save contract error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all contracts for the authenticated user
|
||||
*
|
||||
* Steps:
|
||||
* 1. Query database for contracts matching filter criteria
|
||||
* 2. Serialize data: convert Decimal to number, dates to ISO strings
|
||||
* 3. Return paginated/filtered contract list
|
||||
*
|
||||
* Supported Filters:
|
||||
* - status: UPLOADED, PROCESSING, COMPLETED, FAILED
|
||||
* - type: INSURANCE_AUTO, INSURANCE_HOME, etc.
|
||||
* - search: Searches title, provider, policyNumber, fileName
|
||||
* - userId: Auto-filtered to authenticated user
|
||||
*
|
||||
* @param filters - Filter criteria
|
||||
* @returns Array of contracts with serialized data
|
||||
*/
|
||||
export async function getContracts(filters?: Record<string, unknown>) {
|
||||
try {
|
||||
const contracts = await ContractService.getAll(filters);
|
||||
|
||||
// Serialize contracts: convert Decimal to number, dates to ISO strings
|
||||
const serializedContracts = contracts.map((contract: any) => ({
|
||||
id: contract.id,
|
||||
fileName: contract.fileName,
|
||||
fileSize: contract.fileSize,
|
||||
mimeType: contract.mimeType,
|
||||
status: contract.status,
|
||||
createdAt: contract.createdAt?.toISOString() || new Date().toISOString(),
|
||||
fileUrl: contract.fileUrl,
|
||||
// AI Analysis fields
|
||||
title: contract.title || null,
|
||||
type: contract.type || null,
|
||||
provider: contract.provider || null,
|
||||
policyNumber: contract.policyNumber || null,
|
||||
startDate: contract.startDate ? contract.startDate.toISOString() : null,
|
||||
endDate: contract.endDate ? contract.endDate.toISOString() : null,
|
||||
premium: contract.premium
|
||||
? parseFloat(contract.premium.toString())
|
||||
: null,
|
||||
summary: contract.summary || null,
|
||||
keyPoints: contract.keyPoints || null,
|
||||
extractedText: contract.extractedText || null,
|
||||
}));
|
||||
|
||||
return { success: true, contracts: serializedContracts };
|
||||
} catch (error: unknown) {
|
||||
console.error("Get contracts error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single contract by ID
|
||||
*
|
||||
* @param id - Contract ID
|
||||
* @returns Contract details or error
|
||||
*/
|
||||
export async function getContract(id: string) {
|
||||
try {
|
||||
const contract = await ContractService.getById(id);
|
||||
return { success: true, contract };
|
||||
} catch (error: unknown) {
|
||||
console.error("Get contract error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a contract from both cloud storage and database
|
||||
*
|
||||
* Steps:
|
||||
* 1. Get authenticated user from Clerk
|
||||
* 2. Get internal user ID from database
|
||||
* 3. Verify user owns the contract
|
||||
* 4. Delete file from UploadThing cloud storage
|
||||
* 5. Delete contract record from database
|
||||
* 6. Create success notification
|
||||
* 7. Revalidate pages
|
||||
*
|
||||
* @param id - Contract ID to delete
|
||||
* @returns Success status or error message
|
||||
*
|
||||
* Security: Only the contract owner can delete their contracts
|
||||
*/
|
||||
export async function deleteContract(id: string) {
|
||||
try {
|
||||
// Get authenticated user
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
// Get contract to verify ownership and get title
|
||||
const contract = await ContractService.getById(id);
|
||||
const contractTitle = contract.title || contract.fileName;
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user || contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Delete contract (handles both storage and database)
|
||||
await ContractService.delete(id);
|
||||
|
||||
if (user) {
|
||||
// Create success notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "🗑️ Contract Deleted",
|
||||
message: `"${contractTitle}" has been permanently deleted.`,
|
||||
actionType: "DELETE_SUCCESS",
|
||||
icon: "Trash2",
|
||||
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return { success: true, message: "Contract deleted successfully" };
|
||||
} catch (error: unknown) {
|
||||
console.error("Delete error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves dashboard statistics for the authenticated user
|
||||
*
|
||||
* Returns:
|
||||
* - Total contracts count
|
||||
* - Status breakdown (uploaded, processing, completed, failed)
|
||||
* - Contract type distribution
|
||||
* - AI learning telemetry data
|
||||
*
|
||||
* @returns Statistics object or error
|
||||
*/
|
||||
export async function getContractStats() {
|
||||
try {
|
||||
const stats = await ContractService.getStats();
|
||||
return { success: true, stats };
|
||||
} catch (error: unknown) {
|
||||
console.error("Stats error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a contract using AI service
|
||||
*
|
||||
* Steps:
|
||||
* 1. Authenticate user
|
||||
* 2. Get contract details
|
||||
* 3. Update status to PROCESSING
|
||||
* 4. Call AI service to analyze contract
|
||||
* 5. Validate AI results
|
||||
* 6. Save results to database with COMPLETED status
|
||||
* 7. Create success notification
|
||||
* 8. Return analysis results or error
|
||||
*
|
||||
* On Error:
|
||||
* - Detects if contract is invalid vs analysis failed
|
||||
* - Saves failure reason to database
|
||||
* - Creates error notification
|
||||
* - Returns appropriate error code for UI handling
|
||||
*
|
||||
* @param id - Contract ID to analyze
|
||||
* @returns Success with analysis results or error with error code
|
||||
*
|
||||
* Error Codes:
|
||||
* - INVALID_CONTRACT: File is not a valid contract document
|
||||
* - ANALYSIS_ERROR: Analysis failed during processing
|
||||
*/
|
||||
export async function analyzeContractAction(id: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
if (!user) {
|
||||
return { success: false, error: "User not found" };
|
||||
}
|
||||
|
||||
// Get contract
|
||||
const contract = await ContractService.getById(id);
|
||||
|
||||
// Verify ownership
|
||||
if (contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Update status to PROCESSING
|
||||
await ContractService.updateStatus(id, "PROCESSING");
|
||||
|
||||
// Create processing notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "INFO",
|
||||
title: "⏳ Analyzing Contract",
|
||||
message: `"${contract.fileName}" is being analyzed. This may take a few seconds...`,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_STARTED",
|
||||
icon: "Loader",
|
||||
});
|
||||
|
||||
// Analyze with AI
|
||||
const aiResults = await AIService.analyzeContract(contract.fileUrl, {
|
||||
userId: contract.userId,
|
||||
fileName: contract.fileName,
|
||||
maxRetries: 3,
|
||||
});
|
||||
|
||||
// Validate results
|
||||
if (!AIService.validateAnalysis(aiResults)) {
|
||||
console.error("❌ AI validation failed");
|
||||
await ContractService.markFailed(
|
||||
id,
|
||||
"AI validation failed. The file may be incomplete or not a valid contract.",
|
||||
);
|
||||
|
||||
// Create error notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "ERROR",
|
||||
title: "❌ Analysis Failed",
|
||||
message:
|
||||
"The AI could not validate the analysis result. The file may be incomplete or corrupted.",
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_FAILED",
|
||||
icon: "AlertCircle",
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "AI analysis validation failed. Please try again.",
|
||||
errorCode: "ANALYSIS_ERROR",
|
||||
};
|
||||
}
|
||||
|
||||
// Persist AI learning metadata inside keyPoints JSON so future analyses can adapt
|
||||
// without requiring DB schema changes.
|
||||
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,
|
||||
learnedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
// Save AI results to database (convert nulls to undefined for optional fields)
|
||||
await ContractService.updateWithAIResults(id, {
|
||||
...aiResults,
|
||||
keyPoints: keyPointsWithLearning,
|
||||
provider: aiResults.provider ?? undefined,
|
||||
policyNumber: aiResults.policyNumber ?? undefined,
|
||||
startDate: aiResults.startDate ?? undefined,
|
||||
endDate: aiResults.endDate ?? undefined,
|
||||
premium: aiResults.premium ?? undefined,
|
||||
});
|
||||
|
||||
// Create success notification with extracted info
|
||||
const contractTitle = aiResults.title || "Contract";
|
||||
const contractProvider = aiResults.provider || "Unknown Provider";
|
||||
const endDate = aiResults.endDate
|
||||
? new Date(aiResults.endDate).toLocaleDateString()
|
||||
: "N/A";
|
||||
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "✅ Contract Analyzed",
|
||||
message: `"${contractTitle}" from ${contractProvider} (Expires: ${endDate}) has been successfully analyzed and saved.`,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_SUCCESS",
|
||||
icon: "CheckCircle2",
|
||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Contract analyzed successfully!",
|
||||
contract: aiResults,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Analyze error:", error);
|
||||
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
const user = clerkId && (await ContractService.getUserByClerkId(clerkId));
|
||||
|
||||
// Update contract status to FAILED
|
||||
const reason =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
await ContractService.markFailed(id, reason);
|
||||
|
||||
// Create error notification
|
||||
if (user) {
|
||||
const contract = await ContractService.getById(id);
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "ERROR",
|
||||
title: "❌ Analysis Failed",
|
||||
message: reason,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_ERROR",
|
||||
icon: "AlertCircle",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update status or create notification", e);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
|
||||
// Detect if contract is invalid vs analysis failed
|
||||
const invalidContractSignals = [
|
||||
"not recognized as a valid contract",
|
||||
"contract confidence is too low",
|
||||
"does not contain enough contract-specific signals",
|
||||
"uploaded file is not recognized as a contract",
|
||||
"invalid_contract",
|
||||
];
|
||||
const normalizedError = errorMessage.toLowerCase();
|
||||
const isInvalidContract = invalidContractSignals.some((signal) =>
|
||||
normalizedError.includes(signal),
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
errorCode: isInvalidContract ? "INVALID_CONTRACT" : "ANALYSIS_ERROR",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks a question about a specific contract using AI
|
||||
*
|
||||
* Steps:
|
||||
* 1. Authenticate user
|
||||
* 2. Validate question is not empty
|
||||
* 3. Retrieve contract details
|
||||
* 4. Call AI service with contract context
|
||||
* 5. Return answer or error
|
||||
*
|
||||
* The AI uses the contract's extracted data to provide contextual answers about:
|
||||
* - Contract terms and conditions
|
||||
* - Dates and expiration information
|
||||
* - Coverage details
|
||||
* - Renewal terms
|
||||
* - Specific clauses and provisions
|
||||
*
|
||||
* @param id - Contract ID
|
||||
* @param question - User's question about the contract
|
||||
* @returns AI-generated answer or error
|
||||
*
|
||||
* Example Questions:
|
||||
* - "When does this insurance expire?"
|
||||
* - "What is the coverage limit?"
|
||||
* - "What are the exclusions?"
|
||||
* - "How much is the premium?"
|
||||
*/
|
||||
export async function askContractQuestionAction(id: string, question: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) {
|
||||
return { success: false, error: "Question cannot be empty" };
|
||||
}
|
||||
|
||||
// Get contract details
|
||||
const contract = await ContractService.getById(id);
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
if (!user || contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Ask AI about contract with full context
|
||||
const answer = await AIService.askAboutContract({
|
||||
question: trimmedQuestion,
|
||||
contract: {
|
||||
fileName: contract.fileName,
|
||||
title: contract.title,
|
||||
type: contract.type,
|
||||
provider: contract.provider,
|
||||
policyNumber: contract.policyNumber,
|
||||
startDate: contract.startDate,
|
||||
endDate: contract.endDate,
|
||||
premium: contract.premium
|
||||
? parseFloat(contract.premium.toString())
|
||||
: null,
|
||||
summary: contract.summary,
|
||||
keyPoints:
|
||||
(contract.keyPoints as Record<string, unknown> | null) ?? null,
|
||||
extractedText: contract.extractedText,
|
||||
language: (contract.keyPoints as any)?.aiMeta?.language ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, answer };
|
||||
} catch (error: unknown) {
|
||||
console.error("Ask contract question error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
141
features/contracts/components/forms/contract-upload-form.tsx
Normal file
141
features/contracts/components/forms/contract-upload-form.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { UploadDropzone } from "@uploadthing/react";
|
||||
import { AlertCircle, Sparkles, Wand2, ShieldCheck } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { saveContract } from "@/features/contracts/api/contract.action";
|
||||
import { toast } from "sonner";
|
||||
import type { OurFileRouter } from "@/lib/upload";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function ContractUploadForm({
|
||||
onUploadSuccess,
|
||||
}: {
|
||||
onUploadSuccess: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const emitNotificationRefresh = () => {
|
||||
window.dispatchEvent(new Event("notifications:refresh"));
|
||||
const channel = new BroadcastChannel("notifications-channel");
|
||||
channel.postMessage({ type: "notifications:refresh" });
|
||||
channel.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.14),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.1),transparent_42%)] p-0">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<svg
|
||||
viewBox="0 0 480 220"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="absolute -right-8 top-0 h-40 w-80 opacity-45"
|
||||
>
|
||||
<path
|
||||
d="M8 176C76 140 114 68 198 68C260 68 286 116 346 116C394 116 430 88 474 74"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 204C72 174 122 146 186 146C250 146 294 178 350 178C400 178 434 162 474 146"
|
||||
stroke="hsl(var(--secondary))"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5 7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="inline-flex items-center gap-1.5 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-primary">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI-Ready Intake
|
||||
</p>
|
||||
<h3 className="mt-3 text-xl font-semibold tracking-tight text-foreground">
|
||||
Upload contracts for structured extraction
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Clean intake pipeline for contract parsing, validation, and
|
||||
analysis.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||
Theme-aware secure upload
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadDropzone<OurFileRouter, "contractUploader">
|
||||
endpoint="contractUploader"
|
||||
onClientUploadComplete={async (res) => {
|
||||
if (!res || res.length === 0) {
|
||||
toast.error("Upload failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = res[0];
|
||||
|
||||
// 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");
|
||||
}
|
||||
}}
|
||||
onUploadError={(error: Error) => {
|
||||
toast.error(`Upload failed: ${error.message}`);
|
||||
}}
|
||||
appearance={{
|
||||
container:
|
||||
"w-full cursor-pointer rounded-2xl border border-dashed border-primary/35 bg-background/85 px-4 py-8 backdrop-blur-sm transition-all duration-300 hover:border-primary/55 hover:bg-background ut-uploading:cursor-not-allowed",
|
||||
button:
|
||||
"bg-gradient-to-r from-primary to-accent text-white font-semibold px-6 py-3 rounded-xl transition-all duration-300 hover:from-primary/90 hover:to-accent/90 ut-uploading:cursor-not-allowed",
|
||||
label: "text-base md:text-lg text-foreground font-semibold",
|
||||
uploadIcon: "w-11 h-11 text-primary",
|
||||
allowedContent: "mt-2 text-sm text-muted-foreground",
|
||||
}}
|
||||
content={{
|
||||
label: "Upload Your Contract",
|
||||
allowedContent: "PDF, JPG, PNG, WEBP up to 8MB",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid gap-3 border-t border-border/50 pt-5 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold text-foreground">Formats</div>
|
||||
<div>PDF, JPG, PNG, WEBP</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold text-foreground">Max Size</div>
|
||||
<div>8 MB</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground flex items-start gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Wand2 className="h-3.5 w-3.5 text-secondary" />
|
||||
Extraction quality improves as more contracts are analyzed.
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1631
features/contracts/components/list/contracts-list.tsx
Normal file
1631
features/contracts/components/list/contracts-list.tsx
Normal file
File diff suppressed because it is too large
Load Diff
47
features/contracts/components/list/empty-contracts-state.tsx
Normal file
47
features/contracts/components/list/empty-contracts-state.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Inbox } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function EmptyContractsState() {
|
||||
return (
|
||||
<Card className="border-dashed border-border hover:border-primary/50 transition-colors duration-300">
|
||||
<div className="p-12 md:p-16 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<div className="mb-6 relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 rounded-full blur-3xl"></div>
|
||||
<div className="relative p-4 bg-background dark:bg-card rounded-full border border-border/50">
|
||||
<Inbox className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md">
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||
No contracts yet
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Upload your first contract to get started.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Our AI will automatically analyze and extract key information from
|
||||
your documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-3 gap-4 w-full text-center text-xs">
|
||||
<div className="p-3 rounded-lg bg-primary/5 dark:bg-primary/10 border border-primary/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-primary" />
|
||||
<span className="text-muted-foreground">Fast Upload</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-accent/5 dark:bg-accent/10 border border-accent/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-accent" />
|
||||
<span className="text-muted-foreground">AI Analysis</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-secondary/5 dark:bg-secondary/10 border border-secondary/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-secondary" />
|
||||
<span className="text-muted-foreground">Blockchain</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
215
features/contracts/components/modals/contract-chat-modal.tsx
Normal file
215
features/contracts/components/modals/contract-chat-modal.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"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 { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { askContractQuestionAction } from "@/features/contracts/api/contract.action";
|
||||
|
||||
interface Contract {
|
||||
id: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ContractChatModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
contract: Contract | null;
|
||||
renderRichParagraphs: (text: string, prefix: string) => React.ReactNode[];
|
||||
}
|
||||
|
||||
export function ContractChatModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
contract,
|
||||
renderRichParagraphs,
|
||||
}: ContractChatModalProps) {
|
||||
const [question, setQuestion] = useState("");
|
||||
const [isAsking, setIsAsking] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Ask me anything about this contract. I will answer based on the file analysis.",
|
||||
},
|
||||
]);
|
||||
|
||||
const quickQuestions = [
|
||||
"What are the main obligations and deadlines?",
|
||||
"What are the non-compliance risks under general EU/US principles?",
|
||||
"What are the most important exclusions and liabilities?",
|
||||
];
|
||||
|
||||
const handleAskQuestion = async () => {
|
||||
if (!contract) return;
|
||||
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) return;
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", content: trimmedQuestion }]);
|
||||
setQuestion("");
|
||||
setIsAsking(true);
|
||||
|
||||
try {
|
||||
const result = await askContractQuestionAction(contract.id, trimmedQuestion);
|
||||
|
||||
if (result.success && result.answer) {
|
||||
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}` }]);
|
||||
}
|
||||
} catch (error) {
|
||||
const fallbackMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${fallbackMessage}` }]);
|
||||
} finally {
|
||||
setIsAsking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.18),transparent_40%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.12),transparent_45%)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
Ask About This File
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{contract && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/20 dark:border-white/10 bg-background/40 p-4 shadow-xl backdrop-blur-xl ring-1 ring-black/5 dark:ring-white/5 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</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">
|
||||
<Briefcase className="w-3.5 h-3.5" />
|
||||
Business
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/30 px-2 py-1">
|
||||
<Scale className="w-3.5 h-3.5" />
|
||||
Legal Context
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Quick prompts</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickQuestions.map((quickQuestion) => (
|
||||
<Button
|
||||
key={quickQuestion}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isAsking}
|
||||
onClick={() => setQuestion(quickQuestion)}
|
||||
className="border-primary/25 bg-background/80 text-xs hover:border-primary/50 hover:bg-primary/10"
|
||||
>
|
||||
{quickQuestion}
|
||||
</Button>
|
||||
))}
|
||||
</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
|
||||
key={index}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div className="flex max-w-[88%] items-start gap-2">
|
||||
{message.role === "assistant" && (
|
||||
<span className="mt-1 inline-flex h-7 w-7 items-center justify-center rounded-full border border-border/60 bg-muted/40 text-muted-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words shadow-sm transition-all duration-300 hover:shadow-md ${
|
||||
message.role === "user"
|
||||
? "bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-primary/25"
|
||||
: "border border-white/20 dark:border-white/10 bg-white/50 dark:bg-black/50 backdrop-blur-md shadow-[0_4px_30px_rgba(0,0,0,0.05)]"
|
||||
}`}
|
||||
>
|
||||
{message.role === "assistant"
|
||||
? renderRichParagraphs(message.content, `chat-assistant-${index}`)
|
||||
: message.content}
|
||||
</div>
|
||||
{message.role === "user" && (
|
||||
<span className="mt-1 inline-flex h-7 w-7 items-center justify-center rounded-full border border-primary/25 bg-primary/10 text-primary">
|
||||
<User className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isAsking && (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-border/70 bg-background px-3 py-2 text-sm shadow-sm">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Preparing a professional legal-business answer...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={question}
|
||||
onChange={(event) => setQuestion(event.target.value)}
|
||||
placeholder="Ask about obligations, liabilities, legal exposure, compliance risks, or business impact..."
|
||||
rows={3}
|
||||
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()) {
|
||||
event.preventDefault();
|
||||
void handleAskQuestion();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleAskQuestion}
|
||||
disabled={isAsking || !question.trim()}
|
||||
className="gap-2 bg-gradient-to-r from-primary to-accent text-white shadow-md hover:from-primary/90 hover:to-accent/90"
|
||||
>
|
||||
{isAsking ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Tip: press Enter to send, Shift+Enter for a new line.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
157
features/contracts/components/modals/contract-proof-modal.tsx
Normal file
157
features/contracts/components/modals/contract-proof-modal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
interface ProofData {
|
||||
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";
|
||||
}
|
||||
|
||||
interface ContractProofModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
proofData: ProofData | null;
|
||||
}
|
||||
|
||||
export function ContractProofModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
proofData,
|
||||
}: ContractProofModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] max-w-5xl overflow-y-auto border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_38%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.12),transparent_42%)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Info className="h-5 w-5 text-primary" />
|
||||
Field Proof
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{proofData && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-white/20 dark:border-white/10 bg-background/40 p-5 shadow-2xl backdrop-blur-2xl ring-1 ring-black/5 dark:ring-white/5 md:p-6 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
||||
<div className="grid auto-rows-fr gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="rounded-xl border border-primary/25 bg-primary/10 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-primary/80">
|
||||
Field
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-primary truncate">
|
||||
{proofData.field}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Line
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-foreground">
|
||||
{proofData.lineNumber ?? "Not found"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Page
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-foreground truncate">
|
||||
{proofData.page ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Section
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-foreground truncate">
|
||||
{proofData.section ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-2.5 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Confidence
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-foreground">
|
||||
{proofData.confidence ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-white/20 dark:border-white/10 bg-background/40 p-5 shadow-2xl backdrop-blur-2xl ring-1 ring-black/5 dark:ring-white/5 md:p-6 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Exact Source Snippet
|
||||
</p>
|
||||
<div className="mt-2 min-h-[92px] rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-sm italic text-muted-foreground whitespace-pre-wrap break-words">
|
||||
“{proofData.sourceSnippet}”
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-white/20 dark:border-white/10 bg-background/40 p-5 shadow-2xl backdrop-blur-2xl ring-1 ring-black/5 dark:ring-white/5 md:p-6 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Contract Lines Context
|
||||
</p>
|
||||
<span
|
||||
className={`rounded-md border px-2 py-1 text-[10px] font-medium ${
|
||||
proofData.context.length > 0 && proofData.lineNumber
|
||||
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
}`}
|
||||
>
|
||||
{proofData.context.length > 0 && proofData.lineNumber
|
||||
? "Resolved from extracted text"
|
||||
: "Fallback snippet evidence"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{proofData.context.length > 0 && proofData.contextStartLine ? (
|
||||
<div className="mt-2 h-[320px] overflow-auto rounded-xl border border-border/60 bg-muted/20">
|
||||
<pre className="p-3 text-xs leading-6 text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{proofData.context.map((line, idx) => {
|
||||
const currentLineNumber =
|
||||
proofData.contextStartLine! + idx;
|
||||
const isMatch =
|
||||
proofData.lineNumber === currentLineNumber;
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={
|
||||
isMatch ? "font-bold text-primary block" : "block"
|
||||
}
|
||||
>
|
||||
{String(currentLineNumber).padStart(4, " ")} | {line}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 h-[320px] rounded-xl border border-border/60 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Precise line mapping is unavailable for this field. The
|
||||
quoted snippet remains the verified AI evidence.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground/80">
|
||||
This usually happens when OCR compressed multiple lines,
|
||||
formatting changed, or the source value appears in a
|
||||
table-like structure.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user