TechScout/dashboard/app/discoveries/[id]/page.tsx

525 lines
21 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { FiArrowLeft, FiExternalLink, FiDownload, FiLoader } from 'react-icons/fi';
import type { DiscoveryResult, TechnologyCandidate, DeepDiveResult } from '../../types';
export default function DiscoveryDetailPage() {
const params = useParams();
const [discovery, setDiscovery] = useState<DiscoveryResult | null>(null);
const [loading, setLoading] = useState(true);
const [selectedCandidate, setSelectedCandidate] = useState<TechnologyCandidate | null>(null);
const [deepDiveLoading, setDeepDiveLoading] = useState(false);
const [deepDiveResult, setDeepDiveResult] = useState<DeepDiveResult | null>(null);
useEffect(() => {
if (params.id) {
fetch(`/api/discoveries/${params.id}`)
.then(res => res.json())
.then(data => {
setDiscovery(data);
setLoading(false);
})
.catch(err => {
console.error('Failed to load discovery:', err);
setLoading(false);
});
}
}, [params.id]);
const handleExport = () => {
if (!discovery) return;
// Create CSV content
const headers = ['Rank', 'Title', 'Organization', 'Score', 'TRL', 'Source', 'Award Amount', 'URL'];
const rows = discovery.candidates.map((c, i) => [
i + 1,
`"${c.title.replace(/"/g, '""')}"`,
`"${c.organization.replace(/"/g, '""')}"`,
(c.score * 100).toFixed(1) + '%',
c.trl_estimate || 'N/A',
c.source_type,
c.award_amount ? `$${c.award_amount.toLocaleString()}` : 'N/A',
c.url
]);
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
// Download
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `techscout_${discovery.id}_results.csv`;
a.click();
URL.revokeObjectURL(url);
};
const handleDeepDive = async (candidate: TechnologyCandidate) => {
if (!candidate || !discovery) return;
setDeepDiveLoading(true);
setDeepDiveResult(null);
try {
const response = await fetch('http://localhost:8000/api/deepdive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
organization: candidate.organization,
technology: candidate.description,
gap: discovery.capability_gap,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Deep dive failed');
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
if (data.result) {
setDeepDiveResult(data.result);
}
} catch (error) {
console.error('Deep dive failed:', error);
alert(`Deep dive failed: ${error}`);
} finally {
setDeepDiveLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading discovery...</div>
</div>
);
}
if (!discovery) {
return (
<div className="text-center py-12">
<p className="text-gray-500">Discovery not found</p>
<Link href="/discoveries" className="text-[#00d4ff] hover:underline mt-4 inline-block">
Back to discoveries
</Link>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link href="/discoveries" className="text-gray-500 hover:text-white flex items-center gap-2 mb-4">
<FiArrowLeft /> Back to discoveries
</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold mb-2">{discovery.capability_gap}</h1>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>{new Date(discovery.timestamp).toLocaleString()}</span>
<span>{discovery.search_duration_seconds.toFixed(1)}s</span>
<span>{discovery.total_results_found} total results</span>
</div>
</div>
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 bg-[#1e1e2e] rounded-lg hover:bg-[#2e2e3e] transition-colors"
>
<FiDownload /> Export CSV
</button>
</div>
</div>
{/* Query Analysis */}
<div className="bg-[#12121a] rounded-2xl border border-[#1e1e2e] p-6 mb-6">
<h2 className="text-lg font-semibold text-[#00d4ff] mb-3">Query Analysis</h2>
<p className="text-gray-300 mb-4">{discovery.decomposition.understanding}</p>
<div className="flex flex-wrap gap-2 mb-4">
{discovery.decomposition.technical_domains.map((domain, i) => (
<span
key={i}
className="px-3 py-1 bg-[#7c3aed]/20 text-[#7c3aed] rounded-full text-sm border border-[#7c3aed]/30"
>
{domain}
</span>
))}
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-center">
<StatCard label="Candidates" value={discovery.candidates.length} color="#00d4ff" />
<StatCard label="SBIR" value={discovery.source_stats.sbir || 0} color="#22c55e" />
<StatCard label="Patents" value={discovery.source_stats.patents || 0} color="#f59e0b" />
<StatCard label="Contracts" value={discovery.source_stats.contracts || 0} color="#7c3aed" />
<StatCard label="Web" value={discovery.source_stats.web || 0} color="#ef4444" />
</div>
</div>
{/* Results */}
<div className="flex gap-6">
{/* Candidate List */}
<div className="flex-1">
<h2 className="text-lg font-semibold mb-4">Technology Candidates ({discovery.candidates.length})</h2>
<div className="space-y-3">
{discovery.candidates.map((candidate, i) => (
<div
key={candidate.id}
onClick={() => setSelectedCandidate(candidate)}
className={`bg-[#12121a] rounded-xl border p-4 cursor-pointer transition-all ${
selectedCandidate?.id === candidate.id
? 'border-[#00d4ff] glow-accent'
: 'border-[#1e1e2e] hover:border-[#2e2e3e]'
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-gray-500">#{i + 1}</span>
<span className={`text-xs px-2 py-0.5 rounded ${
candidate.source_type === 'sbir' ? 'bg-[#22c55e]/20 text-[#22c55e]' :
candidate.source_type === 'patent' ? 'bg-[#f59e0b]/20 text-[#f59e0b]' :
candidate.source_type === 'contract' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' :
'bg-[#1e1e2e] text-gray-400'
}`}>
{candidate.source_type}
</span>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-[#00d4ff]">
{(candidate.score * 100).toFixed(0)}%
</div>
{candidate.trl_estimate && (
<div className="text-xs text-gray-500">TRL {candidate.trl_estimate}</div>
)}
</div>
</div>
<h3 className="font-medium text-white mb-1">{candidate.title}</h3>
<p className="text-sm text-gray-500">{candidate.organization}</p>
{candidate.award_amount && (
<p className="text-xs text-[#22c55e] mt-1">${candidate.award_amount.toLocaleString()}</p>
)}
</div>
))}
</div>
</div>
{/* Selected Candidate Detail */}
{selectedCandidate && (
<div className="w-96 bg-[#12121a] rounded-2xl border border-[#00d4ff] p-6 sticky top-8 h-fit glow-accent">
<h3 className="text-lg font-semibold text-white mb-4">{selectedCandidate.title}</h3>
<div className="space-y-4">
<div>
<label className="text-xs text-gray-500">Organization</label>
<p className="text-white">{selectedCandidate.organization}</p>
</div>
<div>
<label className="text-xs text-gray-500">Description</label>
<p className="text-gray-300 text-sm">{selectedCandidate.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-500">Score</label>
<p className="text-[#00d4ff] font-semibold">{(selectedCandidate.score * 100).toFixed(1)}%</p>
</div>
<div>
<label className="text-xs text-gray-500">TRL</label>
<p className="text-white">{selectedCandidate.trl_estimate || 'Unknown'}</p>
</div>
<div>
<label className="text-xs text-gray-500">Source</label>
<p className="text-white capitalize">{selectedCandidate.source_type}</p>
</div>
<div>
<label className="text-xs text-gray-500">Date</label>
<p className="text-white">{selectedCandidate.published_date || 'N/A'}</p>
</div>
</div>
{selectedCandidate.award_amount && (
<div>
<label className="text-xs text-gray-500">Award Amount</label>
<p className="text-[#22c55e] font-semibold">${selectedCandidate.award_amount.toLocaleString()}</p>
</div>
)}
<div className="flex gap-2 pt-4">
<a
href={selectedCandidate.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-2 bg-[#1e1e2e] text-center text-sm rounded-lg hover:bg-[#2e2e3e] transition-colors flex items-center justify-center gap-2"
>
<FiExternalLink /> View Source
</a>
<button
onClick={() => handleDeepDive(selectedCandidate)}
disabled={deepDiveLoading || selectedCandidate.organization === 'Unknown'}
title={selectedCandidate.organization === 'Unknown' ? 'Deep Dive requires a known organization' : 'Run deep dive analysis'}
className="flex-1 py-2 bg-gradient-to-r from-[#00d4ff] to-[#7c3aed] text-center text-sm rounded-lg font-semibold hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{deepDiveLoading ? (
<>
<FiLoader className="animate-spin" />
Analyzing...
</>
) : selectedCandidate.organization === 'Unknown' ? (
'No Organization'
) : (
'Deep Dive'
)}
</button>
</div>
</div>
</div>
)}
</div>
{/* Deep Dive Modal */}
{deepDiveResult && (
<DeepDiveModal
result={deepDiveResult}
onClose={() => setDeepDiveResult(null)}
/>
)}
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="bg-[#0a0a12] rounded-lg p-3">
<div className="text-2xl font-bold" style={{ color }}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
);
}
function DeepDiveModal({
result,
onClose
}: {
result: DeepDiveResult;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8">
<div className="bg-[#0a0a12] rounded-2xl border border-[#1e1e2e] max-w-4xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-[#0a0a12] border-b border-[#1e1e2e] p-6 flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold gradient-text">{result.organization}</h2>
<p className="text-gray-500 text-sm mt-1">Deep Dive Analysis</p>
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-white text-2xl"
>
&times;
</button>
</div>
<div className="p-6 space-y-6">
{/* Recommendation Banner */}
<div className={`p-4 rounded-xl border ${
result.recommendation.includes('STRONGLY RECOMMEND') ? 'bg-[#22c55e]/10 border-[#22c55e]/30 text-[#22c55e]' :
result.recommendation.includes('RECOMMEND') ? 'bg-[#00d4ff]/10 border-[#00d4ff]/30 text-[#00d4ff]' :
result.recommendation.includes('CONSIDER') ? 'bg-[#f59e0b]/10 border-[#f59e0b]/30 text-[#f59e0b]' :
'bg-[#ef4444]/10 border-[#ef4444]/30 text-[#ef4444]'
}`}>
<div className="font-semibold text-lg">{result.recommendation}</div>
</div>
{/* Assessment */}
<Section title="Assessment">
<p className="text-gray-300 whitespace-pre-wrap">{result.assessment}</p>
</Section>
{/* Company Profile */}
<Section title="Company Profile">
<div className="grid grid-cols-2 gap-4">
<InfoItem label="Headquarters" value={result.company_profile.headquarters} />
<InfoItem label="Founded" value={result.company_profile.founded} />
<InfoItem label="Employees" value={result.company_profile.employee_count} />
<InfoItem label="Website" value={result.company_profile.website} isLink />
</div>
<p className="text-gray-400 mt-4 text-sm">{result.company_profile.description}</p>
{result.company_profile.leadership.length > 0 && (
<div className="mt-4">
<label className="text-xs text-gray-500">Leadership</label>
<div className="flex flex-wrap gap-2 mt-1">
{result.company_profile.leadership.map((leader, i) => (
<span key={i} className="px-3 py-1 bg-[#1e1e2e] rounded-full text-sm">
{leader.name} - {leader.title}
</span>
))}
</div>
</div>
)}
</Section>
{/* Technology Profile */}
<Section title="Technology Profile">
<div className="grid grid-cols-2 gap-4 mb-4">
<InfoItem label="TRL Assessment" value={`TRL ${result.technology_profile.trl_assessment}`} />
<InfoItem label="Technology" value={result.technology_profile.name} />
</div>
<div className="mb-4">
<label className="text-xs text-gray-500">Technical Approach</label>
<p className="text-gray-300 text-sm mt-1">{result.technology_profile.technical_approach}</p>
</div>
<div className="mb-4">
<label className="text-xs text-gray-500">Competitive Advantage</label>
<p className="text-gray-300 text-sm mt-1">{result.technology_profile.competitive_advantage}</p>
</div>
{result.technology_profile.key_capabilities.length > 0 && (
<div className="mb-4">
<label className="text-xs text-gray-500">Key Capabilities</label>
<ul className="list-disc list-inside text-gray-300 text-sm mt-1">
{result.technology_profile.key_capabilities.map((cap, i) => (
<li key={i}>{cap}</li>
))}
</ul>
</div>
)}
{result.technology_profile.limitations.length > 0 && (
<div>
<label className="text-xs text-gray-500">Limitations</label>
<ul className="list-disc list-inside text-gray-400 text-sm mt-1">
{result.technology_profile.limitations.map((lim, i) => (
<li key={i}>{lim}</li>
))}
</ul>
</div>
)}
</Section>
{/* Contract History */}
<Section title="Contract History">
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-[#1e1e2e] rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-[#00d4ff]">{result.contract_history.total_contracts}</div>
<div className="text-xs text-gray-500">Total Contracts</div>
</div>
<div className="bg-[#1e1e2e] rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-[#22c55e]">
${(result.contract_history.total_value / 1000000).toFixed(1)}M
</div>
<div className="text-xs text-gray-500">Total Value</div>
</div>
<div className="bg-[#1e1e2e] rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-[#f59e0b]">{result.contract_history.sbir_awards.length}</div>
<div className="text-xs text-gray-500">SBIR Awards</div>
</div>
</div>
{result.contract_history.primary_agencies.length > 0 && (
<div className="mb-4">
<label className="text-xs text-gray-500">Primary Agencies</label>
<div className="flex flex-wrap gap-2 mt-1">
{result.contract_history.primary_agencies.map((agency, i) => (
<span key={i} className="px-3 py-1 bg-[#7c3aed]/20 text-[#7c3aed] rounded-full text-sm">
{agency}
</span>
))}
</div>
</div>
)}
</Section>
{/* Risk Factors */}
{result.risk_factors.length > 0 && (
<Section title="Risk Factors">
<ul className="space-y-2">
{result.risk_factors.map((risk, i) => (
<li key={i} className="flex items-start gap-2 text-[#f59e0b]">
<span className="text-[#f59e0b]">!</span>
<span className="text-gray-300">{risk}</span>
</li>
))}
</ul>
</Section>
)}
{/* News Mentions */}
{result.news_mentions.length > 0 && (
<Section title="Recent News">
<div className="space-y-3">
{result.news_mentions.slice(0, 5).map((news, i) => (
<a
key={i}
href={news.url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 bg-[#1e1e2e] rounded-lg hover:bg-[#2e2e3e] transition-colors"
>
<div className="font-medium text-white text-sm">{news.title}</div>
<div className="text-gray-500 text-xs mt-1">{news.date}</div>
</a>
))}
</div>
</Section>
)}
</div>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-[#12121a] rounded-xl border border-[#1e1e2e] p-5">
<h3 className="text-lg font-semibold text-[#00d4ff] mb-4">{title}</h3>
{children}
</div>
);
}
function InfoItem({ label, value, isLink }: { label: string; value: unknown; isLink?: boolean }) {
if (!value) return null;
// Convert value to string, handling objects
let displayValue: string;
if (typeof value === 'object' && value !== null) {
const obj = value as Record<string, unknown>;
// Handle common object shapes like {location: "..."}
if (obj.location) {
displayValue = String(obj.location);
} else if (obj.name) {
displayValue = String(obj.name);
} else if (obj.value) {
displayValue = String(obj.value);
} else {
// Fallback to JSON for unknown object shapes
displayValue = JSON.stringify(value);
}
} else {
displayValue = String(value);
}
return (
<div>
<label className="text-xs text-gray-500">{label}</label>
{isLink ? (
<a href={displayValue} target="_blank" rel="noopener noreferrer" className="block text-[#00d4ff] hover:underline text-sm">
{displayValue}
</a>
) : (
<p className="text-white text-sm">{displayValue}</p>
)}
</div>
);
}