Álvaro Valenzuela Valdes commited on
Commit ·
42c0d3f
1
Parent(s): 38054b7
feat: refactor TenderSearch for build stability with sub-renders
Browse files- frontend/components/TenderSearch.tsx +210 -13
frontend/components/TenderSearch.tsx
CHANGED
|
@@ -1,29 +1,226 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState } from "react";
|
|
|
|
| 4 |
import type { Tender } from "../lib/types";
|
| 5 |
-
import { Language } from "../lib/translations";
|
|
|
|
| 6 |
import type { CompanyProfile } from "../lib/types";
|
| 7 |
|
| 8 |
type Props = {
|
| 9 |
tenders: Tender[];
|
| 10 |
-
onSearch: (params:
|
| 11 |
onAnalyze: (tender: Tender) => void;
|
|
|
|
|
|
|
| 12 |
lang: Language;
|
| 13 |
companyProfile: CompanyProfile;
|
| 14 |
};
|
| 15 |
|
| 16 |
-
export default function TenderSearch({ tenders, onSearch, onAnalyze, lang, companyProfile }: Props) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
return (
|
| 18 |
-
<div className="
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
<button
|
| 22 |
-
onClick={() => window.location.reload()}
|
| 23 |
-
className="mt-8 px-8 py-4 bg-purple-600 text-white rounded-2xl font-bold"
|
| 24 |
-
>
|
| 25 |
-
Reload Platform
|
| 26 |
-
</button>
|
| 27 |
</div>
|
| 28 |
);
|
| 29 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { 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 |
+
import AgentChat from "./AgentChat";
|
| 8 |
import type { CompanyProfile } from "../lib/types";
|
| 9 |
|
| 10 |
type Props = {
|
| 11 |
tenders: Tender[];
|
| 12 |
+
onSearch: (params: { keyword?: string; buyer?: string; provider_code?: string; org_code?: string; status?: string; code?: string; date?: string; type_code?: string; skip?: number; limit?: number; isAgile?: boolean }) => void;
|
| 13 |
onAnalyze: (tender: Tender) => void;
|
| 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("");
|
| 24 |
+
const [providerCode, setProviderCode] = useState("");
|
| 25 |
+
const [orgCode, setOrgCode] = useState("");
|
| 26 |
+
const [status, setStatus] = useState("");
|
| 27 |
+
const [date, setDate] = useState("");
|
| 28 |
+
const [typeCode, setTypeCode] = useState("");
|
| 29 |
+
const [showAdvanced, setShowAdvanced] = useState(false);
|
| 30 |
+
const [selectedTenderForModal, setSelectedTenderForModal] = useState<Tender | null>(null);
|
| 31 |
+
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
| 32 |
+
const [isSyncingToAgents, setIsSyncingToAgents] = useState(false);
|
| 33 |
+
const [activeDetailTab, setActiveDetailTab] = useState<"Overview" | "Agent Chat">("Overview");
|
| 34 |
+
|
| 35 |
+
const [followedTenders, setFollowedTenders] = useState<Tender[]>(() => {
|
| 36 |
+
if (typeof window !== 'undefined') {
|
| 37 |
+
const saved = localStorage.getItem('andes_followed_tenders_full');
|
| 38 |
+
return saved ? JSON.parse(saved) : [];
|
| 39 |
+
}
|
| 40 |
+
return [];
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
const followedCodes = useMemo(() => followedTenders.map(item => item.code), [followedTenders]);
|
| 44 |
+
const [showOnlyFollowed, setShowOnlyFollowed] = useState(forceShowFollowed);
|
| 45 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 46 |
+
const [isAgileMode, setIsAgileMode] = useState(false);
|
| 47 |
+
const isSearchPending = useRef(false);
|
| 48 |
+
|
| 49 |
+
const filteredTenders = useMemo(() => {
|
| 50 |
+
if (showOnlyFollowed) return followedTenders;
|
| 51 |
+
let list = tenders;
|
| 52 |
+
if (isAgileMode) {
|
| 53 |
+
list = list.filter(item =>
|
| 54 |
+
item.code.includes('COT26') ||
|
| 55 |
+
item.name.toLowerCase().includes('compra ágil') ||
|
| 56 |
+
item.sector?.toLowerCase().includes('agil')
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
return list;
|
| 60 |
+
}, [tenders, showOnlyFollowed, followedTenders, isAgileMode]);
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
if (forceShowFollowed) setShowOnlyFollowed(true);
|
| 64 |
+
}, [forceShowFollowed]);
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
localStorage.setItem('andes_followed_tenders_full', JSON.stringify(followedTenders));
|
| 68 |
+
}, [followedTenders]);
|
| 69 |
+
|
| 70 |
+
const toggleFollow = (tender: Tender) => {
|
| 71 |
+
setFollowedTenders(prev => {
|
| 72 |
+
const isFollowing = prev.some(item => item.code === tender.code);
|
| 73 |
+
return isFollowing ? prev.filter(item => item.code !== tender.code) : [...prev, tender];
|
| 74 |
+
});
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const handleSearch = async (e?: React.FormEvent) => {
|
| 78 |
+
if (e) e.preventDefault();
|
| 79 |
+
if (isSearchPending.current) return;
|
| 80 |
+
isSearchPending.current = true;
|
| 81 |
+
setIsLoading(true);
|
| 82 |
+
try {
|
| 83 |
+
const isCode = /^[0-9]+-[0-9]+-[A-Z0-9]+$/i.test(keyword);
|
| 84 |
+
await onSearch({
|
| 85 |
+
keyword: isCode ? undefined : keyword,
|
| 86 |
+
code: isCode ? keyword : undefined,
|
| 87 |
+
org_code: orgCode || undefined,
|
| 88 |
+
status: status || undefined,
|
| 89 |
+
type_code: typeCode || undefined,
|
| 90 |
+
date,
|
| 91 |
+
skip: 0,
|
| 92 |
+
limit: 50,
|
| 93 |
+
isAgile: isAgileMode
|
| 94 |
+
});
|
| 95 |
+
} catch (error) {
|
| 96 |
+
console.error(error);
|
| 97 |
+
} finally {
|
| 98 |
+
setIsLoading(false);
|
| 99 |
+
isSearchPending.current = false;
|
| 100 |
+
}
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const isTenderCode = /^[0-9]+-[0-9]+-[A-Z0-9]+$/i.test(keyword);
|
| 104 |
+
const isLiveSearch = Boolean(isTenderCode || orgCode || status || date || typeCode);
|
| 105 |
+
const searchButtonLabel = isLoading ? "Searching..." : isLiveSearch ? "Live MP Search" : "Fetch Active Tenders";
|
| 106 |
+
|
| 107 |
+
// VIEW: Search & List
|
| 108 |
+
const renderListView = () => (
|
| 109 |
+
<div className="space-y-8">
|
| 110 |
+
<div className={`glass-card rounded-3xl p-8 mb-4 border transition-all duration-500 ${forceShowFollowed ? 'border-purple-500/30 bg-purple-500/5 shadow-[0_0_50px_rgba(168,85,247,0.1)]' : 'border-white/10'}`}>
|
| 111 |
+
<div className="mb-6 flex justify-between items-start">
|
| 112 |
+
<div>
|
| 113 |
+
<div className="flex items-center gap-3 mb-2">
|
| 114 |
+
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center text-xl ${forceShowFollowed ? 'bg-purple-500 text-white' : 'bg-white/5 text-slate-400'}`}>
|
| 115 |
+
{forceShowFollowed ? "★" : "📡"}
|
| 116 |
+
</div>
|
| 117 |
+
<h2 className="text-3xl font-black text-white tracking-tight">{forceShowFollowed ? "My Portfolio" : "Tender Discovery"}</h2>
|
| 118 |
+
</div>
|
| 119 |
+
<p className="text-slate-400 text-sm">Real-time access to the Chilean public procurement market.</p>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{!forceShowFollowed && (
|
| 124 |
+
<form onSubmit={handleSearch} className="relative z-10 space-y-6">
|
| 125 |
+
<div className="flex flex-col md:flex-row gap-4">
|
| 126 |
+
<div className="relative flex-1">
|
| 127 |
+
<input
|
| 128 |
+
type="text"
|
| 129 |
+
placeholder="Search by name, ID, or description..."
|
| 130 |
+
className="w-full bg-slate-900/60 border border-white/10 rounded-2xl pl-6 pr-4 py-4 text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50 transition-all"
|
| 131 |
+
value={keyword}
|
| 132 |
+
onChange={(e) => setKeyword(e.target.value)}
|
| 133 |
+
/>
|
| 134 |
+
</div>
|
| 135 |
+
<div className="flex gap-2">
|
| 136 |
+
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className={`px-6 py-4 rounded-2xl border font-bold text-sm transition-all ${showAdvanced ? 'bg-purple-500/20 border-purple-500/50 text-purple-300' : 'bg-white/5 border-white/10 text-slate-400'}`}>Settings</button>
|
| 137 |
+
<button type="submit" disabled={isLoading} className="px-8 py-4 bg-purple-600 hover:bg-purple-500 text-white rounded-2xl font-bold transition-all shadow-lg active:scale-95">{searchButtonLabel}</button>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
{showAdvanced && (
|
| 142 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 p-6 glass-card rounded-3xl bg-white/[0.02] border border-white/5">
|
| 143 |
+
<div className="space-y-1"><label className="text-[10px] font-bold uppercase text-slate-500 ml-1">Date</label><input type="date" className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2 text-white text-sm [color-scheme:dark]" value={date} onChange={(e) => setDate(e.target.value)} /></div>
|
| 144 |
+
<div className="space-y-1"><label className="text-[10px] font-bold uppercase text-slate-500 ml-1">Org ID</label><input type="text" placeholder="e.g. 6945" className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2 text-white text-sm" value={orgCode} onChange={(e) => setOrgCode(e.target.value)} /></div>
|
| 145 |
+
<div className="space-y-1"><label className="text-[10px] font-bold uppercase text-slate-500 ml-1">Status</label><select className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2 text-white text-sm" value={status} onChange={(e) => setStatus(e.target.value)}><option value="">All</option><option value="5">Publicada</option><option value="7">Desierta</option></select></div>
|
| 146 |
+
<div className="space-y-1"><label className="text-[10px] font-bold uppercase text-slate-500 ml-1">Type</label><select className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2 text-white text-sm" value={typeCode} onChange={(e) => setTypeCode(e.target.value)}><option value="">All</option><option value="LE">LE</option><option value="LP">LP</option></select></div>
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
+
</form>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<div className="glass-card rounded-3xl overflow-hidden border border-white/5">
|
| 154 |
+
<table className="w-full text-left text-sm table-fixed">
|
| 155 |
+
<thead className="bg-white/5 text-slate-500 uppercase text-[10px] font-bold border-b border-white/5">
|
| 156 |
+
<tr>
|
| 157 |
+
<th className="px-6 py-5 w-[100px]">ID</th>
|
| 158 |
+
<th className="px-6 py-5 w-[300px]">Opportunity</th>
|
| 159 |
+
<th className="px-6 py-5 w-[200px]">Buyer</th>
|
| 160 |
+
<th className="px-6 py-5 text-center w-[120px]">Status</th>
|
| 161 |
+
</tr>
|
| 162 |
+
</thead>
|
| 163 |
+
<tbody className="divide-y divide-white/5">
|
| 164 |
+
{filteredTenders.map((item) => (
|
| 165 |
+
<tr key={item.code} className="hover:bg-white/[0.04] transition-colors group cursor-pointer" onClick={() => setSelectedTenderForModal(item)}>
|
| 166 |
+
<td className="px-6 py-5 font-mono text-purple-400 text-[10px]">{item.code}</td>
|
| 167 |
+
<td className="px-6 py-5"><div className="font-bold text-white truncate text-xs">{item.name}</div></td>
|
| 168 |
+
<td className="px-6 py-5 text-slate-400 text-[11px] truncate">{item.buyer}</td>
|
| 169 |
+
<td className="px-6 py-5 text-center">
|
| 170 |
+
<span className={`inline-block rounded-full px-3 py-1 text-[9px] font-black uppercase ${item.status.toLowerCase().includes('publicada') ? 'bg-green-500/10 text-green-400' : 'bg-slate-800 text-slate-500'}`}>{item.status}</span>
|
| 171 |
+
</td>
|
| 172 |
+
</tr>
|
| 173 |
+
))}
|
| 174 |
+
</tbody>
|
| 175 |
+
</table>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
);
|
| 179 |
+
|
| 180 |
+
// VIEW: Detail Modal
|
| 181 |
+
const renderDetailView = (tender: Tender) => (
|
| 182 |
+
<div className="animate-in slide-in-from-right-8 fade-in duration-700 w-full max-w-[1600px] mx-auto pt-4 pb-20">
|
| 183 |
+
<div className="flex justify-between items-end mb-8">
|
| 184 |
+
<button onClick={() => setSelectedTenderForModal(null)} className="flex items-center gap-2 text-slate-400 hover:text-white transition group">
|
| 185 |
+
<span className="text-xl group-hover:-translate-x-1 transition-transform">←</span>
|
| 186 |
+
<span className="text-sm font-bold uppercase tracking-widest">Back to search</span>
|
| 187 |
+
</button>
|
| 188 |
+
<div className="flex bg-white/5 p-1 rounded-2xl border border-white/10">
|
| 189 |
+
<button onClick={() => setActiveDetailTab("Overview")} className={`px-6 py-2.5 rounded-xl text-xs font-black uppercase transition-all ${activeDetailTab === "Overview" ? "bg-purple-600 text-white" : "text-slate-500"}`}>Overview</button>
|
| 190 |
+
<button onClick={() => setActiveDetailTab("Agent Chat")} className={`px-6 py-2.5 rounded-xl text-xs font-black uppercase transition-all ${activeDetailTab === "Agent Chat" ? "bg-purple-600 text-white" : "text-slate-500"}`}>Agent Chat</button>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{activeDetailTab === "Overview" ? (
|
| 195 |
+
<div className="glass-card rounded-[2.5rem] overflow-hidden border border-white/5 bg-slate-900/40 backdrop-blur-xl p-10 md:p-14">
|
| 196 |
+
<div className="flex items-center gap-3 mb-6">
|
| 197 |
+
<span className="text-sm font-mono text-purple-400 bg-purple-400/10 px-3 py-1 rounded-lg">{tender.code}</span>
|
| 198 |
+
<span className="px-3 py-1 rounded-lg text-xs font-black uppercase bg-green-500/10 text-green-400">{tender.status}</span>
|
| 199 |
+
</div>
|
| 200 |
+
<h3 className="text-3xl md:text-4xl font-black text-white leading-tight mb-8">{tender.name}</h3>
|
| 201 |
+
<div className="grid lg:grid-cols-3 gap-12">
|
| 202 |
+
<div className="lg:col-span-2 space-y-8">
|
| 203 |
+
<div className="text-slate-300 leading-relaxed text-lg bg-white/[0.02] p-8 rounded-[2rem] border border-white/5 whitespace-pre-wrap">{tender.description || "No description provided."}</div>
|
| 204 |
+
</div>
|
| 205 |
+
<div className="space-y-6">
|
| 206 |
+
<div className="p-8 rounded-[2rem] bg-purple-600/10 border border-purple-500/20">
|
| 207 |
+
<div className="text-[10px] text-slate-500 font-bold uppercase mb-1">Closing Deadline</div>
|
| 208 |
+
<div className="text-2xl font-black text-white font-mono">{tender.closing_date ? new Date(tender.closing_date).toLocaleDateString() : "---"}</div>
|
| 209 |
+
</div>
|
| 210 |
+
<button onClick={() => { onAnalyze(tender); setSelectedTenderForModal(null); }} className="w-full premium-gradient text-white px-8 py-4 rounded-2xl font-black uppercase shadow-xl">Analyze with AI</button>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
) : (
|
| 215 |
+
<AgentChat tender={tender} companyProfile={companyProfile} />
|
| 216 |
+
)}
|
| 217 |
+
</div>
|
| 218 |
+
);
|
| 219 |
+
|
| 220 |
return (
|
| 221 |
+
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
| 222 |
+
{selectedTenderForModal ? renderDetailView(selectedTenderForModal) : renderListView()}
|
| 223 |
+
{isLoading && <BrandLoader />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
</div>
|
| 225 |
);
|
| 226 |
}
|