Álvaro Valenzuela Valdes commited on
Commit ·
dc78036
1
Parent(s): b7ad7a5
Implement Agent Chat feature with expert selection and real-time context
Browse files- backend/app/routers/analysis.py +20 -1
- backend/app/schemas/analysis.py +14 -0
- frontend/app/page.tsx +1 -0
- frontend/components/AgentChat.tsx +175 -0
- frontend/components/TenderSearch.tsx +203 -178
backend/app/routers/analysis.py
CHANGED
|
@@ -3,8 +3,9 @@ from typing import List
|
|
| 3 |
|
| 4 |
from fastapi import APIRouter
|
| 5 |
|
| 6 |
-
from app.schemas.analysis import AnalysisRecord, AnalysisRequest, AnalysisResult
|
| 7 |
from app.services.agents import run_full_analysis
|
|
|
|
| 8 |
from app.services.persistence import save_to_json, load_from_json
|
| 9 |
|
| 10 |
router = APIRouter()
|
|
@@ -35,3 +36,21 @@ async def analyze_opportunity(request: AnalysisRequest):
|
|
| 35 |
@router.get("/analysis-history", response_model=List[AnalysisRecord])
|
| 36 |
def get_analysis_history():
|
| 37 |
return analysis_history
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from fastapi import APIRouter
|
| 5 |
|
| 6 |
+
from app.schemas.analysis import AnalysisRecord, AnalysisRequest, AnalysisResult, ChatRequest
|
| 7 |
from app.services.agents import run_full_analysis
|
| 8 |
+
from app.services.llm import call_gemini_with_model
|
| 9 |
from app.services.persistence import save_to_json, load_from_json
|
| 10 |
|
| 11 |
router = APIRouter()
|
|
|
|
| 36 |
@router.get("/analysis-history", response_model=List[AnalysisRecord])
|
| 37 |
def get_analysis_history():
|
| 38 |
return analysis_history
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("/chat")
|
| 42 |
+
async def agent_chat(request: ChatRequest):
|
| 43 |
+
# Construct context
|
| 44 |
+
history_str = "\n".join([f"{m.role.upper()}: {m.content}" for m in request.history])
|
| 45 |
+
|
| 46 |
+
prompt = (
|
| 47 |
+
f"Eres {request.agent} en AndesOps AI.\n"
|
| 48 |
+
f"CONTEXTO DE LA LICITACIÓN:\n{request.tender.model_dump_json()}\n\n"
|
| 49 |
+
f"DATOS DE MI EMPRESA:\n{request.company_profile.model_dump_json()}\n\n"
|
| 50 |
+
f"HISTORIAL DE CHAT:\n{history_str}\n\n"
|
| 51 |
+
f"PREGUNTA DEL USUARIO: {request.message}\n\n"
|
| 52 |
+
f"INSTRUCCIONES: Responde como el experto {request.agent}. Sé directo, profesional y usa los datos de la empresa para dar respuestas personalizadas. Si te preguntan sobre requisitos, búscalos en la licitación."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
response = await call_gemini_with_model(prompt, request.model)
|
| 56 |
+
return {"response": response}
|
backend/app/schemas/analysis.py
CHANGED
|
@@ -6,6 +6,20 @@ from app.schemas.company import CompanyProfile
|
|
| 6 |
from app.schemas.tender import Tender
|
| 7 |
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
class RiskItem(BaseModel):
|
| 10 |
title: str
|
| 11 |
severity: str
|
|
|
|
| 6 |
from app.schemas.tender import Tender
|
| 7 |
|
| 8 |
|
| 9 |
+
class ChatMessage(BaseModel):
|
| 10 |
+
role: str
|
| 11 |
+
content: str
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ChatRequest(BaseModel):
|
| 15 |
+
tender: Tender
|
| 16 |
+
company_profile: CompanyProfile
|
| 17 |
+
message: str
|
| 18 |
+
agent: str
|
| 19 |
+
model: str
|
| 20 |
+
history: List[ChatMessage]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
class RiskItem(BaseModel):
|
| 24 |
title: str
|
| 25 |
severity: str
|
frontend/app/page.tsx
CHANGED
|
@@ -260,6 +260,7 @@ export default function HomePage() {
|
|
| 260 |
forceShowFollowed={activeTab === "My Portfolio"}
|
| 261 |
initialKeyword={searchKeyword}
|
| 262 |
lang={lang}
|
|
|
|
| 263 |
/>
|
| 264 |
)}
|
| 265 |
{activeTab === "Market Monitor" && <MarketMonitor />}
|
|
|
|
| 260 |
forceShowFollowed={activeTab === "My Portfolio"}
|
| 261 |
initialKeyword={searchKeyword}
|
| 262 |
lang={lang}
|
| 263 |
+
companyProfile={companyProfile}
|
| 264 |
/>
|
| 265 |
)}
|
| 266 |
{activeTab === "Market Monitor" && <MarketMonitor />}
|
frontend/components/AgentChat.tsx
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from "react";
|
| 4 |
+
import type { Tender, CompanyProfile } from "../lib/types";
|
| 5 |
+
|
| 6 |
+
type Message = {
|
| 7 |
+
role: "user" | "assistant";
|
| 8 |
+
content: string;
|
| 9 |
+
agent?: string;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
type Props = {
|
| 13 |
+
tender: Tender;
|
| 14 |
+
companyProfile: CompanyProfile;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
const agents = [
|
| 18 |
+
{ id: "legal", name: "Dra. Legal", avatar: "⚖️", color: "text-amber-400" },
|
| 19 |
+
{ id: "tech", name: "Ing. Tech", avatar: "👨💻", color: "text-cyan" },
|
| 20 |
+
{ id: "risk", name: "Sra. Estrategia", avatar: "🕵️♀️", color: "text-purple-400" },
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
const models = [
|
| 24 |
+
"Gemini 2.5 Flash",
|
| 25 |
+
"DeepSeek-V3.2 (Featherless)",
|
| 26 |
+
"Qwen-2.5 (Featherless)",
|
| 27 |
+
];
|
| 28 |
+
|
| 29 |
+
export default function AgentChat({ tender, companyProfile }: Props) {
|
| 30 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 31 |
+
const [input, setInput] = useState("");
|
| 32 |
+
const [selectedAgent, setSelectedAgent] = useState(agents[0]);
|
| 33 |
+
const [selectedModel, setSelectedModel] = useState(models[0]);
|
| 34 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 35 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (scrollRef.current) {
|
| 39 |
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 40 |
+
}
|
| 41 |
+
}, [messages]);
|
| 42 |
+
|
| 43 |
+
const handleSend = async () => {
|
| 44 |
+
if (!input.trim() || isLoading) return;
|
| 45 |
+
|
| 46 |
+
const userMsg: Message = { role: "user", content: input };
|
| 47 |
+
setMessages(prev => [...prev, userMsg]);
|
| 48 |
+
setInput("");
|
| 49 |
+
setIsLoading(true);
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
const response = await fetch("/api/chat", {
|
| 53 |
+
method: "POST",
|
| 54 |
+
headers: { "Content-Type": "application/json" },
|
| 55 |
+
body: JSON.stringify({
|
| 56 |
+
tender,
|
| 57 |
+
company_profile: companyProfile,
|
| 58 |
+
message: input,
|
| 59 |
+
agent: selectedAgent.id,
|
| 60 |
+
model: selectedModel,
|
| 61 |
+
history: messages,
|
| 62 |
+
}),
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
if (!response.ok) throw new Error("Failed to chat");
|
| 66 |
+
|
| 67 |
+
const data = await response.json();
|
| 68 |
+
const assistantMsg: Message = {
|
| 69 |
+
role: "assistant",
|
| 70 |
+
content: data.response,
|
| 71 |
+
agent: selectedAgent.name
|
| 72 |
+
};
|
| 73 |
+
setMessages(prev => [...prev, assistantMsg]);
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error(error);
|
| 76 |
+
setMessages(prev => [...prev, { role: "assistant", content: "⚠️ Error connecting to the agent. Please try again." }]);
|
| 77 |
+
} finally {
|
| 78 |
+
setIsLoading(false);
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
return (
|
| 83 |
+
<div className="flex flex-col h-[600px] glass-card rounded-[2rem] overflow-hidden border border-white/10 bg-slate-900/60 backdrop-blur-xl">
|
| 84 |
+
{/* Chat Header */}
|
| 85 |
+
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/5">
|
| 86 |
+
<div className="flex items-center gap-4">
|
| 87 |
+
<div className="text-3xl">{selectedAgent.avatar}</div>
|
| 88 |
+
<div>
|
| 89 |
+
<h4 className="text-white font-bold">{selectedAgent.name}</h4>
|
| 90 |
+
<p className="text-[10px] text-slate-500 uppercase tracking-widest font-black">Expert Consultant</p>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
<div className="flex items-center gap-3">
|
| 94 |
+
<select
|
| 95 |
+
value={selectedAgent.id}
|
| 96 |
+
onChange={(e) => setSelectedAgent(agents.find(a => a.id === e.target.value) || agents[0])}
|
| 97 |
+
className="bg-white/5 border border-white/10 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-purple-500"
|
| 98 |
+
>
|
| 99 |
+
{agents.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
| 100 |
+
</select>
|
| 101 |
+
<select
|
| 102 |
+
value={selectedModel}
|
| 103 |
+
onChange={(e) => setSelectedModel(e.target.value)}
|
| 104 |
+
className="bg-white/5 border border-white/10 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-purple-500"
|
| 105 |
+
>
|
| 106 |
+
{models.map(m => <option key={m} value={m}>{m}</option>)}
|
| 107 |
+
</select>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* Messages Area */}
|
| 112 |
+
<div
|
| 113 |
+
ref={scrollRef}
|
| 114 |
+
className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar bg-black/20"
|
| 115 |
+
>
|
| 116 |
+
{messages.length === 0 && (
|
| 117 |
+
<div className="h-full flex flex-col items-center justify-center text-center space-y-4 opacity-40">
|
| 118 |
+
<div className="text-5xl">💬</div>
|
| 119 |
+
<p className="text-slate-400 text-sm max-w-xs">
|
| 120 |
+
Hi! I'm your {selectedAgent.name}. Ask me anything about this tender's requirements, risks, or strategy.
|
| 121 |
+
</p>
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
{messages.map((msg, i) => (
|
| 125 |
+
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
| 126 |
+
<div className={`max-w-[80%] rounded-2xl px-5 py-3 text-sm ${
|
| 127 |
+
msg.role === 'user'
|
| 128 |
+
? 'bg-purple-600 text-white rounded-tr-none'
|
| 129 |
+
: 'bg-white/10 text-slate-200 border border-white/10 rounded-tl-none'
|
| 130 |
+
}`}>
|
| 131 |
+
{msg.role === 'assistant' && (
|
| 132 |
+
<div className="text-[10px] font-black uppercase text-purple-400 mb-1 tracking-widest">
|
| 133 |
+
{msg.agent}
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
<p className="leading-relaxed whitespace-pre-wrap">{msg.content}</p>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
))}
|
| 140 |
+
{isLoading && (
|
| 141 |
+
<div className="flex justify-start">
|
| 142 |
+
<div className="bg-white/5 rounded-2xl rounded-tl-none px-5 py-3 border border-white/10">
|
| 143 |
+
<div className="flex gap-1">
|
| 144 |
+
<div className="w-1.5 h-1.5 bg-slate-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
| 145 |
+
<div className="w-1.5 h-1.5 bg-slate-500 rounded-full animate-bounce" style={{ animationDelay: '200ms' }} />
|
| 146 |
+
<div className="w-1.5 h-1.5 bg-slate-500 rounded-full animate-bounce" style={{ animationDelay: '400ms' }} />
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
{/* Input Area */}
|
| 154 |
+
<div className="p-6 bg-white/5 border-t border-white/5">
|
| 155 |
+
<div className="flex gap-3">
|
| 156 |
+
<input
|
| 157 |
+
type="text"
|
| 158 |
+
value={input}
|
| 159 |
+
onChange={(e) => setInput(e.target.value)}
|
| 160 |
+
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
| 161 |
+
placeholder={`Message ${selectedAgent.name}...`}
|
| 162 |
+
className="flex-1 bg-black/40 border border-white/10 rounded-2xl px-5 py-3 text-white text-sm placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all"
|
| 163 |
+
/>
|
| 164 |
+
<button
|
| 165 |
+
onClick={handleSend}
|
| 166 |
+
disabled={!input.trim() || isLoading}
|
| 167 |
+
className="w-12 h-12 rounded-2xl premium-gradient text-white flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 shadow-lg shadow-purple-500/20"
|
| 168 |
+
>
|
| 169 |
+
<span className="text-xl">✈️</span>
|
| 170 |
+
</button>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
);
|
| 175 |
+
}
|
frontend/components/TenderSearch.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import { Fragment, useMemo, useState, useRef, useEffect } from "react";
|
|
| 4 |
import BrandLoader from "./BrandLoader";
|
| 5 |
import type { Tender } from "../lib/types";
|
| 6 |
import { Language, translations } from "../lib/translations";
|
|
|
|
|
|
|
| 7 |
|
| 8 |
type Props = {
|
| 9 |
tenders: Tender[];
|
|
@@ -12,9 +14,10 @@ type Props = {
|
|
| 12 |
forceShowFollowed?: boolean;
|
| 13 |
initialKeyword?: string;
|
| 14 |
lang: Language;
|
|
|
|
| 15 |
};
|
| 16 |
|
| 17 |
-
export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFollowed = false, initialKeyword = "", lang }: Props) {
|
| 18 |
const t = translations[lang];
|
| 19 |
const [keyword, setKeyword] = useState(initialKeyword);
|
| 20 |
const [buyerCode, setBuyerCode] = useState("");
|
|
@@ -25,6 +28,7 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
|
|
| 25 |
const [selectedTenderForModal, setSelectedTenderForModal] = useState<Tender | null>(null);
|
| 26 |
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
| 27 |
const [isSyncingToAgents, setIsSyncingToAgents] = useState(false);
|
|
|
|
| 28 |
|
| 29 |
const [followedTenders, setFollowedTenders] = useState<Tender[]>(() => {
|
| 30 |
if (typeof window !== 'undefined') {
|
|
@@ -432,16 +436,33 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
|
|
| 432 |
/* Immersive Detail View (Replaces List) */
|
| 433 |
<div className="animate-in slide-in-from-right-8 fade-in duration-700 w-full max-w-[1600px] mx-auto pt-4 pb-20">
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
-
|
| 437 |
-
<button
|
| 438 |
-
onClick={() => setSelectedTenderForModal(null)}
|
| 439 |
-
className="flex items-center gap-2 text-slate-400 hover:text-white transition group w-fit"
|
| 440 |
-
>
|
| 441 |
-
<span className="text-xl group-hover:-translate-x-1 transition-transform">←</span>
|
| 442 |
-
<span className="text-sm font-bold uppercase tracking-widest">{lang === 'en' ? 'Back to search' : 'Volver a búsqueda'}</span>
|
| 443 |
-
</button>
|
| 444 |
-
<div className="grid grid-cols-2 gap-4">
|
| 445 |
<div className="px-4 py-3 rounded-lg bg-white/5 border border-white/10">
|
| 446 |
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-2">Search</div>
|
| 447 |
<p className="text-xs text-slate-300">View detailed information</p>
|
|
@@ -462,202 +483,206 @@ export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFo
|
|
| 462 |
</div>
|
| 463 |
</div>
|
| 464 |
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
<div className="
|
| 469 |
-
|
| 470 |
-
<div className="
|
| 471 |
-
<
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
<h3 className="text-3xl md:text-4xl font-black text-white leading-tight tracking-tight mb-4">{selectedTenderForModal.name}</h3>
|
| 479 |
-
|
| 480 |
-
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
|
| 481 |
-
<div className="flex items-center gap-3">
|
| 482 |
-
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">🏢</div>
|
| 483 |
-
<span className="text-slate-400 font-medium">{selectedTenderForModal.buyer}</span>
|
| 484 |
-
</div>
|
| 485 |
-
<div className="flex items-center gap-3">
|
| 486 |
-
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">📍</div>
|
| 487 |
-
<span className="text-slate-400 font-medium">{selectedTenderForModal.buyer_region || selectedTenderForModal.region || "Nacional"}</span>
|
| 488 |
</div>
|
| 489 |
-
<
|
| 490 |
-
|
| 491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
</div>
|
| 493 |
</div>
|
| 494 |
</div>
|
| 495 |
-
</div>
|
| 496 |
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
</tr>
|
| 525 |
-
</thead>
|
| 526 |
-
<tbody className="divide-y divide-white/5">
|
| 527 |
-
{selectedTenderForModal.items.map((item, idx) => (
|
| 528 |
-
<tr key={idx} className="hover:bg-white/[0.02]">
|
| 529 |
-
<td className="px-6 py-4">
|
| 530 |
-
<div className="text-slate-200 font-bold">{item.name}</div>
|
| 531 |
-
<div className="text-[10px] text-slate-500 mt-1">{item.description}</div>
|
| 532 |
-
</td>
|
| 533 |
-
<td className="px-6 py-4 text-slate-400">{item.category || "N/A"}</td>
|
| 534 |
-
<td className="px-6 py-4 text-right">
|
| 535 |
-
<span className="text-cyan font-mono font-bold">{item.quantity}</span>
|
| 536 |
-
<span className="ml-1 text-slate-500 uppercase text-[10px]">{item.unit}</span>
|
| 537 |
-
</td>
|
| 538 |
</tr>
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
</div>
|
| 555 |
-
{selectedTenderForModal.currency && selectedTenderForModal.currency !== 'CLP' && (
|
| 556 |
-
<div className="text-[10px] text-cyan mt-1 font-bold">Currency: {selectedTenderForModal.currency}</div>
|
| 557 |
-
)}
|
| 558 |
-
</div>
|
| 559 |
-
<div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
|
| 560 |
-
<div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Industry Classification</div>
|
| 561 |
-
<div className="text-xl text-white font-bold tracking-tight">{selectedTenderForModal.sector || "General Procurement"}</div>
|
| 562 |
</div>
|
| 563 |
</div>
|
| 564 |
-
</div>
|
| 565 |
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
<div className="space-y-4">
|
| 573 |
-
<div>
|
| 574 |
-
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">Closing Deadline</div>
|
| 575 |
-
<div className="text-2xl font-black text-white font-mono">
|
| 576 |
-
{selectedTenderForModal.closing_date ? new Date(selectedTenderForModal.closing_date).toLocaleDateString() : "---"}
|
| 577 |
-
</div>
|
| 578 |
-
</div>
|
| 579 |
|
| 580 |
-
|
| 581 |
<div>
|
| 582 |
-
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">
|
| 583 |
-
<div className="text-
|
| 584 |
-
{new Date(selectedTenderForModal.
|
| 585 |
</div>
|
| 586 |
</div>
|
| 587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
</div>
|
| 589 |
-
|
| 590 |
-
<p className="mt-6 text-[10px] text-purple-400/60 font-bold uppercase tracking-tighter border-t border-purple-400/10 pt-4">Final Window for Submission</p>
|
| 591 |
-
</div>
|
| 592 |
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
|
|
|
| 605 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
</div>
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
))}
|
| 610 |
-
{!selectedTenderForModal.attachments?.length && (
|
| 611 |
-
<div className="p-8 text-center rounded-2xl border border-white/5 bg-white/[0.01]">
|
| 612 |
-
<p className="text-xs text-slate-600 italic font-medium">No external files registered.</p>
|
| 613 |
-
</div>
|
| 614 |
-
)}
|
| 615 |
</div>
|
| 616 |
-
</div>
|
| 617 |
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
|
|
|
| 625 |
</div>
|
| 626 |
</div>
|
| 627 |
-
</div>
|
| 628 |
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
</div>
|
| 637 |
-
</div>
|
| 638 |
-
<div className="flex items-center gap-4 w-full md:w-auto">
|
| 639 |
-
<button
|
| 640 |
-
onClick={() => toggleFollow(selectedTenderForModal)}
|
| 641 |
-
className={`flex-1 md:flex-none px-6 py-3 text-xs font-bold uppercase tracking-widest rounded-lg border transition-all ${
|
| 642 |
-
followedCodes.includes(selectedTenderForModal.code)
|
| 643 |
-
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
|
| 644 |
-
: 'border-white/10 text-slate-400 hover:border-purple-400/40 hover:text-purple-300'
|
| 645 |
-
}`}
|
| 646 |
-
>
|
| 647 |
-
{followedCodes.includes(selectedTenderForModal.code) ? "★ In Portfolio" : "☆ Add to Portfolio"}
|
| 648 |
-
</button>
|
| 649 |
-
<button
|
| 650 |
-
onClick={() => {
|
| 651 |
-
onAnalyze(selectedTenderForModal);
|
| 652 |
-
setSelectedTenderForModal(null);
|
| 653 |
-
}}
|
| 654 |
-
className="flex-1 md:flex-none premium-gradient text-white px-8 py-3 rounded-lg font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-purple-500/30 hover:scale-105 active:scale-95 transition-all"
|
| 655 |
-
>
|
| 656 |
-
{t.analyze}
|
| 657 |
-
</button>
|
| 658 |
</div>
|
| 659 |
</div>
|
| 660 |
-
|
|
|
|
|
|
|
| 661 |
</div>
|
| 662 |
)}
|
| 663 |
|
|
|
|
| 4 |
import BrandLoader from "./BrandLoader";
|
| 5 |
import type { Tender } from "../lib/types";
|
| 6 |
import { Language, translations } from "../lib/translations";
|
| 7 |
+
import AgentChat from "./AgentChat";
|
| 8 |
+
import type { CompanyProfile } from "../lib/types";
|
| 9 |
|
| 10 |
type Props = {
|
| 11 |
tenders: Tender[];
|
|
|
|
| 14 |
forceShowFollowed?: boolean;
|
| 15 |
initialKeyword?: string;
|
| 16 |
lang: Language;
|
| 17 |
+
companyProfile: CompanyProfile;
|
| 18 |
};
|
| 19 |
|
| 20 |
+
export default function TenderSearch({ tenders, onSearch, onAnalyze, forceShowFollowed = false, initialKeyword = "", lang, companyProfile }: Props) {
|
| 21 |
const t = translations[lang];
|
| 22 |
const [keyword, setKeyword] = useState(initialKeyword);
|
| 23 |
const [buyerCode, setBuyerCode] = useState("");
|
|
|
|
| 28 |
const [selectedTenderForModal, setSelectedTenderForModal] = useState<Tender | null>(null);
|
| 29 |
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
| 30 |
const [isSyncingToAgents, setIsSyncingToAgents] = useState(false);
|
| 31 |
+
const [activeDetailTab, setActiveDetailTab] = useState<"Overview" | "Agent Chat">("Overview");
|
| 32 |
|
| 33 |
const [followedTenders, setFollowedTenders] = useState<Tender[]>(() => {
|
| 34 |
if (typeof window !== 'undefined') {
|
|
|
|
| 436 |
/* Immersive Detail View (Replaces List) */
|
| 437 |
<div className="animate-in slide-in-from-right-8 fade-in duration-700 w-full max-w-[1600px] mx-auto pt-4 pb-20">
|
| 438 |
|
| 439 |
+
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-8">
|
| 440 |
+
<div className="flex flex-col gap-4">
|
| 441 |
+
<button
|
| 442 |
+
onClick={() => setSelectedTenderForModal(null)}
|
| 443 |
+
className="flex items-center gap-2 text-slate-400 hover:text-white transition group w-fit"
|
| 444 |
+
>
|
| 445 |
+
<span className="text-xl group-hover:-translate-x-1 transition-transform">←</span>
|
| 446 |
+
<span className="text-sm font-bold uppercase tracking-widest">{lang === 'en' ? 'Back to search' : 'Volver a búsqueda'}</span>
|
| 447 |
+
</button>
|
| 448 |
+
|
| 449 |
+
<div className="flex bg-white/5 p-1 rounded-2xl border border-white/10 w-fit">
|
| 450 |
+
<button
|
| 451 |
+
onClick={() => setActiveDetailTab("Overview")}
|
| 452 |
+
className={`px-6 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest transition-all ${activeDetailTab === "Overview" ? "bg-purple-600 text-white shadow-lg" : "text-slate-500 hover:text-slate-300"}`}
|
| 453 |
+
>
|
| 454 |
+
Overview
|
| 455 |
+
</button>
|
| 456 |
+
<button
|
| 457 |
+
onClick={() => setActiveDetailTab("Agent Chat")}
|
| 458 |
+
className={`px-6 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest transition-all ${activeDetailTab === "Agent Chat" ? "bg-purple-600 text-white shadow-lg" : "text-slate-500 hover:text-slate-300"}`}
|
| 459 |
+
>
|
| 460 |
+
Agent Chat
|
| 461 |
+
</button>
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
|
| 465 |
+
<div className="grid grid-cols-2 gap-4 w-full md:w-auto">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
<div className="px-4 py-3 rounded-lg bg-white/5 border border-white/10">
|
| 467 |
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-2">Search</div>
|
| 468 |
<p className="text-xs text-slate-300">View detailed information</p>
|
|
|
|
| 483 |
</div>
|
| 484 |
</div>
|
| 485 |
|
| 486 |
+
{activeDetailTab === "Overview" ? (
|
| 487 |
+
<div className="glass-card rounded-[2.5rem] overflow-hidden border border-white/5 bg-slate-900/40 backdrop-blur-xl shadow-2xl">
|
| 488 |
+
{/* Header Section */}
|
| 489 |
+
<div className="p-10 md:p-14 border-b border-white/5 relative overflow-hidden">
|
| 490 |
+
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500/10 blur-[100px] -translate-y-1/2 translate-x-1/2" />
|
| 491 |
+
<div className="relative z-10">
|
| 492 |
+
<div className="flex items-center gap-3 mb-6">
|
| 493 |
+
<span className="text-sm font-mono text-purple-400 bg-purple-400/10 px-3 py-1 rounded-lg border border-purple-400/20">{selectedTenderForModal.code}</span>
|
| 494 |
+
<span className={`px-3 py-1 rounded-lg text-xs font-black uppercase tracking-widest ${
|
| 495 |
+
selectedTenderForModal.status.toLowerCase().includes('publicada') ? 'bg-green-500/10 text-green-400 border border-green-500/20' : 'bg-slate-800 text-slate-500'
|
| 496 |
+
}`}>
|
| 497 |
+
{selectedTenderForModal.status}
|
| 498 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
</div>
|
| 500 |
+
<h3 className="text-3xl md:text-4xl font-black text-white leading-tight tracking-tight mb-4">{selectedTenderForModal.name}</h3>
|
| 501 |
+
|
| 502 |
+
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
|
| 503 |
+
<div className="flex items-center gap-3">
|
| 504 |
+
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">🏢</div>
|
| 505 |
+
<span className="text-slate-400 font-medium">{selectedTenderForModal.buyer}</span>
|
| 506 |
+
</div>
|
| 507 |
+
<div className="flex items-center gap-3">
|
| 508 |
+
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">📍</div>
|
| 509 |
+
<span className="text-slate-400 font-medium">{selectedTenderForModal.buyer_region || selectedTenderForModal.region || "Nacional"}</span>
|
| 510 |
+
</div>
|
| 511 |
+
<div className="flex items-center gap-3">
|
| 512 |
+
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-lg">🏷️</div>
|
| 513 |
+
<span className="text-slate-400 font-medium">Type: {selectedTenderForModal.type || "N/A"}</span>
|
| 514 |
+
</div>
|
| 515 |
</div>
|
| 516 |
</div>
|
| 517 |
</div>
|
|
|
|
| 518 |
|
| 519 |
+
{/* Content Section */}
|
| 520 |
+
<div className="p-10 md:p-14">
|
| 521 |
+
<div className="grid gap-16 lg:grid-cols-3">
|
| 522 |
+
{/* Main Column */}
|
| 523 |
+
<div className="lg:col-span-2 space-y-12">
|
| 524 |
+
<section>
|
| 525 |
+
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-500 mb-6 flex items-center gap-3">
|
| 526 |
+
<span className="w-8 h-[1px] bg-slate-700" />
|
| 527 |
+
Project Scope & Description
|
| 528 |
+
</h4>
|
| 529 |
+
<div className="text-slate-300 leading-relaxed text-lg bg-white/[0.02] p-8 rounded-[2rem] border border-white/5 whitespace-pre-wrap font-light mb-12">
|
| 530 |
+
{selectedTenderForModal.description || "No detailed description provided."}
|
| 531 |
+
</div>
|
| 532 |
|
| 533 |
+
{selectedTenderForModal.items && selectedTenderForModal.items.length > 0 && (
|
| 534 |
+
<section>
|
| 535 |
+
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-500 mb-6 flex items-center gap-3">
|
| 536 |
+
<span className="w-8 h-[1px] bg-slate-700" />
|
| 537 |
+
Line Items & Requirements
|
| 538 |
+
</h4>
|
| 539 |
+
<div className="overflow-hidden rounded-3xl border border-white/5 bg-white/[0.01]">
|
| 540 |
+
<table className="w-full text-left text-xs">
|
| 541 |
+
<thead className="bg-white/5 text-slate-500 uppercase font-black tracking-tighter">
|
| 542 |
+
<tr>
|
| 543 |
+
<th className="px-6 py-4">Item / Product</th>
|
| 544 |
+
<th className="px-6 py-4">Category</th>
|
| 545 |
+
<th className="px-6 py-4 text-right">Quantity</th>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
</tr>
|
| 547 |
+
</thead>
|
| 548 |
+
<tbody className="divide-y divide-white/5">
|
| 549 |
+
{selectedTenderForModal.items.map((item, idx) => (
|
| 550 |
+
<tr key={idx} className="hover:bg-white/[0.02]">
|
| 551 |
+
<td className="px-6 py-4">
|
| 552 |
+
<div className="text-slate-200 font-bold">{item.name}</div>
|
| 553 |
+
<div className="text-[10px] text-slate-500 mt-1">{item.description}</div>
|
| 554 |
+
</td>
|
| 555 |
+
<td className="px-6 py-4 text-slate-400">{item.category || "N/A"}</td>
|
| 556 |
+
<td className="px-6 py-4 text-right">
|
| 557 |
+
<span className="text-cyan font-mono font-bold">{item.quantity}</span>
|
| 558 |
+
<span className="ml-1 text-slate-500 uppercase text-[10px]">{item.unit}</span>
|
| 559 |
+
</td>
|
| 560 |
+
</tr>
|
| 561 |
+
))}
|
| 562 |
+
</tbody>
|
| 563 |
+
</table>
|
| 564 |
+
</div>
|
| 565 |
+
</section>
|
| 566 |
+
)}
|
| 567 |
+
</section>
|
| 568 |
|
| 569 |
+
<div className="grid grid-cols-2 gap-6">
|
| 570 |
+
<div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
|
| 571 |
+
<div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Estimated Investment</div>
|
| 572 |
+
<div className="text-xl text-white font-bold tracking-tight">
|
| 573 |
+
{selectedTenderForModal.estimated_amount
|
| 574 |
+
? new Intl.NumberFormat("es-CL", { style: "currency", currency: selectedTenderForModal.currency || "CLP" }).format(selectedTenderForModal.estimated_amount)
|
| 575 |
+
: "Not Disclosed"}
|
| 576 |
+
</div>
|
| 577 |
+
{selectedTenderForModal.currency && selectedTenderForModal.currency !== 'CLP' && (
|
| 578 |
+
<div className="text-[10px] text-cyan mt-1 font-bold">Currency: {selectedTenderForModal.currency}</div>
|
| 579 |
+
)}
|
| 580 |
+
</div>
|
| 581 |
+
<div className="p-6 rounded-3xl bg-white/[0.03] border border-white/5 group hover:bg-white/[0.05] transition-colors">
|
| 582 |
+
<div className="text-[10px] uppercase text-slate-500 font-black mb-2 tracking-widest">Industry Classification</div>
|
| 583 |
+
<div className="text-xl text-white font-bold tracking-tight">{selectedTenderForModal.sector || "General Procurement"}</div>
|
| 584 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
</div>
|
| 586 |
</div>
|
|
|
|
| 587 |
|
| 588 |
+
{/* Sidebar Column */}
|
| 589 |
+
<div className="space-y-12">
|
| 590 |
+
<div className="p-8 rounded-[2rem] bg-purple-600/10 border border-purple-500/20 shadow-2xl shadow-purple-500/5 relative overflow-hidden group">
|
| 591 |
+
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/20 blur-[60px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 592 |
+
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-purple-400 mb-6">Timeline</h4>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
|
| 594 |
+
<div className="space-y-4">
|
| 595 |
<div>
|
| 596 |
+
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">Closing Deadline</div>
|
| 597 |
+
<div className="text-2xl font-black text-white font-mono">
|
| 598 |
+
{selectedTenderForModal.closing_date ? new Date(selectedTenderForModal.closing_date).toLocaleDateString() : "---"}
|
| 599 |
</div>
|
| 600 |
</div>
|
| 601 |
+
|
| 602 |
+
{selectedTenderForModal.publication_date && (
|
| 603 |
+
<div>
|
| 604 |
+
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-widest mb-1">Published On</div>
|
| 605 |
+
<div className="text-sm font-bold text-slate-300">
|
| 606 |
+
{new Date(selectedTenderForModal.publication_date).toLocaleDateString()}
|
| 607 |
+
</div>
|
| 608 |
+
</div>
|
| 609 |
+
)}
|
| 610 |
+
</div>
|
| 611 |
+
|
| 612 |
+
<p className="mt-6 text-[10px] text-purple-400/60 font-bold uppercase tracking-tighter border-t border-purple-400/10 pt-4">Final Window for Submission</p>
|
| 613 |
</div>
|
|
|
|
|
|
|
|
|
|
| 614 |
|
| 615 |
+
<div>
|
| 616 |
+
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-500 mb-6">Official Documentation</h4>
|
| 617 |
+
<div className="grid gap-3">
|
| 618 |
+
{selectedTenderForModal.attachments?.map((att, i) => (
|
| 619 |
+
<a key={i} href={att.url} target="_blank" className="flex items-center justify-between p-5 rounded-2xl bg-white/[0.03] hover:bg-white/[0.08] border border-white/5 transition-all group/file">
|
| 620 |
+
<div className="flex items-center gap-4">
|
| 621 |
+
<div className="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center text-2xl">
|
| 622 |
+
{att.name.endsWith('.pdf') ? "📕" : "📘"}
|
| 623 |
+
</div>
|
| 624 |
+
<div className="flex flex-col">
|
| 625 |
+
<span className="text-sm font-bold text-slate-200 group-hover/file:text-white transition-colors truncate max-w-[150px]">{att.name}</span>
|
| 626 |
+
<span className="text-[9px] text-slate-600 uppercase font-black">Official Basis</span>
|
| 627 |
+
</div>
|
| 628 |
</div>
|
| 629 |
+
<span className="text-xl text-slate-600 group-hover/file:text-purple-400 transition-colors">↓</span>
|
| 630 |
+
</a>
|
| 631 |
+
))}
|
| 632 |
+
{!selectedTenderForModal.attachments?.length && (
|
| 633 |
+
<div className="p-8 text-center rounded-2xl border border-white/5 bg-white/[0.01]">
|
| 634 |
+
<p className="text-xs text-slate-600 italic font-medium">No external files registered.</p>
|
| 635 |
</div>
|
| 636 |
+
)}
|
| 637 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
</div>
|
|
|
|
| 639 |
|
| 640 |
+
<a
|
| 641 |
+
href={`https://www.mercadopublico.cl/fichaLicitacion.html?code=${selectedTenderForModal.code}`}
|
| 642 |
+
target="_blank"
|
| 643 |
+
className="flex items-center justify-center gap-3 w-full p-5 rounded-2xl bg-white/5 border border-white/10 text-white text-sm font-black uppercase tracking-widest hover:bg-white/10 transition-all shadow-xl active:scale-[0.98]"
|
| 644 |
+
>
|
| 645 |
+
Mercado Público Portal ↗
|
| 646 |
+
</a>
|
| 647 |
+
</div>
|
| 648 |
</div>
|
| 649 |
</div>
|
|
|
|
| 650 |
|
| 651 |
+
{/* Action Footer */}
|
| 652 |
+
<div className="p-10 md:p-14 bg-slate-950/40 border-t border-white/5 flex flex-col md:flex-row items-center justify-between gap-8">
|
| 653 |
+
<div className="flex items-center gap-4">
|
| 654 |
+
<div className="w-12 h-12 rounded-full bg-cyan/10 border border-cyan/20 flex items-center justify-center text-cyan">🤖</div>
|
| 655 |
+
<div>
|
| 656 |
+
<p className="text-white font-bold text-sm">Ready for Analysis</p>
|
| 657 |
+
<p className="text-slate-500 text-xs">Run AI compliance check and opportunity fit assessment.</p>
|
| 658 |
+
</div>
|
| 659 |
+
</div>
|
| 660 |
+
<div className="flex items-center gap-4 w-full md:w-auto">
|
| 661 |
+
<button
|
| 662 |
+
onClick={() => toggleFollow(selectedTenderForModal)}
|
| 663 |
+
className={`flex-1 md:flex-none px-6 py-3 text-xs font-bold uppercase tracking-widest rounded-lg border transition-all ${
|
| 664 |
+
followedCodes.includes(selectedTenderForModal.code)
|
| 665 |
+
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
|
| 666 |
+
: 'border-white/10 text-slate-400 hover:border-purple-400/40 hover:text-purple-300'
|
| 667 |
+
}`}
|
| 668 |
+
>
|
| 669 |
+
{followedCodes.includes(selectedTenderForModal.code) ? "★ In Portfolio" : "☆ Add to Portfolio"}
|
| 670 |
+
</button>
|
| 671 |
+
<button
|
| 672 |
+
onClick={() => {
|
| 673 |
+
onAnalyze(selectedTenderForModal);
|
| 674 |
+
setSelectedTenderForModal(null);
|
| 675 |
+
}}
|
| 676 |
+
className="flex-1 md:flex-none premium-gradient text-white px-8 py-3 rounded-lg font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-purple-500/30 hover:scale-105 active:scale-95 transition-all"
|
| 677 |
+
>
|
| 678 |
+
{t.analyze}
|
| 679 |
+
</button>
|
| 680 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
</div>
|
| 682 |
</div>
|
| 683 |
+
) : (
|
| 684 |
+
<AgentChat tender={selectedTenderForModal} companyProfile={companyProfile} />
|
| 685 |
+
)}
|
| 686 |
</div>
|
| 687 |
)}
|
| 688 |
|