feat: competitive intelligence & historical pattern matching layer
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useParams, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useCompany, useCompanySources, useCreateAlias, useCreateSource } from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
import {
|
||||
useCompany,
|
||||
useCompanySources,
|
||||
useCreateAlias,
|
||||
useCreateSource,
|
||||
useCompanyMacroImpacts,
|
||||
useCompanyCompetitors,
|
||||
useInferCompetitors,
|
||||
useHistoricalPatterns,
|
||||
useCompetitiveSignals,
|
||||
useCorporateDecisions,
|
||||
} from '../api/hooks';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import type { Source } from '../api/hooks';
|
||||
import type { Alias } from '../api/hooks';
|
||||
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision } from '../api/hooks';
|
||||
|
||||
const sourceCols: Column<Source>[] = [
|
||||
{ key: 'source_type', header: 'Type' },
|
||||
@@ -15,12 +25,21 @@ const sourceCols: Column<Source>[] = [
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams({ from: '/companies/$id' });
|
||||
const navigate = useNavigate();
|
||||
const { data: company, isLoading } = useCompany(id);
|
||||
const { data: sources } = useCompanySources(id);
|
||||
const [tab, setTab] = useState<'aliases' | 'sources'>('sources');
|
||||
const { data: macroData } = useCompanyMacroImpacts(company?.ticker);
|
||||
const { data: competitors } = useCompanyCompetitors(id);
|
||||
const inferCompetitors = useInferCompetitors(id);
|
||||
const { data: patterns } = useHistoricalPatterns(company?.ticker);
|
||||
const { data: signals } = useCompetitiveSignals(company?.ticker);
|
||||
const { data: decisions } = useCorporateDecisions(company?.ticker);
|
||||
const [tab, setTab] = useState<'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('sources');
|
||||
|
||||
if (isLoading || !company) return <LoadingSpinner />;
|
||||
|
||||
const tabs = ['sources', 'aliases', 'macro', 'competitors', 'patterns', 'signals', 'decisions'] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -40,12 +59,12 @@ export function CompanyDetailPage() {
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 border-b border-surface-700">
|
||||
{(['sources', 'aliases'] as const).map((t) => (
|
||||
<div className="flex gap-4 border-b border-surface-700 overflow-x-auto">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`pb-2 text-sm font-medium capitalize transition-colors ${tab === t ? 'border-b-2 border-brand-500 text-brand-300' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
className={`whitespace-nowrap pb-2 text-sm font-medium capitalize transition-colors ${tab === t ? 'border-b-2 border-brand-500 text-brand-300' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
@@ -65,6 +84,28 @@ export function CompanyDetailPage() {
|
||||
<AddAliasForm companyId={id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'macro' && (
|
||||
<MacroExposurePanel macroData={macroData} onEventClick={(eventId) => navigate({ to: '/macro/events/$id', params: { id: eventId } })} />
|
||||
)}
|
||||
|
||||
{tab === 'competitors' && (
|
||||
<CompetitorsPanel competitors={competitors ?? []} onInfer={() => inferCompetitors.mutate()} isInferring={inferCompetitors.isPending} />
|
||||
)}
|
||||
|
||||
{tab === 'patterns' && (
|
||||
<HistoricalPatternsPanel patterns={patterns ?? []} />
|
||||
)}
|
||||
|
||||
{tab === 'signals' && (
|
||||
<CompetitiveSignalsPanel signals={signals ?? []} />
|
||||
)}
|
||||
|
||||
{tab === 'decisions' && (
|
||||
<DecisionsPanel decisions={decisions ?? []} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -188,3 +229,304 @@ function AddSourceForm({ companyId }: { companyId: string }) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MacroExposurePanel({ macroData, onEventClick }: {
|
||||
macroData: { exposure_profile: import('../api/hooks').ExposureProfile | null; impacts: MacroImpactRecord[] } | undefined;
|
||||
onEventClick: (eventId: string) => void;
|
||||
}) {
|
||||
if (!macroData) return <p className="text-sm text-gray-500">Loading macro data…</p>;
|
||||
|
||||
const profile = macroData.exposure_profile;
|
||||
const impacts = macroData.impacts ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Exposure Profile */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Exposure Profile</h2>
|
||||
{!profile ? (
|
||||
<p className="text-sm text-gray-500">No exposure profile configured</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-gray-500">Market Position</dt>
|
||||
<dd><StatusBadge status={profile.market_position_tier} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Export Dependency</dt>
|
||||
<dd className="text-gray-200">{(profile.export_dependency_pct * 100).toFixed(0)}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Source</dt>
|
||||
<dd><StatusBadge status={profile.source} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Confidence</dt>
|
||||
<dd><ConfidenceBar value={profile.confidence} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Revenue Mix</dt>
|
||||
<dd className="flex flex-wrap gap-1 mt-1">
|
||||
{Object.entries(profile.geographic_revenue_mix).map(([region, pct]) => (
|
||||
<span key={region} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">
|
||||
{region}: {(pct * 100).toFixed(0)}%
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Supply Chain Regions</dt>
|
||||
<dd className="flex flex-wrap gap-1 mt-1">
|
||||
{profile.supply_chain_regions.map((r) => (
|
||||
<span key={r} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{r}</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Key Commodities</dt>
|
||||
<dd className="flex flex-wrap gap-1 mt-1">
|
||||
{profile.key_input_commodities.length > 0
|
||||
? profile.key_input_commodities.map((c) => (
|
||||
<span key={c} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{c}</span>
|
||||
))
|
||||
: <span className="text-gray-500">—</span>}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Active Macro Impacts */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Active Macro Impacts ({impacts.length})</h2>
|
||||
{impacts.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No active macro impacts</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{impacts.map((impact) => (
|
||||
<div
|
||||
key={impact.id}
|
||||
className="flex items-center justify-between rounded-lg border border-surface-700 bg-surface-950 p-3 cursor-pointer hover:border-brand-500/50"
|
||||
onClick={() => onEventClick(impact.event_id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={impact.impact_direction} />
|
||||
<ConfidenceBar value={impact.macro_impact_score} />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{impact.contributing_factors.map((f, i) => (
|
||||
<span key={i} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{f}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{new Date(impact.computed_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompetitorsPanel({ competitors, onInfer, isInferring }: {
|
||||
competitors: CompetitorRelationship[];
|
||||
onInfer: () => void;
|
||||
isInferring: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-gray-400">Active Competitors ({competitors.length})</h2>
|
||||
<button
|
||||
onClick={onInfer}
|
||||
disabled={isInferring}
|
||||
className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{isInferring ? 'Inferring…' : 'Infer Competitors'}
|
||||
</button>
|
||||
</div>
|
||||
{competitors.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No competitor relationships defined</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{competitors.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{c.ticker ?? c.company_b_id.slice(0, 8)}</span>
|
||||
<StatusBadge status={c.relationship_type} />
|
||||
<ConfidenceBar value={c.strength} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${c.source === 'manual' ? 'bg-blue-900/40 border border-blue-700/50 text-blue-400' : 'bg-emerald-900/40 border border-emerald-700/50 text-emerald-400'}`}>
|
||||
{c.source.toUpperCase()}
|
||||
</span>
|
||||
{c.bidirectional && (
|
||||
<span className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">↔ bidirectional</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoricalPatternsPanel({ patterns }: { patterns: HistoricalPattern[] }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-gray-400">Historical Patterns ({patterns.length})</h2>
|
||||
{patterns.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No historical patterns found</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{patterns.map((p, i) => (
|
||||
<Card key={i}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-200">{p.catalyst_type}</span>
|
||||
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{p.time_horizon}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${p.tier === 'major_corporate_decision' ? 'bg-orange-900/40 border border-orange-700/50 text-orange-400' : 'bg-surface-800 text-gray-400'}`}>
|
||||
{p.tier === 'major_corporate_decision' ? 'MAJOR' : 'ROUTINE'}
|
||||
</span>
|
||||
{p.insufficient_data && (
|
||||
<span className="rounded bg-yellow-900/40 border border-yellow-700/50 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">LOW DATA</span>
|
||||
)}
|
||||
</div>
|
||||
<ConfidenceBar value={p.pattern_confidence} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-gray-500">Samples: </span>
|
||||
<span className="text-gray-300">{p.sample_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Bullish: </span>
|
||||
<span className="text-green-400">{(p.bullish_pct * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Bearish: </span>
|
||||
<span className="text-red-400">{(p.bearish_pct * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Avg Strength: </span>
|
||||
<span className="text-gray-300">{(p.avg_strength * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompetitiveSignalsPanel({ signals }: { signals: CompetitiveSignal[] }) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-gray-400">Incoming Competitive Signals ({signals.length})</h2>
|
||||
{signals.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No competitive signals received</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{signals.map((s) => (
|
||||
<div key={s.id}>
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg border border-cyan-700/30 bg-cyan-900/10 p-3 cursor-pointer hover:border-cyan-500/50"
|
||||
onClick={() => setExpandedId(expandedId === s.id ? null : s.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded bg-cyan-900/40 border border-cyan-700/50 px-1.5 py-0.5 text-[10px] font-medium text-cyan-400">COMPETITIVE</span>
|
||||
<span className="font-mono text-sm text-brand-300">{s.source_ticker}</span>
|
||||
<span className="text-xs text-gray-400">→</span>
|
||||
<StatusBadge status={s.catalyst_type} />
|
||||
<StatusBadge status={s.signal_direction} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ConfidenceBar value={s.signal_strength} />
|
||||
<span className="text-xs text-gray-500">{new Date(s.computed_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{expandedId === s.id && (
|
||||
<Card className="mt-1 ml-4">
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-gray-500">Source Ticker</dt>
|
||||
<dd className="font-mono text-gray-200">{s.source_ticker}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Target Ticker</dt>
|
||||
<dd className="font-mono text-gray-200">{s.target_ticker}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Catalyst Type</dt>
|
||||
<dd className="text-gray-200">{s.catalyst_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Pattern Confidence</dt>
|
||||
<dd><ConfidenceBar value={s.pattern_confidence} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Signal Strength</dt>
|
||||
<dd><ConfidenceBar value={s.signal_strength} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Relationship Strength</dt>
|
||||
<dd><ConfidenceBar value={s.relationship_strength} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Source Document</dt>
|
||||
<dd className="font-mono text-gray-400 text-[10px]">{s.source_document_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Computed At</dt>
|
||||
<dd className="text-gray-200">{new Date(s.computed_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecisionsPanel({ decisions }: { decisions: CorporateDecision[] }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-gray-400">Corporate Decision Timeline ({decisions.length})</h2>
|
||||
{decisions.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No major corporate decisions found</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{decisions.map((d, i) => (
|
||||
<div key={i} className="flex items-start gap-4 rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xs font-medium text-gray-400">{new Date(d.date).toLocaleDateString()}</span>
|
||||
<div className="mt-1 h-full w-px bg-surface-700" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded bg-orange-900/40 border border-orange-700/50 px-1.5 py-0.5 text-[10px] font-medium text-orange-400">
|
||||
{d.catalyst_type}
|
||||
</span>
|
||||
<StatusBadge status={d.trend_direction} />
|
||||
<ConfidenceBar value={d.trend_strength} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{d.summary}</p>
|
||||
<div className="flex gap-4 text-xs text-gray-500">
|
||||
<span>Samples: {d.sample_count}</span>
|
||||
<span>Confidence: {(d.pattern_confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user